diff --git a/README.md b/README.md index a597a21..e7fdefc 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Example append call: Task input keys are explicit so strict tool-schema bridges do not invent values for omitted properties. Use `description: null` when no detail is needed, `status: "pending"` for the default state, `parentId: null` for root tasks and nested children, and `children: []` for leaves. `prepareArguments` still expands legacy calls that omit these fields. -To keep model-generated input safe and bounded, a session may contain up to 200 tasks. One mutation may affect up to 50 explicitly supplied tasks, hierarchy depth is limited to eight levels, names to 200 Unicode characters, and descriptions to 4,000 Unicode characters. Names and descriptions reject terminal controls and bidirectional override characters; descriptions may still contain line feeds for normal multi-line detail. +To keep model-generated input safe and bounded, a session may contain up to 200 tasks. One mutation may affect up to 50 explicitly supplied tasks. The widget header acts as a virtual root above three stored task levels: categories, items, and leaf sub-items. Names are limited to 200 Unicode characters and descriptions to 4,000 Unicode characters. Names and descriptions reject terminal controls and bidirectional override characters; descriptions may still contain line feeds for normal multi-line detail. IDs are not reused by append/remove operations; `replace` deliberately rebuilds IDs from `1`. Replace forbids non-null `parentId`, so new hierarchy must use nested `children`. Multiple tasks may be `in-progress`. A parent can be marked `done` only when all descendants are also `done`. @@ -98,14 +98,15 @@ A task starts timing the first time it enters `in-progress`. Leaving that state ## Pi Widget -The widget appears above Pi's editor while tasks exist. Run `/tasks` to hide or show it for the current Pi runtime. It shows at most 12 task rows plus a compact count of hidden tasks, so larger plans do not crowd the editor out of an 80×24 terminal. +The widget appears above Pi's editor while tasks exist. Run `/tasks` to hide or show it for the current Pi runtime. Its header is the virtual task-tree root. Root tasks render as Roman-numeral categories, their children as numbered items, and leaf sub-items use indentation without another label. Internal task IDs remain unchanged and visible in tool output. The widget shows at most 12 task rows plus a compact count of hidden tasks, so larger plans do not crowd the editor out of an 80×24 terminal. ```text Tasks 2/6 · !1 ━━━━━──────── - ◉ 1. Build auth · 12:08 - ├─ ! 3. Resolve token issue · 03:41 - └─ ○ 4. Add middleware - ✓ 2. Define schema · 04:31 + I. ◉ Build auth · 12:08 + ├─ 1. ! Resolve token issue · 03:41 + │ └─ ○ Inspect provider response + └─ 2. ○ Add middleware + II. ✓ Define schema · 04:31 ``` Tool calls remain compact in the conversation. Expand a tool result with Pi's normal tool-output shortcut to inspect affected tasks or full descriptions. diff --git a/src/limits.ts b/src/limits.ts index 02a00bb..c42d506 100644 --- a/src/limits.ts +++ b/src/limits.ts @@ -1,6 +1,6 @@ export const MAX_TASKS = 200; export const MAX_TASKS_PER_MUTATION = 50; -export const MAX_TASK_DEPTH = 8; +export const MAX_TASK_DEPTH = 3; export const MAX_TASK_NAME_LENGTH = 200; export const MAX_TASK_DESCRIPTION_LENGTH = 4_000; export const MAX_WIDGET_TASK_ROWS = 12; diff --git a/src/tools.ts b/src/tools.ts index 1ab3b91..131b2bd 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -336,7 +336,7 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange: name: "set_tasks", label: "Set Tasks", description: - `Atomically append tasks or replace the task tree. Supply at most ${MAX_TASKS_PER_MUTATION} total tasks with no more than ${MAX_TASK_DEPTH} hierarchy levels. Pass force false unless unfinished work is explicitly discarded.`, + `Atomically append tasks or replace the task tree. Supply at most ${MAX_TASKS_PER_MUTATION} total tasks across ${MAX_TASK_DEPTH} stored levels: root categories, child items, and leaf sub-items beneath the widget's virtual root. Pass force false unless unfinished work is explicitly discarded.`, promptGuidelines: [...TASK_PROMPT_GUIDELINES], parameters: SetTaskParams, prepareArguments: prepareSetTaskArguments, diff --git a/src/ui.ts b/src/ui.ts index 33945c5..89d42df 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -19,6 +19,8 @@ interface DescendantStats { interface TreeRow { task: Task; prefix: string; + depth: number; + siblingIndex: number; collapsed?: DescendantStats; } @@ -49,23 +51,23 @@ function buildTreeRows(tasks: Task[], collapseCompleted: boolean): TreeRow[] { const memo = new Map(); const rows: TreeRow[] = []; - const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number) => { + const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number, siblingIndex: number) => { const ancestors = ancestorHasNext.map((hasNext) => (hasNext ? "│ " : " ")).join(""); const connector = depth === 0 ? "" : isLast ? "└─ " : "├─ "; const stats = collapseCompleted ? descendantStats(task, byId, memo) : undefined; const collapsed = stats && stats.total > 0 && stats.done === stats.total ? stats : undefined; - rows.push({ task, prefix: `${ancestors}${connector}`, ...(collapsed ? { collapsed } : {}) }); + rows.push({ task, prefix: `${ancestors}${connector}`, depth, siblingIndex, ...(collapsed ? { collapsed } : {}) }); if (collapsed) return; const children = task.childrenIds.map((id) => byId.get(id)).filter((child): child is Task => child !== undefined); children.forEach((child, index) => { const childAncestors = depth === 0 ? [] : [...ancestorHasNext, !isLast]; - visit(child, childAncestors, index === children.length - 1, depth + 1); + visit(child, childAncestors, index === children.length - 1, depth + 1, index); }); }; const roots = tasks.filter((task) => task.parentId === null); - roots.forEach((root, index) => visit(root, [], index === roots.length - 1, 0)); + roots.forEach((root, index) => visit(root, [], index === roots.length - 1, 0, index)); return rows; } @@ -103,27 +105,39 @@ function renderTaskLineWithSuffix( theme: Theme, width: number, suffix?: string, + identifier = `${task.id}.`, + identifierBeforeStatus = false, ): string { - const line = - theme.fg("dim", prefix) + - styleStatusIcon(task, theme) + - " " + - theme.fg("accent", `${task.id}.`) + - " " + - styleTaskName(task, theme); + const status = styleStatusIcon(task, theme); + const styledIdentifier = identifier.length === 0 ? "" : theme.fg("accent", identifier); + const identifierFits = + !identifierBeforeStatus || + visibleWidth(prefix) + visibleWidth(styledIdentifier) + 1 + visibleWidth(status) <= width; + const visibleIdentifier = identifierFits ? styledIdentifier : ""; + const markers = identifierBeforeStatus ? [visibleIdentifier, status] : [status, visibleIdentifier]; + const leading = theme.fg("dim", prefix) + markers.filter((marker) => marker.length > 0).join(" "); + const styledName = styleTaskName(task, theme); + const renderMain = (targetWidth: number): string => { + const safeWidth = Math.max(1, targetWidth); + if (!identifierBeforeStatus) return truncateToWidth(`${leading} ${styledName}`, safeWidth); + + const availableNameWidth = safeWidth - visibleWidth(leading) - 1; + if (availableNameWidth < 1) return truncateToWidth(leading, safeWidth); + return `${leading} ${truncateToWidth(styledName, availableNameWidth)}`; + }; const trailing = [ suffix === undefined ? undefined : theme.fg("dim", ` · ${suffix}`), task.durationMs === undefined ? undefined : theme.fg("dim", ` · ${formatDuration(task.durationMs)}`), ] .filter((part): part is string => part !== undefined) .join(""); - if (trailing.length === 0) return truncateToWidth(line, Math.max(1, width)); + if (trailing.length === 0) return renderMain(width); const trailingWidth = visibleWidth(trailing); - const minimumTaskWidth = suffix === undefined ? 6 : 12; - if (width <= trailingWidth + minimumTaskWidth) return truncateToWidth(line, Math.max(1, width)); + const minimumTaskWidth = Math.max(suffix === undefined ? 6 : 12, visibleWidth(leading)); + if (width <= trailingWidth + minimumTaskWidth) return renderMain(width); const taskWidth = Math.max(1, width - trailingWidth); - return truncateToWidth(line, taskWidth) + trailing; + return renderMain(taskWidth) + trailing; } export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: number): string { @@ -134,12 +148,42 @@ export function renderTree(tasks: Task[], theme: Theme, width: number, padding = return orderedTreeRows(tasks).map(({ task, prefix }) => renderTaskLine(task, `${padding}${prefix}`, theme, width)); } +function toRomanNumeral(value: number): string { + const numerals: Array<[number, string]> = [ + [100, "C"], + [90, "XC"], + [50, "L"], + [40, "XL"], + [10, "X"], + [9, "IX"], + [5, "V"], + [4, "IV"], + [1, "I"], + ]; + let remaining = value; + let result = ""; + for (const [amount, numeral] of numerals) { + while (remaining >= amount) { + result += numeral; + remaining -= amount; + } + } + return result; +} + +function widgetOutlineLabel(depth: number, siblingIndex: number): string { + if (depth === 0) return `${toRomanNumeral(siblingIndex + 1)}.`; + if (depth === 1) return `${siblingIndex + 1}.`; + return ""; +} + export function renderWidgetTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] { const rows = buildTreeRows(tasks, true); const visibleRows = rows.slice(0, MAX_WIDGET_TASK_ROWS); - const lines = visibleRows.map(({ task, prefix, collapsed }) => { + const lines = visibleRows.map(({ task, prefix, depth, siblingIndex, collapsed }) => { const summary = collapsed === undefined ? undefined : `${collapsed.done}/${collapsed.total} done`; - return renderTaskLineWithSuffix(task, `${padding}${prefix}`, theme, width, summary); + const identifier = widgetOutlineLabel(depth, siblingIndex); + return renderTaskLineWithSuffix(task, `${padding}${prefix}`, theme, width, summary, identifier, true); }); if (rows.length <= MAX_WIDGET_TASK_ROWS) return lines; diff --git a/tests/state.test.ts b/tests/state.test.ts index 9cb39b9..0284812 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -415,7 +415,7 @@ test("enforces per-mutation and per-session task limits atomically", () => { ); }); -test("accepts eight task levels and rejects deeper trees atomically", () => { +test("allows three stored levels beneath the virtual root and rejects deeper task trees atomically", () => { const nested = (depth: number) => { let task: { name: string; children?: Array<{ name: string; children?: unknown[] }> } = { name: `Level ${depth}` }; for (let level = depth - 1; level >= 1; level -= 1) { @@ -425,10 +425,27 @@ test("accepts eight task levels and rejects deeper trees atomically", () => { }; const state = new TaskState(); - state.add([nested(8) as never]); - assert.equal(state.list().length, 8); + state.add([nested(3) as never, { name: "Other category" }]); + assert.equal(state.list().length, 4); const before = state.snapshot(); - assert.throws(() => state.add([nested(9) as never]), /depth must not exceed 8 levels/); + assert.throws(() => state.add([nested(4) as never]), /depth must not exceed 3 levels/); + assert.deepEqual(state.snapshot(), before); + assert.throws(() => state.update([{ id: 4, parentId: 3 }]), /depth must not exceed 3 levels/); + assert.deepEqual(state.snapshot(), before); + + assert.throws( + () => + state.restore({ + tasks: [ + { id: 1, name: "Category", status: "pending", parentId: null, childrenIds: [2] }, + { id: 2, name: "Item", status: "pending", parentId: 1, childrenIds: [3] }, + { id: 3, name: "Leaf", status: "pending", parentId: 2, childrenIds: [4] }, + { id: 4, name: "Too deep", status: "pending", parentId: 3, childrenIds: [] }, + ], + nextId: 5, + }), + /depth must not exceed 3 levels/, + ); assert.deepEqual(state.snapshot(), before); }); diff --git a/tests/ui.test.ts b/tests/ui.test.ts index bd46378..b8c2848 100644 --- a/tests/ui.test.ts +++ b/tests/ui.test.ts @@ -92,11 +92,11 @@ test("collapses completed widget subtrees while keeping list trees expanded", () const widgetLines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi); assert.deepEqual(widgetLines, [ - " ○ 1. Release", - " ├─ ○ 2. Completed branch · 2/2 done", - " └─ ○ 5. Open branch", - " └─ ○ 6. Open leaf", - " ○ 7. Pending parent · 1/1 done", + " I. ○ Release", + " ├─ 1. ○ Completed branch · 2/2 done", + " └─ 2. ○ Open branch", + " └─ ○ Open leaf", + " II. ○ Pending parent · 1/1 done", ]); const fullTree = renderTree(state.list(), plainTheme as never, 80).map(stripAnsi); @@ -109,7 +109,75 @@ test("collapses completed widget subtrees while keeping list trees expanded", () assert.ok(reopened.some((line) => line.includes("Done two"))); }); -test("caps the widget at twelve task rows with an overflow summary", () => { +test("uses sibling-local Roman and numeric widget labels while leaving leaf sub-items unlabeled", () => { + const state = new TaskState(() => 0); + state.add([ + { name: "Category one" }, + { name: "Category two" }, + { name: "Category three" }, + { + name: "Category four", + children: [ + { name: "First item", children: [{ name: "Leaf sub-item" }] }, + { name: "Second item" }, + ], + }, + { name: "Category five", children: [{ name: "Reset item numbering" }] }, + ]); + + const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi); + assert.deepEqual(lines, [ + " I. ○ Category one", + " II. ○ Category two", + " III. ○ Category three", + " IV. ○ Category four", + " ├─ 1. ○ First item", + " │ └─ ○ Leaf sub-item", + " └─ 2. ○ Second item", + " V. ○ Category five", + " └─ 1. ○ Reset item numbering", + ]); + + const narrow = renderWidgetTree(state.list(), plainTheme as never, 18); + assert.ok(narrow.every((line) => visibleWidth(line) <= 18)); + + const statusPriority = renderWidgetTree(state.list(), plainTheme as never, 6).map(stripAnsi); + assert.match(statusPriority[3] ?? "", /○/); + const childStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 8).map(stripAnsi); + assert.match(childStatusPriority[4] ?? "", /○/); + + const timedState = new TaskState(() => 0); + timedState.add([ + { name: "Timed category", status: "in-progress", children: [{ name: "Timed item", status: "in-progress" }] }, + ]); + const timedRoot = stripAnsi(renderWidgetTree(timedState.list(), plainTheme as never, 15)[0] ?? ""); + const timedChild = stripAnsi(renderWidgetTree(timedState.list(), plainTheme as never, 18)[1] ?? ""); + assert.match(timedRoot, /◉/); + assert.match(timedChild, /◉/); +}); + +test("renumbers widget labels after task reordering and reparenting", () => { + const state = new TaskState(() => 0); + state.add([ + { name: "Category A", children: [{ name: "A1" }, { name: "A2" }] }, + { name: "Category B", children: [{ name: "B1" }] }, + ]); + state.update([ + { id: 4, position: 0 }, + { id: 3, parentId: 4, position: 0 }, + ]); + + const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi); + assert.deepEqual(lines, [ + " I. ○ Category B", + " ├─ 1. ○ A2", + " └─ 2. ○ B1", + " II. ○ Category A", + " └─ 1. ○ A1", + ]); +}); + +test("caps the widget at twelve task rows with an honest overflow summary", () => { const state = new TaskState(() => 0); state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Task ${index + 1}` }))); @@ -119,6 +187,20 @@ test("caps the widget at twelve task rows with an overflow summary", () => { assert.match(lines[11] ?? "", /Task 12/); assert.equal(lines[12], " … 8 more tasks"); assert.ok(lines.every((line) => visibleWidth(line) <= 60)); + + const collapsedOverflow = new TaskState(() => 0); + collapsedOverflow.add([ + ...Array.from({ length: 12 }, (_, index) => ({ name: `Visible ${index + 1}` })), + { + name: "Hidden completed branch", + children: [ + { name: "Hidden done one", status: "done" as const }, + { name: "Hidden done two", status: "done" as const }, + ], + }, + ]); + const overflowLines = renderWidgetTree(collapsedOverflow.list(), plainTheme as never, 60).map(stripAnsi); + assert.equal(overflowLines[12], " … 3 more tasks"); }); test("hides a completed widget summary before squeezing the task name", () => {