diff --git a/CLI.md b/CLI.md index 2e5c8dfa..940e682f 100644 --- a/CLI.md +++ b/CLI.md @@ -826,6 +826,23 @@ Example (JSON): wl --json init ``` +### `piman` | `pi` [options] + +Launch the Pi-based TUI for browsing and managing work items with agent chat and action palette. All Worklog reads/writes use the wl CLI (no direct database access). + +Options: + +- `--in-progress` — Show only in-progress items. +- `--all` — Include completed/deleted items in the list. +- `--prefix ` — Override the default prefix. +- `--perf` — Enable performance instrumentation. + +Example: + +```sh +wl piman --in-progress +``` + ### `status` [options] Show Worklog system and database status (counts, configuration values). diff --git a/packages/tui/extensions/worklog-helpers.ts b/packages/tui/extensions/worklog-helpers.ts new file mode 100644 index 00000000..cdc529ac --- /dev/null +++ b/packages/tui/extensions/worklog-helpers.ts @@ -0,0 +1,106 @@ +/** + * Shared widget helper functions for the worklog widgets. + * + * These are pure functions that can be tested independently of the Pi runtime. + * Both the Pi extension and the unit tests import from this module. + */ + +export interface WorkItem { + id: string; + title: string; + status: string; + priority: string; + assignee?: string; + stage?: string; + issueType?: string; + description?: string; +} + +/** + * Get a status icon character for the given status. + */ +export function getStatusIcon(status: string): string { + switch (status) { + case 'in_progress': return '◐'; + case 'completed': return '✓'; + case 'blocked': return '⊘'; + default: return '○'; + } +} + +/** + * Truncate text to fit within maxLen characters. + */ +export function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen - 3) + '...'; +} + +/** + * Build the numbered work item list widget lines. + * + * @param width - Available width in characters + * @param items - Work items to display + * @param selectedIndex - Index of the currently selected item (0-based) + * @returns Array of line strings for rendering + */ +export function buildWorklogWidgetLines( + width: number, + items: WorkItem[], + selectedIndex: number +): string[] { + const maxIndex = Math.min(items.length, 9); + if (maxIndex === 0) return [' No work items found']; + + const lines: string[] = []; + lines.push(' Work Items (Ctrl+1-9 select, Ctrl+Up/Down cycle):'); + + for (let i = 0; i < maxIndex; i++) { + const item = items[i]; + const marker = i === selectedIndex ? '▸' : ' '; + const num = i + 1; + const statusIcon = getStatusIcon(item.status); + const title = truncate(item.title, width - 12); + lines.push(` ${marker} ${num}: ${statusIcon} ${title}`); + } + + if (items.length > 9) { + lines.push(` ... and ${items.length - 9} more (/worklog-select for full access)`); + } + + return lines; +} + +/** + * Build the details widget lines for the selected item. + * + * @param width - Available width in characters + * @param item - The selected work item (or null) + * @returns Array of line strings for rendering + */ +export function buildWorklogDetailsLines( + width: number, + item: WorkItem | null +): string[] { + if (!item) return [' No item selected']; + + const lines: string[] = []; + lines.push(` ${item.id}`); + lines.push(` Title: ${truncate(item.title, width - 12)}`); + lines.push(` Status: ${item.status}`); + lines.push(` Priority: ${item.priority}`); + if (item.assignee) lines.push(` Assignee: ${item.assignee}`); + if (item.stage) lines.push(` Stage: ${item.stage}`); + if (item.issueType) lines.push(` Type: ${item.issueType}`); + + // Description excerpt + if (item.description) { + const excerpt = truncate( + item.description.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim(), + width - 12 + ); + lines.push(` Summary: ${excerpt}`); + } + + return lines; +} diff --git a/packages/tui/extensions/worklog-widgets.ts b/packages/tui/extensions/worklog-widgets.ts new file mode 100644 index 00000000..92d4e1a6 --- /dev/null +++ b/packages/tui/extensions/worklog-widgets.ts @@ -0,0 +1,201 @@ +/** + * Worklog Widget Extension for Pi TUI. + * + * Provides persistent widgets below the editor showing: + * - worklog.list: numbered list of work items + * - worklog.details: metadata and description for selected item + * + * Commands: + * /worklog show - Display both widgets below the editor + * /worklog hide - Remove both widgets + * /worklog-select - Select by index or WL id + * + * Keyboard shortcuts (when widgets are visible): + * Ctrl+1..Ctrl+9 - Select work items 1-9 + * Ctrl+Up/Down - Cycle selection + * + * Usage: + * pi -e packages/tui/extensions/worklog-widgets.ts + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { execSync } from "node:child_process"; +import { buildWorklogWidgetLines, buildWorklogDetailsLines, type WorkItem } from "./worklog-helpers.js"; + +// Re-export helpers for test consumers +export { buildWorklogWidgetLines, buildWorklogDetailsLines }; + +interface WidgetState { + visible: boolean; + items: WorkItem[]; + selectedIndex: number; + error: string | null; +} + +const state: WidgetState = { + visible: false, + items: [], + selectedIndex: 0, + error: null, +}; + +/** + * Fetch work items by invoking the wl CLI. + */ +function fetchWorkItems(): { items: WorkItem[]; error: string | null } { + try { + const output = execSync("wl list --status open,in_progress --json -n 50", { + encoding: "utf-8", + timeout: 10000, + }); + // wl list returns either a bare array or { workItems: [...] } + let parsed: any; + try { + parsed = JSON.parse(output); + } catch { + return { items: [], error: "Failed to parse wl list output" }; + } + const items: WorkItem[] = Array.isArray(parsed) ? parsed : (parsed.workItems ?? []); + return { items, error: null }; + } catch (err: any) { + return { items: [], error: err.message ?? "wl list failed" }; + } +} + +/** + * Refresh widgets with current data. + */ +function refreshWidgets(ctx: any) { + const { items, error } = fetchWorkItems(); + state.items = items; + state.error = error; + if (state.selectedIndex >= items.length && items.length > 0) { + state.selectedIndex = 0; + } + + if (state.visible && ctx.ui && typeof ctx.ui.setWidget === "function") { + const listWidth = Math.max(60, process.stdout.columns || 80); + ctx.ui.setWidget("worklog.list", (_tui: any, theme: any) => ({ + render: (width: number) => { + if (error) return [` Error: ${error}`]; + return buildWorklogWidgetLines(width, items, state.selectedIndex); + }, + invalidate: () => refreshWidgets(ctx), + }), { placement: "belowEditor" }); + + const selectedItem = items[state.selectedIndex] ?? null; + ctx.ui.setWidget("worklog.details", (_tui: any, theme: any) => ({ + render: (width: number) => buildWorklogDetailsLines(width, selectedItem), + invalidate: () => {}, + }), { placement: "belowEditor" }); + } +} + +/** + * Pi extension entry point. + */ +export default function (pi: ExtensionAPI) { + // Register /worklog show command + pi.registerCommand("worklog", { + description: "Show/hide worklog widgets or select an item", + handler: async (args: string, ctx) => { + const trimmed = args.trim(); + + if (trimmed === "hide") { + state.visible = false; + ctx.ui.setWidget("worklog.list", undefined); + ctx.ui.setWidget("worklog.details", undefined); + ctx.ui.notify("Worklog widgets hidden", "info"); + return; + } + + if (trimmed === "show" || trimmed === "") { + state.visible = true; + refreshWidgets(ctx); + ctx.ui.notify("Worklog widgets shown below editor", "info"); + return; + } + + ctx.ui.notify("Usage: /worklog show | /worklog hide", "info"); + }, + }); + + // Register /worklog-select command + pi.registerCommand("worklog-select", { + description: "Select a work item by index (1-9) or id", + handler: async (args: string, ctx) => { + if (!state.visible || state.items.length === 0) { + ctx.ui.notify("Show worklog first with /worklog show", "info"); + return; + } + + const trimmed = args.trim(); + if (!trimmed) { + ctx.ui.notify("Usage: /worklog-select <1-9|id>", "info"); + return; + } + + // Try numeric index + const num = parseInt(trimmed, 10); + if (!isNaN(num) && num >= 1 && num <= state.items.length) { + state.selectedIndex = num - 1; + } else { + // Try work item ID match + const idx = state.items.findIndex(item => + item.id.toLowerCase() === trimmed.toLowerCase() || + item.id.toLowerCase().endsWith(trimmed.toLowerCase()) + ); + if (idx >= 0) { + state.selectedIndex = idx; + } else { + ctx.ui.notify(`No work item matching "${trimmed}"`, "error"); + return; + } + } + + refreshWidgets(ctx); + const item = state.items[state.selectedIndex]; + ctx.ui.notify(`Selected: ${item.id} - ${item.title}`, "info"); + }, + }); + + // Register keyboard shortcuts for selection + for (let i = 1; i <= 9; i++) { + pi.registerShortcut(`ctrl+${i}`, { + description: `Select work item ${i}`, + handler: async (ctx) => { + if (!state.visible || state.items.length === 0) return; + const idx = i - 1; + if (idx < state.items.length) { + state.selectedIndex = idx; + refreshWidgets(ctx); + } + }, + }); + } + + pi.registerShortcut("ctrl+up", { + description: "Cycle worklog selection up", + handler: async (ctx) => { + if (!state.visible || state.items.length === 0) return; + state.selectedIndex = (state.selectedIndex - 1 + state.items.length) % state.items.length; + refreshWidgets(ctx); + }, + }); + + pi.registerShortcut("ctrl+down", { + description: "Cycle worklog selection down", + handler: async (ctx) => { + if (!state.visible || state.items.length === 0) return; + state.selectedIndex = (state.selectedIndex + 1) % state.items.length; + refreshWidgets(ctx); + }, + }); + + // On session start, fetch initial data (but don't show widgets until user requests) + pi.on("session_start", async (_event, ctx) => { + const { items, error } = fetchWorkItems(); + state.items = items; + state.error = error; + }); +} diff --git a/packages/tui/tests/worklog-widgets.test.ts b/packages/tui/tests/worklog-widgets.test.ts new file mode 100644 index 00000000..b8fe8673 --- /dev/null +++ b/packages/tui/tests/worklog-widgets.test.ts @@ -0,0 +1,201 @@ +/** + * Unit tests for worklog widget helper functions. + * + * Run: npx vitest run packages/tui/tests/worklog-widgets.test.ts + */ + +import { describe, it, expect } from 'vitest'; + +// Import the widget helper functions +import { + buildWorklogWidgetLines, + buildWorklogDetailsLines, + getStatusIcon, + truncate, + type WorkItem, +} from '../extensions/worklog-helpers.js'; + +const mockItems = [ + { + id: 'WL-001', + title: 'Implement chat pane', + status: 'in_progress', + priority: 'high', + assignee: 'alice', + stage: 'in_progress', + issueType: 'feature', + description: 'Build the chat pane UI component', + }, + { + id: 'WL-002', + title: 'Fix bug in action palette', + status: 'open', + priority: 'medium', + assignee: 'bob', + issueType: 'bug', + description: 'The action palette crashes when there are no items', + }, + { + id: 'WL-003', + title: 'Update documentation', + status: 'open', + priority: 'low', + issueType: 'task', + description: '', + }, +]; + +describe('buildWorklogWidgetLines', () => { + it('returns a no-items message when given an empty array', () => { + const lines = buildWorklogWidgetLines(80, [], 0); + expect(lines.length).toBeGreaterThan(0); + expect(lines.join('\n')).toContain('No work items'); + }); + + it('renders items with numbered indices', () => { + const lines = buildWorklogWidgetLines(80, mockItems, 0); + expect(lines.some(l => l.includes('1:'))).toBe(true); + expect(lines.some(l => l.includes('2:'))).toBe(true); + expect(lines.some(l => l.includes('3:'))).toBe(true); + }); + + it('marks the selected item with a pointer', () => { + const lines = buildWorklogWidgetLines(80, mockItems, 1); + const selectedIndexLine = lines.find(l => l.includes('2:')); + expect(selectedIndexLine).toBeDefined(); + expect(selectedIndexLine).toContain('▸'); + // Non-selected items should not have the pointer + const nonSelectedLine = lines.find(l => l.includes('1:') && !l.includes('▸')); + expect(nonSelectedLine).toBeDefined(); + }); + + it('includes status icons', () => { + const lines = buildWorklogWidgetLines(80, mockItems, 0); + const joined = lines.join('\n'); + expect(joined).toContain('◐'); // in_progress + expect(joined).toContain('○'); // open + }); + + it('truncates long titles', () => { + const longItem = { + ...mockItems[0], + title: 'This is an extremely long work item title that should be truncated to fit the available width of the terminal', + }; + const lines = buildWorklogWidgetLines(40, [longItem], 0); + const titleLine = lines.find(l => l.includes('1:')); + expect(titleLine).toBeDefined(); + expect(titleLine!.length).toBeLessThanOrEqual(40); + expect(titleLine).toContain('...'); + }); + + it('limits display to 9 items with a "more" note', () => { + const manyItems = Array.from({ length: 15 }, (_, i) => ({ + ...mockItems[0], + id: `WL-${i + 1}`, + title: `Item ${i + 1}`, + })); + const lines = buildWorklogWidgetLines(80, manyItems, 0); + // Should have header + 9 items + "more" note + expect(lines.length).toBeLessThanOrEqual(12); + expect(lines.some(l => l.includes('more'))).toBe(true); + }); + + it('handles narrow width constraints by truncating titles', () => { + const lines = buildWorklogWidgetLines(30, mockItems, 0); + // Item lines (not header) should be truncated to fit + const itemLines = lines.filter(l => l.match(/^\s+\d:/)); + for (const line of itemLines) { + expect(line.length).toBeLessThanOrEqual(30); + } + }); +}); + +describe('buildWorklogDetailsLines', () => { + it('returns a no-selection message when given null', () => { + const lines = buildWorklogDetailsLines(80, null as any); + expect(lines.some(l => l.includes('No item selected'))).toBe(true); + }); + + it('renders item id, title, status, and priority', () => { + const lines = buildWorklogDetailsLines(80, mockItems[0]); + const joined = lines.join('\n'); + expect(joined).toContain('WL-001'); + expect(joined).toContain('Implement chat pane'); + expect(joined).toContain('in_progress'); + expect(joined).toContain('high'); + }); + + it('includes optional fields when present', () => { + const lines = buildWorklogDetailsLines(80, mockItems[0]); + const joined = lines.join('\n'); + expect(joined).toContain('alice'); + expect(joined).toContain('feature'); + }); + + it('omits optional fields when not present', () => { + const lines = buildWorklogDetailsLines(80, mockItems[2]); + const joined = lines.join('\n'); + expect(joined).not.toContain('Assignee:'); + expect(joined).not.toContain('Stage:'); + }); + + it('includes description summary when present', () => { + const lines = buildWorklogDetailsLines(80, mockItems[0]); + expect(lines.some(l => l.includes('Summary:'))).toBe(true); + }); + + it('truncates long descriptions', () => { + const longDescItem = { + ...mockItems[0], + description: 'A'.repeat(500), + }; + const lines = buildWorklogDetailsLines(40, longDescItem); + const summaryLine = lines.find(l => l.includes('Summary:')); + expect(summaryLine).toBeDefined(); + expect(summaryLine!.length).toBeLessThanOrEqual(40); + }); + + it('handles empty description gracefully', () => { + const lines = buildWorklogDetailsLines(80, mockItems[2]); + expect(lines.some(l => l.includes('Summary:'))).toBe(false); + }); +}); + +describe('getStatusIcon', () => { + it('returns a progress icon for in_progress', () => { + expect(getStatusIcon('in_progress')).toBe('◐'); + }); + + it('returns a check icon for completed', () => { + expect(getStatusIcon('completed')).toBe('✓'); + }); + + it('returns a blocked icon for blocked', () => { + expect(getStatusIcon('blocked')).toBe('⊘'); + }); + + it('returns a circle icon for unknown statuses', () => { + expect(getStatusIcon('unknown')).toBe('○'); + expect(getStatusIcon('open')).toBe('○'); + }); +}); + +describe('truncate', () => { + it('returns the original text when it fits', () => { + expect(truncate('short', 10)).toBe('short'); + }); + + it('truncates and adds ellipsis when text is too long', () => { + const result = truncate('hello world', 8); + expect(result).toBe('hello...'); + expect(result.length).toBe(8); + }); + + it('handles exact length match', () => { + expect(truncate('exact', 5)).toBe('exact'); + }); + + it('handles empty string', () => { + expect(truncate('', 10)).toBe(''); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 1f5dacd5..f6e0b4f4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,6 +28,7 @@ import closeCommand from './commands/close.js'; import recentCommand from './commands/recent.js'; import pluginsCommand from './commands/plugins.js'; import tuiCommand from './commands/tui.js'; +import pimanCommand from './commands/piman.js'; import migrateCommand from './commands/migrate.js'; import depCommand from './commands/dep.js'; import reSortCommand from './commands/re-sort.js'; @@ -243,6 +244,7 @@ const builtInCommands = [ recentCommand, pluginsCommand, tuiCommand, + pimanCommand, migrateCommand, depCommand, reSortCommand, @@ -273,6 +275,7 @@ const builtInCommandNames = new Set([ 'recent', 'plugins', 'tui', + 'piman', 'migrate', 'dep', 're-sort', diff --git a/src/commands/piman.ts b/src/commands/piman.ts new file mode 100644 index 00000000..4e198c37 --- /dev/null +++ b/src/commands/piman.ts @@ -0,0 +1,45 @@ +/** + * Piman command - Pi-based TUI for work items. + * + * This is the Pi-native entry point for the TUI. Unlike `wl tui` which + * was the original entry point, `wl piman` explicitly signals the Pi-based + * implementation that uses the wl CLI for all database operations (no direct + * SQLite access). + * + * Usage: + * wl piman # Launch TUI with all items + * wl piman --in-progress # Show only in-progress items + * wl piman --all # Include completed/deleted items + */ + +import type { PluginContext } from '../plugin-types.js'; +import { TuiController } from '../tui/controller.js'; + +export default function register(ctx: PluginContext): void { + const controller = new TuiController(ctx); + const { program } = ctx; + + program + .command('piman') + .alias('pi') + .description('Pi-based TUI: browse and manage work items with agent chat and action palette') + .option('--in-progress', 'Show only in-progress items') + .option('--all', 'Include completed/deleted items in the list') + .option('--prefix ', 'Override the default prefix') + .option('--perf', 'Enable performance instrumentation') + .action(async (options: PimanOptions) => { + await controller.start({ + inProgress: options.inProgress, + all: options.all, + prefix: options.prefix, + perf: options.perf, + }); + }); +} + +interface PimanOptions { + inProgress?: boolean; + all?: boolean; + prefix?: string; + perf?: boolean; +}