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
17 changes: 17 additions & 0 deletions CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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).
Expand Down
106 changes: 106 additions & 0 deletions packages/tui/extensions/worklog-helpers.ts
Original file line number Diff line number Diff line change
@@ -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;
}
201 changes: 201 additions & 0 deletions packages/tui/extensions/worklog-widgets.ts
Original file line number Diff line number Diff line change
@@ -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 <n|id> - 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;
});
}
Loading
Loading