Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Changelog

## v1.0.2 (2026-07-08)
### Other
- Integrate model-display extension into the Worklog extension (WL-0MRC3F30E008XJN3)
- Move provider/model display to start of Worklog extension status line (WL-0MRC91VIN0089UXD)
- Change audit/review question mark icon from red to grey in selection list (WL-0MRCN2HF5003HSG9)
- Model display extension is not showing the provider (WL-0MRC3NOVZ000P0P5)

9 changes: 9 additions & 0 deletions docs/AUDIT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,17 @@ All acceptance criteria verified."

# Set audit via the new dedicated command
wl audit-set SA-123 --ready-to-close --summary "All acceptance criteria verified." --author agent

# Mark an audit as approved by manual review
wl audit-set SA-123 --ready-to-close yes --summary "Manual review approved." --author "reviewer-name"

# Mark an audit as not yet ready (e.g., after a failed review)
wl audit-set SA-123 --ready-to-close no --summary "Manual review failed: criteria X not met."
```

> **Note:** `--ready-to-close` accepts both a bare flag (`--ready-to-close`) which defaults to `yes`,
> and an explicit value (`--ready-to-close yes` or `--ready-to-close no`).

### Viewing audit results

```bash
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "worklog",
"version": "1.0.1",
"version": "1.0.2",
"description": "A simple experimental issue tracker for AI agents",
"main": "dist/index.js",
"type": "module",
Expand Down
3 changes: 3 additions & 0 deletions packages/tui/extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,9 @@ The `/wl` command registers `getArgumentCompletions`, so Pi's editor shows autoc

## Shortcuts

Source config file: `packages/tui/extensions/Worklog/shortcuts.json`
(installed to `~/.pi/agent/extensions/worklog/Worklog/shortcuts.json`).

The `shortcuts.json` config file defines a **config-driven shortcut system** that allows keyboard shortcuts in the Pi extension's worklog browse views (list and detail) to be expressed declaratively rather than hardcoded.

### Schema
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/extensions/Worklog/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { worklogConfig } from './config.js';
import { reloadSettings, currentSettings, STAGE_MAP, VALID_STAGES, updateSettings, openSettingsOverlay } from './lib/settings.js';
import { runWl, defaultListWorkItems, defaultListWorkItemsWithStage, createDefaultListWorkItems, createListWorkItemsWithStage, createDefaultListWorkItemsDb, createListWorkItemsWithStageDb, fetchTotalActionableCountDb } from './lib/tools.js';
import { registerAutoInject } from './lib/auto-inject.js';
import { registerModelDisplay } from './model-display.js';
import { INSTALL_GUARDRAILS } from './lib/guardrails.js';
import { registerSkillPathTool } from './lib/skill-path.js';
import { registerRecoveryModule } from './lib/recovery/register-recovery.js';
Expand Down Expand Up @@ -88,6 +89,7 @@ export function createWorklogBrowseExtension(deps: WorklogBrowseDependencies = {
return function registerWorklogBrowseExtension(pi: ExtensionAPI): void {
registerActivityIndicator(pi, () => currentSettings.showActivityIndicator);
registerAutoInject(pi);
registerModelDisplay(pi);
INSTALL_GUARDRAILS(pi, { enabled: currentSettings.guardrailsEnabled });

// ── Skill path discovery tool ─────────────────────────────────
Expand Down
239 changes: 239 additions & 0 deletions packages/tui/extensions/Worklog/model-display.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
/**
* Unit tests for model-display.ts — Model/provider display in Pi's status bar.
*
* Verifies that:
* 1. registerModelDisplay() sets up event listeners for model_select and
* after_provider_response.
* 2. The status display shows only the resolved provider/model (no model alias).
* 3. after_provider_response updates the status bar with the resolved
* provider/model from X-Resolved-Model header.
* 4. When no proxy response has been received yet, the status bar shows (pending).
* 5. The status bar key is "worklog-0model" (not "model-display"),
* with the `0` prefix ensuring it sorts first in the extension status line.
* 6. The module exports the MODEL_DISPLAY_STATUS_KEY constant.
*
* Run: npx vitest run packages/tui/extensions/Worklog/model-display.test.ts
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';

// ── Mock @earendil-works/pi-coding-agent ──────────────────────────────

vi.mock('@earendil-works/pi-coding-agent', () => ({
getAgentDir: () => '/home/test-user/.pi/agent',
}));

// ── Module under test ─────────────────────────────────────────────────

import { registerModelDisplay, MODEL_DISPLAY_STATUS_KEY } from './model-display.js';

describe('model-display', () => {
/** Tracks event listeners registered by registerModelDisplay. */
const registeredListeners: Record<string, Function> = {};
/** Tracks status bar calls: { key: value } */
const statusCalls: Record<string, string | undefined> = {};

/** Mock ExtensionAPI with on() and a minimal context with ui. */
let mockPi: any;
let mockCtx: any;

beforeEach(() => {
// Reset tracking
Object.keys(registeredListeners).forEach(k => delete registeredListeners[k]);
Object.keys(statusCalls).forEach(k => delete statusCalls[k]);

// Mock extension context
mockCtx = {
ui: {
setStatus: vi.fn((key: string, value: string | undefined) => {
statusCalls[key] = value;
}),
},
};

// Mock Pi API
mockPi = {
on: vi.fn((event: string, handler: Function) => {
registeredListeners[event] = handler;
}),
};
});

// ── Exports ───────────────────────────────────────────────────────

it('exports MODEL_DISPLAY_STATUS_KEY', () => {
expect(MODEL_DISPLAY_STATUS_KEY).toBe('worklog-0model');
});

it('exports registerModelDisplay as a function', () => {
expect(typeof registerModelDisplay).toBe('function');
});

// ── Event registration ───────────────────────────────────────────

it('registers model_select listener', () => {
registerModelDisplay(mockPi);
expect(mockPi.on).toHaveBeenCalledWith('model_select', expect.any(Function));
expect(registeredListeners['model_select']).toBeDefined();
});

it('registers after_provider_response listener', () => {
registerModelDisplay(mockPi);
expect(mockPi.on).toHaveBeenCalledWith('after_provider_response', expect.any(Function));
expect(registeredListeners['after_provider_response']).toBeDefined();
});

// ── model_select behavior ────────────────────────────────────────

it('model_select sets status to (pending) when no response yet', () => {
registerModelDisplay(mockPi);
const handler = registeredListeners['model_select'];

handler({ model: { id: 'plan', provider: 'openai' } }, mockCtx);

expect(mockCtx.ui.setStatus).toHaveBeenCalledWith(
'worklog-0model',
'(pending)',
);
});

it('model_select without model id does not crash and does not set status', () => {
registerModelDisplay(mockPi);
const handler = registeredListeners['model_select'];

handler({ model: null }, mockCtx);

// No status call expected because there's nothing to display
expect(mockCtx.ui.setStatus).not.toHaveBeenCalled();
});

it('model_select no longer shows model alias when after_provider_response was previously received', () => {
registerModelDisplay(mockPi);
const modelHandler = registeredListeners['model_select'];
const responseHandler = registeredListeners['after_provider_response'];

// First, receive a provider response
responseHandler({ headers: { 'x-resolved-model': 'openai/gpt-4' } }, mockCtx);
expect(mockCtx.ui.setStatus).toHaveBeenLastCalledWith(
'worklog-0model',
'openai/gpt-4',
);

// Then, select a model — should still show provider/model only (no model alias)
modelHandler({ model: { id: 'plan' } }, mockCtx);
expect(mockCtx.ui.setStatus).toHaveBeenLastCalledWith(
'worklog-0model',
'openai/gpt-4',
);
});

// ── after_provider_response behavior ──────────────────────────────

it('after_provider_response extracts X-Resolved-Model from headers (no arrow prefix)', () => {
registerModelDisplay(mockPi);
const handler = registeredListeners['after_provider_response'];

handler({ headers: { 'x-resolved-model': 'openai/gpt-4' } }, mockCtx);

expect(mockCtx.ui.setStatus).toHaveBeenCalledWith(
'worklog-0model',
'openai/gpt-4',
);
});

it('after_provider_response handles uppercase X-Resolved-Model header', () => {
registerModelDisplay(mockPi);
const handler = registeredListeners['after_provider_response'];

handler({ headers: { 'X-Resolved-Model': 'anthropic/claude-3' } }, mockCtx);

expect(mockCtx.ui.setStatus).toHaveBeenCalledWith(
'worklog-0model',
'anthropic/claude-3',
);
});

it('after_provider_response without resolved model header does not crash', () => {
registerModelDisplay(mockPi);
const handler = registeredListeners['after_provider_response'];

handler({ headers: {} }, mockCtx);

// No call expected because nothing to update
expect(mockCtx.ui.setStatus).not.toHaveBeenCalled();
});

it('after_provider_response shows provider/model (no model alias) when model was previously selected', () => {
registerModelDisplay(mockPi);
const modelHandler = registeredListeners['model_select'];
const responseHandler = registeredListeners['after_provider_response'];

// First, select a model
modelHandler({ model: { id: 'code' } }, mockCtx);

// Then, receive a provider response
responseHandler({ headers: { 'x-resolved-model': 'anthropic/claude-sonnet-4' } }, mockCtx);

expect(mockCtx.ui.setStatus).toHaveBeenLastCalledWith(
'worklog-0model',
'anthropic/claude-sonnet-4',
);
});

// ── Status key verification ───────────────────────────────────────

it('uses status key "worklog-0model" (sorts first) not "model-display"', () => {
registerModelDisplay(mockPi);
const handler = registeredListeners['model_select'];

handler({ model: { id: 'plan', provider: 'openai' } }, mockCtx);

// Verify the old key is NOT used
expect(statusCalls['model-display']).toBeUndefined();
// Verify the new key IS used
expect(statusCalls['worklog-0model']).toBeDefined();
});

// ── Edge cases ───────────────────────────────────────────────────

it('handles multiple model_select calls, staying in pending state', () => {
registerModelDisplay(mockPi);
const handler = registeredListeners['model_select'];

handler({ model: { id: 'plan' } }, mockCtx);
handler({ model: { id: 'code' } }, mockCtx);

expect(mockCtx.ui.setStatus).toHaveBeenLastCalledWith(
'worklog-0model',
'(pending)',
);
});

it('handles multiple after_provider_response calls, overwriting previous resolved model', () => {
registerModelDisplay(mockPi);
const modelHandler = registeredListeners['model_select'];
const responseHandler = registeredListeners['after_provider_response'];

modelHandler({ model: { id: 'code' } }, mockCtx);
responseHandler({ headers: { 'x-resolved-model': 'openai/gpt-4' } }, mockCtx);
responseHandler({ headers: { 'x-resolved-model': 'anthropic/claude-sonnet-4' } }, mockCtx);

expect(mockCtx.ui.setStatus).toHaveBeenLastCalledWith(
'worklog-0model',
'anthropic/claude-sonnet-4',
);
});

it('after_provider_response with no prior model_select shows provider/model only', () => {
registerModelDisplay(mockPi);
const handler = registeredListeners['after_provider_response'];

// Without any prior model_select
handler({ headers: { 'x-resolved-model': 'openai/gpt-4' } }, mockCtx);

expect(mockCtx.ui.setStatus).toHaveBeenCalledWith(
'worklog-0model',
'openai/gpt-4',
);
});
});
87 changes: 87 additions & 0 deletions packages/tui/extensions/Worklog/model-display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Model display module for the Worklog Pi extension.
*
* Displays the resolved provider/model from llama-proxy responses in Pi's
* status bar. Integrated from the standalone model-display extension
* (previously at packages/tui/extensions/model-display.ts).
*
* Shows the format: `<provider>/<model-id>` (e.g., `openai/gpt-4`)
* without the Pi model alias prefix.
*
* - Listens to `after_provider_response` to read the `X-Resolved-Model` header
* that the llama-proxy adds to every proxy-routed request.
* - Listens to `model_select` to track whether a model is selected (for the
* `(pending)` fallback state), but does not display the model alias.
* - Updates the status bar on every request so the user always sees which
* provider and model actually served their request.
*
* Requires the llama-proxy to be configured as Pi's provider and to include
* the `X-Resolved-Model` header in its responses (see LP-0MR4ZIGDT004A3E1).
*/

import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';

/**
* Status key used for the model/provider display in the footer.
* The `0` prefix ensures this status sorts first in the extension status
* line (localeCompare order), placing the provider/model at the start.
* Changed from "model-display" when this module was integrated into the
* Worklog extension, to avoid conflicts with other status entries.
*/
export const MODEL_DISPLAY_STATUS_KEY = 'worklog-0model';

/**
* Register model-display event handlers with a Pi extension instance.
*
* Sets up listeners for:
* - `model_select` — tracks whether a model is selected (for `(pending)` state)
* - `after_provider_response` — reads the `X-Resolved-Model` header from
* llama-proxy responses and updates the status bar
*
* The status display shows only the resolved provider/model from the
* `X-Resolved-Model` header. When no response has been received yet but a
* model is selected, it shows `(pending)`. The Pi model alias is not shown.
*
* @param pi - The ExtensionAPI instance
*/
export function registerModelDisplay(pi: ExtensionAPI): void {
// Track whether a model is selected (used for the (pending) fallback state)
let selectedModel: string | null = null;

// Track the resolved model from the last proxy response
let resolvedModel: string | null = null;

// Update the status display
function updateStatus(ctx: { ui: { setStatus: (key: string, value: string) => void } }) {
if (resolvedModel) {
ctx.ui.setStatus(MODEL_DISPLAY_STATUS_KEY, resolvedModel);
} else if (selectedModel) {
ctx.ui.setStatus(MODEL_DISPLAY_STATUS_KEY, '(pending)');
}
// If neither resolvedModel nor selectedModel, no status call (nothing to display)
}

// Track model selection changes (used only for (pending) fallback)
pi.on('model_select', (_event, ctx) => {
// event.model has provider and id fields
const model = _event.model;
if (model && model.id) {
selectedModel = model.id;
}
updateStatus(ctx);
});

// Extract resolved model from proxy response headers
pi.on('after_provider_response', (event, ctx) => {
// event.headers contains the normalized response headers
// X-Resolved-Model header is lowercase in normalized form
const headers = event.headers as Record<string, string>;
const headerValue: string | undefined =
headers['x-resolved-model'] ?? headers['X-Resolved-Model'];

if (headerValue) {
resolvedModel = headerValue;
}
updateStatus(ctx);
});
}
Loading
Loading