diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..ecb771f6 --- /dev/null +++ b/CHANGELOG.md @@ -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) + diff --git a/docs/AUDIT_STATUS.md b/docs/AUDIT_STATUS.md index 591cd694..ebd5eb3f 100644 --- a/docs/AUDIT_STATUS.md +++ b/docs/AUDIT_STATUS.md @@ -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 diff --git a/package.json b/package.json index d9941ac9..81db6969 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/tui/extensions/README.md b/packages/tui/extensions/README.md index 6732ed9a..7011ac60 100644 --- a/packages/tui/extensions/README.md +++ b/packages/tui/extensions/README.md @@ -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 diff --git a/packages/tui/extensions/Worklog/index.ts b/packages/tui/extensions/Worklog/index.ts index 61997898..f852a805 100644 --- a/packages/tui/extensions/Worklog/index.ts +++ b/packages/tui/extensions/Worklog/index.ts @@ -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'; @@ -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 ───────────────────────────────── diff --git a/packages/tui/extensions/Worklog/model-display.test.ts b/packages/tui/extensions/Worklog/model-display.test.ts new file mode 100644 index 00000000..b76655b4 --- /dev/null +++ b/packages/tui/extensions/Worklog/model-display.test.ts @@ -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 = {}; + /** Tracks status bar calls: { key: value } */ + const statusCalls: Record = {}; + + /** 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', + ); + }); +}); diff --git a/packages/tui/extensions/Worklog/model-display.ts b/packages/tui/extensions/Worklog/model-display.ts new file mode 100644 index 00000000..31a0e2b0 --- /dev/null +++ b/packages/tui/extensions/Worklog/model-display.ts @@ -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: `/` (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; + const headerValue: string | undefined = + headers['x-resolved-model'] ?? headers['X-Resolved-Model']; + + if (headerValue) { + resolvedModel = headerValue; + } + updateStatus(ctx); + }); +} diff --git a/packages/tui/extensions/Worklog/shortcut-config.test.ts b/packages/tui/extensions/Worklog/shortcut-config.test.ts index 5b27c7f9..a6727cef 100644 --- a/packages/tui/extensions/Worklog/shortcut-config.test.ts +++ b/packages/tui/extensions/Worklog/shortcut-config.test.ts @@ -210,7 +210,7 @@ describe('loadShortcutConfig', () => { it('loads valid entries from shortcuts.json', () => { const registry = loadShortcutConfig(); const entries = registry.getEntries(); - expect(entries).toHaveLength(15); + expect(entries).toHaveLength(17); const createEntry = entries.find(e => e.key === 'c'); expect(createEntry).toBeDefined(); @@ -244,15 +244,7 @@ describe('loadShortcutConfig', () => { expect(intakeEntry!.label).toBe('intake'); expect(intakeEntry!.description).toBe('Ensure that the selected item is reasonably well defined in terms of objectives.'); - const auditEntry = entries.find(e => e.key === 'a'); - expect(auditEntry).toBeDefined(); - expect(auditEntry!.command).toBe('/skill:audit '); - expect(auditEntry!.view).toBe('both'); - expect(auditEntry!.stages).toEqual(['in_progress', 'in_review']); - expect(auditEntry!.label).toBe('audit'); - expect(auditEntry!.description).toBe('Run an audit on the selected work item'); - - expect(entries.filter(e => e.key === '').length).toBe(9); // 9 chord entries have empty key + expect(entries.filter(e => e.key === '').length).toBe(12); // 12 chord entries have empty key }); it('has no duplicate key+view or chord+view combinations in shortcuts.json', () => { @@ -297,18 +289,17 @@ describe('loadShortcutConfig', () => { expect(registry.lookup('n', 'detail', 'idea')).toBe('/intake '); expect(registry.lookup('n', 'detail', 'intake_complete')).toBeUndefined(); - // 'a' (audit) has stages: ['in_progress', 'in_review'] - expect(registry.lookup('a', 'list', 'in_review')).toBe('/skill:audit '); - expect(registry.lookup('a', 'detail', 'in_review')).toBe('/skill:audit '); - expect(registry.lookup('a', 'list', 'in_progress')).toBe('/skill:audit '); - expect(registry.lookup('a', 'list', 'idea')).toBeUndefined(); + // 'a' is now a chord leader (a,a for audit, a+y for approve, a+r for reject), + // so single-key lookup for 'a' returns undefined + expect(registry.lookup('a', 'list', 'in_review')).toBeUndefined(); + expect(registry.lookup('a', 'detail', 'in_review')).toBeUndefined(); + expect(registry.lookup('a', 'list', 'in_progress')).toBeUndefined(); // Without stage parameter, entries with stages constraint still work // (backward compatible when calling without stage) expect(registry.lookup('n', 'detail')).toBe('/intake '); expect(registry.lookup('i', 'list')).toBe('/skill:implement '); expect(registry.lookup('p', 'list')).toBe('/plan '); - expect(registry.lookup('a', 'list')).toBe('/skill:audit '); }); it('loads chord entries from shortcuts.json', () => { @@ -316,7 +307,7 @@ describe('loadShortcutConfig', () => { const entries = registry.getEntries(); const upChords = registry.getChordEntries(); - expect(upChords).toHaveLength(9); + expect(upChords).toHaveLength(12); const upEntry = upChords.find((e: any) => Array.isArray((e as any).chord) && (e as any).chord[0] === 'u' && (e as any).chord[1] === 'p', diff --git a/packages/tui/extensions/Worklog/shortcuts.json b/packages/tui/extensions/Worklog/shortcuts.json index e6ad9611..f36582f2 100644 --- a/packages/tui/extensions/Worklog/shortcuts.json +++ b/packages/tui/extensions/Worklog/shortcuts.json @@ -29,13 +29,29 @@ "stages": ["intake_complete", "plan_complete", "in_progress"], "label": "implement", "description": "Run the implement workflow on the selected work item" + }, + { + "chord": ["a", "y"], + "command": "!!wl audit-set --ready-to-close yes --summary 'Approved by manual review'", + "view": "both", + "stages": ["in_review"], + "label": "audit approve", + "description": "Approve the in_review item by manual audit." + }, + { + "chord": ["a", "r"], + "command": "!!wl audit-set --ready-to-close no --summary 'Rejected by manual review. '", + "view": "both", + "stages": ["in_review"], + "label": "audit reject", + "description": "Reject the in_review item by manual audit." }, { - "key": "a", + "chord": ["a", "a"], "command": "/skill:audit ", "view": "both", "stages": ["in_progress", "in_review"], - "label": "audit", + "label": "audit automatic", "description": "Run an audit on the selected work item" }, { diff --git a/packages/tui/extensions/Worklog/terminal-utils.test.ts b/packages/tui/extensions/Worklog/terminal-utils.test.ts index 35702d3a..269fb38b 100644 --- a/packages/tui/extensions/Worklog/terminal-utils.test.ts +++ b/packages/tui/extensions/Worklog/terminal-utils.test.ts @@ -170,7 +170,7 @@ describe('terminal-utils', () => { it('handles mixed icons with VS16 sequences correctly', () => { // 🔓 (2) + space (1) + 🛠️ (2) + space (1) + ❓ (2) = 8 - expect(visibleWidth('\u{1F513} \u{1F6E0}\uFE0F \u{2753}')).toBe(8); + expect(visibleWidth('\u{1F513} \u{1F6E0}\uFE0F \u{2754}')).toBe(8); }); it('treats ZWJ sequences correctly counting only visible characters', () => { diff --git a/src/icons.ts b/src/icons.ts index 010dcdc2..6edc557a 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -287,7 +287,7 @@ function auditKey(result: boolean | null | undefined): string { const AUDIT_ICON: Record = { yes: '\u{2705}', // ✅ no: '\u{274C}', // ❌ - unknown: '\u{2753}', // ❓ + unknown: '\u{2754}', // ❔ }; const AUDIT_FALLBACK: Record = { diff --git a/src/version.ts b/src/version.ts index 14260576..50fff331 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Auto-generated; do not edit. -export const WORKLOG_VERSION = '1.0.0'; +export const WORKLOG_VERSION = '1.0.1'; diff --git a/tests/cli/status.test.ts b/tests/cli/status.test.ts index cd5b60da..5d0abe00 100644 --- a/tests/cli/status.test.ts +++ b/tests/cli/status.test.ts @@ -108,7 +108,7 @@ describe('CLI Status Tests', () => { expect(stdout).toContain('Worklog System Status'); expect(stdout).toContain('Initialized: Yes'); - expect(stdout).toContain('Version: 1.0.0'); + expect(stdout).toContain('Version: 1.0.1'); expect(stdout).toContain('Configuration:'); expect(stdout).toContain('Database Summary:'); expect(stdout).toContain('Work Items:'); diff --git a/tests/unit/icons.test.ts b/tests/unit/icons.test.ts index ceefa52a..ca42bcc9 100644 --- a/tests/unit/icons.test.ts +++ b/tests/unit/icons.test.ts @@ -443,11 +443,11 @@ describe('auditIcon', () => { }); it('returns emoji for unknown (null)', () => { - expect(auditIcon(null)).toBe('\u{2753}'); // ❓ + expect(auditIcon(null)).toBe('\u{2754}'); // ❔ }); it('returns emoji for unknown (undefined)', () => { - expect(auditIcon(undefined)).toBe('\u{2753}'); // ❓ + expect(auditIcon(undefined)).toBe('\u{2754}'); // ❔ }); describe('with noIcons option', () => {