@gavin-hu/my-pi-agent 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +8 -7
  2. package/README.zh-CN.md +7 -7
  3. package/assets/file-browse.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +49 -36
  6. package/extensions/bytefly/doctor/format.ts +36 -10
  7. package/extensions/bytefly/doctor/tools.ts +6 -5
  8. package/extensions/bytefly/footer/config.ts +1 -1
  9. package/extensions/bytefly/footer/render/format.ts +5 -18
  10. package/extensions/bytefly/footer/render/layout.ts +2 -1
  11. package/extensions/bytefly/footer/render/segments.ts +51 -78
  12. package/extensions/bytefly/header/component.ts +33 -16
  13. package/extensions/bytefly/header/config.ts +28 -18
  14. package/extensions/bytefly/index.ts +1 -1
  15. package/extensions/bytefly/panel/commands.ts +69 -35
  16. package/extensions/bytefly/panel/format.ts +8 -8
  17. package/extensions/bytefly/panel/tui.ts +45 -70
  18. package/extensions/bytefly/separator/config.ts +16 -8
  19. package/extensions/bytefly/separator/format.ts +13 -40
  20. package/extensions/bytefly/separator/types.ts +0 -10
  21. package/extensions/document/README.md +1 -1
  22. package/extensions/document/render.ts +11 -10
  23. package/extensions/document/tool.ts +1 -1
  24. package/extensions/file-browse/README.md +51 -9
  25. package/extensions/file-browse/client.ts +103 -2
  26. package/extensions/file-browse/config.ts +12 -0
  27. package/extensions/file-browse/diff.ts +187 -0
  28. package/extensions/file-browse/git.ts +176 -1
  29. package/extensions/file-browse/html.ts +385 -19
  30. package/extensions/file-browse/index.ts +15 -4
  31. package/extensions/file-browse/markdown.ts +33 -0
  32. package/extensions/file-browse/router.ts +94 -2
  33. package/extensions/file-browse/server.ts +4 -1
  34. package/extensions/goal/format.ts +11 -10
  35. package/extensions/goal/tools.ts +6 -5
  36. package/extensions/goal/tui.ts +1 -1
  37. package/extensions/job/README.md +2 -2
  38. package/extensions/job/format.ts +36 -13
  39. package/extensions/job/index.ts +1 -1
  40. package/extensions/job/tools.ts +11 -10
  41. package/extensions/job/tui.ts +141 -220
  42. package/extensions/job/ui.ts +24 -49
  43. package/extensions/memory/README.md +1 -1
  44. package/extensions/memory/tools.ts +4 -3
  45. package/extensions/memory/tui.ts +32 -36
  46. package/extensions/plan/README.md +64 -34
  47. package/extensions/plan/blocks.ts +44 -2
  48. package/extensions/plan/feedback.ts +20 -5
  49. package/extensions/plan/index.ts +1 -1
  50. package/extensions/plan/runtime.ts +3 -2
  51. package/extensions/plan/tools.ts +23 -22
  52. package/extensions/plan/tui.ts +21 -27
  53. package/extensions/plan/web-client.ts +335 -30
  54. package/extensions/plan/web-page.ts +110 -30
  55. package/extensions/plan/web-router.ts +6 -4
  56. package/extensions/plan/web.ts +41 -10
  57. package/extensions/question/README.md +1 -1
  58. package/extensions/question/tools.ts +12 -11
  59. package/extensions/question/tui.ts +132 -76
  60. package/extensions/rewind/README.md +1 -1
  61. package/extensions/rewind/format.ts +29 -1
  62. package/extensions/rewind/rewind-tui.ts +66 -62
  63. package/extensions/rewind/status.ts +3 -2
  64. package/extensions/subagent/render.ts +62 -47
  65. package/extensions/todo/README.md +2 -2
  66. package/extensions/todo/format.ts +31 -10
  67. package/extensions/todo/tools.ts +5 -4
  68. package/extensions/todo/tui.ts +45 -26
  69. package/extensions/watch/README.md +2 -2
  70. package/extensions/watch/format.ts +33 -13
  71. package/extensions/watch/index.ts +1 -1
  72. package/extensions/watch/tools.ts +12 -7
  73. package/extensions/watch/tui.ts +135 -194
  74. package/extensions/watch/ui.ts +24 -46
  75. package/extensions/web-access/fetch/tool.ts +12 -11
  76. package/extensions/web-access/search/tool.ts +16 -13
  77. package/extensions/wechat/README.md +2 -2
  78. package/extensions/wechat/index.ts +4 -3
  79. package/extensions/wechat/login-screen.ts +8 -5
  80. package/extensions/wechat/status-screen.ts +41 -22
  81. package/extensions/wechat/tools.ts +6 -5
  82. package/extensions/worktree/enter.ts +1 -1
  83. package/extensions/worktree/index.ts +1 -1
  84. package/extensions/worktree/runtime.ts +1 -1
  85. package/lib/README.md +10 -7
  86. package/lib/requirements.ts +2 -2
  87. package/lib/{tui.ts → tui/chrome.ts} +9 -30
  88. package/lib/tui/columns.ts +96 -0
  89. package/lib/{list-cursor.ts → tui/cursor.ts} +56 -3
  90. package/lib/tui/gauge.ts +78 -0
  91. package/lib/tui/index.ts +25 -0
  92. package/lib/tui/line-pane.ts +173 -0
  93. package/lib/tui/rows.ts +109 -0
  94. package/lib/tui/rules.ts +88 -0
  95. package/lib/tui/status-controller.ts +107 -0
  96. package/lib/tui/tokens.ts +58 -0
  97. package/package.json +1 -1
  98. /package/lib/{ui.ts → tui/glyphs.ts} +0 -0
@@ -12,10 +12,12 @@
12
12
 
13
13
  import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
14
14
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
15
- import { Container, Markdown, Spacer, Text, type Component } from "@earendil-works/pi-tui";
15
+ import { Container, Markdown, Spacer, Text, visibleWidth, type Component } from "@earendil-works/pi-tui";
16
16
  import { sanitizeLine, shortenPath, stripControlChars } from "../../lib/format.ts";
17
- import { reuseText, toolDetails } from "../../lib/tui.ts";
18
- import { expandHint, GLYPHS } from "../../lib/ui.ts";
17
+ import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
18
+ import { expandHint, GLYPHS } from "../../lib/tui/glyphs.ts";
19
+ import { centeredRule } from "../../lib/tui/rules.ts";
20
+ import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
19
21
  import { aggregateUsage, clip, formatToolCall, formatUsageStats } from "./format.ts";
20
22
  import { COLLAPSED_ERROR_MAX, COLLAPSED_ITEM_COUNT, COLLAPSED_TEXT_LINES, type SubagentArgs } from "./schema.ts";
21
23
  import { getFinalOutput, isFailedResult } from "./stream.ts";
@@ -24,6 +26,11 @@ import type { SingleResult, SubagentDetails } from "./types.ts";
24
26
  type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, unknown> };
25
27
  type ResultStatus = "running" | "aborted" | "failed" | "success";
26
28
 
29
+ /** Columns a section rule adds around its label: a space and three dashes each side. */
30
+ const SECTION_RULE_PAD = 8;
31
+ /** A section rule keeps the `meta` colour the hand-rolled `─── label ───` used. */
32
+ const SECTION_RULE_STYLE = { pad: 1, fillRole: "meta", labelRole: "meta" } as const;
33
+
27
34
  /** Text and tool-call parts of every assistant message, in order. */
28
35
  function getDisplayItems(messages: SingleResult["messages"]): DisplayItem[] {
29
36
  const items: DisplayItem[] = [];
@@ -45,12 +52,12 @@ function hasExpandableContent(items: DisplayItem[]): boolean {
45
52
  );
46
53
  }
47
54
 
48
- /** Colours per status; the running glyph is time-based, so it is not a value here. */
49
- const STATUS_COLOR: Record<ResultStatus, "warning" | "error" | "success"> = {
50
- running: "warning",
51
- aborted: "warning",
52
- failed: "error",
53
- success: "success",
55
+ /** Design roles per status; the running glyph is time-based, so it is not a value here. */
56
+ const STATUS_ROLE: Record<ResultStatus, TuiRole> = {
57
+ running: "warn",
58
+ aborted: "warn",
59
+ failed: "bad",
60
+ success: "ok",
54
61
  };
55
62
 
56
63
  /** Static glyphs for terminal states, from the shared narrow-glyph vocabulary. */
@@ -77,12 +84,12 @@ function resultStatus(result: SingleResult, isPartial = false): ResultStatus {
77
84
  }
78
85
 
79
86
  function statusIcon(status: ResultStatus, theme: Theme, now: number): string {
80
- return theme.fg(STATUS_COLOR[status], statusGlyph(status, now));
87
+ return paint(theme, STATUS_ROLE[status], statusGlyph(status, now));
81
88
  }
82
89
 
83
- /** The color the status uses, so the `[stopReason]` bracket matches its icon. */
84
- function statusColor(status: ResultStatus): "warning" | "error" | "success" {
85
- return STATUS_COLOR[status];
90
+ /** The role the status uses, so the `[stopReason]` bracket matches its icon. */
91
+ function statusRole(status: ResultStatus): TuiRole {
92
+ return STATUS_ROLE[status];
86
93
  }
87
94
 
88
95
  /** The message a failed/aborted result should show, falling back to stderr. */
@@ -99,7 +106,7 @@ function renderError(result: SingleResult, status: ResultStatus, theme: Theme, m
99
106
  const detail = errorDetail(result, status, max);
100
107
  if (!detail) return undefined;
101
108
  const label = status === "aborted" ? "Aborted" : "Error";
102
- return theme.fg(status === "aborted" ? "warning" : "error", `${label}: ${detail}`);
109
+ return paint(theme, status === "aborted" ? "warn" : "bad", `${label}: ${detail}`);
103
110
  }
104
111
 
105
112
  /** Whether the collapsed error/stderr text is truncated, so expanding shows more. */
@@ -114,7 +121,7 @@ function hasClippedError(result: SingleResult, status: ResultStatus): boolean {
114
121
  function collapsedBody(result: SingleResult, status: ResultStatus, items: DisplayItem[], theme: Theme): string {
115
122
  const error = renderError(result, status, theme, COLLAPSED_ERROR_MAX);
116
123
  if (error) return error;
117
- if (items.length === 0) return theme.fg("muted", status === "running" ? "(running…)" : "(no output)");
124
+ if (items.length === 0) return paint(theme, "meta", status === "running" ? "(running…)" : "(no output)");
118
125
  return renderDisplayItems(items, theme, COLLAPSED_ITEM_COUNT);
119
126
  }
120
127
 
@@ -164,11 +171,11 @@ function durationSuffix(result: SingleResult, status: ResultStatus, now: number)
164
171
  function statusSuffix(result: SingleResult, status: ResultStatus, theme: Theme, now: number): string {
165
172
  let suffix = "";
166
173
  const duration = durationSuffix(result, status, now);
167
- if (duration) suffix += theme.fg("dim", duration);
174
+ if (duration) suffix += paint(theme, "faint", duration);
168
175
  const showReason = status === "failed" || status === "aborted";
169
176
  const stop = showReason && result.stopReason && result.stopReason !== "end" ? result.stopReason : undefined;
170
177
  const reason = stop ?? (status === "failed" ? `exit ${result.exitCode}` : undefined);
171
- if (reason) suffix += ` ${theme.fg(statusColor(status), `[${reason}]`)}`;
178
+ if (reason) suffix += ` ${paint(theme, statusRole(status), `[${reason}]`)}`;
172
179
  return suffix;
173
180
  }
174
181
 
@@ -182,9 +189,9 @@ function resultHeader(
182
189
  ): string {
183
190
  let header = `${statusIcon(status, theme, now)} ${theme.fg("toolTitle", theme.bold(sanitizeLine(result.agent)))}`;
184
191
  if (result.agentSource && result.agentSource !== "builtin" && result.agentSource !== "unknown") {
185
- header += theme.fg("dim", ` [${sanitizeLine(result.agentSource)}]`);
192
+ header += paint(theme, "faint", ` [${sanitizeLine(result.agentSource)}]`);
186
193
  }
187
- if (result.step) header = `${theme.fg("muted", `${stepPrefix} ${result.step} `)}` + header;
194
+ if (result.step) header = `${paint(theme, "meta", `${stepPrefix} ${result.step} `)}` + header;
188
195
  return header + statusSuffix(result, status, theme, now);
189
196
  }
190
197
 
@@ -193,7 +200,7 @@ function quoteLines(text: string, theme: Theme): string {
193
200
  return stripControlChars(text)
194
201
  .trim()
195
202
  .split("\n")
196
- .map((line) => theme.fg("muted", "│ ") + theme.fg("toolOutput", line))
203
+ .map((line) => paint(theme, "meta", "│ ") + theme.fg("toolOutput", line))
197
204
  .join("\n");
198
205
  }
199
206
 
@@ -201,14 +208,14 @@ function renderDisplayItems(items: DisplayItem[], theme: Theme, limit?: number,
201
208
  const shown = limit ? items.slice(-limit) : items;
202
209
  const skipped = limit && items.length > limit ? items.length - limit : 0;
203
210
  const lines: string[] = [];
204
- if (skipped > 0) lines.push(theme.fg("muted", `… ${skipped} earlier items`));
211
+ if (skipped > 0) lines.push(paint(theme, "meta", `… ${skipped} earlier items`));
205
212
  for (const item of shown) {
206
213
  if (item.type === "text") {
207
214
  const safe = stripControlChars(item.text);
208
215
  const text = preview ? safe.split("\n").slice(0, COLLAPSED_TEXT_LINES).join("\n") : safe;
209
216
  lines.push(theme.fg("toolOutput", text));
210
217
  } else {
211
- lines.push(theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme, preview));
218
+ lines.push(paint(theme, "meta", "→ ") + formatToolCall(item.name, item.args, theme, preview));
212
219
  }
213
220
  }
214
221
  return lines.join("\n");
@@ -235,36 +242,38 @@ export function renderSubagentCall(args: SubagentArgs, theme: Theme, context?: S
235
242
 
236
243
  /** The call-line string for the requested mode. */
237
244
  function callText(args: SubagentArgs, theme: Theme, context?: { cwd?: string }): string {
238
- const title = theme.fg("toolTitle", theme.bold("subagent ")) + (args.readOnly ? theme.fg("dim", "read-only ") : "");
245
+ const title =
246
+ theme.fg("toolTitle", theme.bold("subagent ")) + (args.readOnly ? paint(theme, "faint", "read-only ") : "");
239
247
  const cwdNote = (cwd?: string) => {
240
248
  if (!cwd || cwd === context?.cwd) return "";
241
- return theme.fg("dim", ` in ${shortenPath(cwd)}`);
249
+ return paint(theme, "faint", ` in ${shortenPath(cwd)}`);
242
250
  };
243
251
 
244
252
  if (args.chain && args.chain.length > 0) {
245
- let text = title + theme.fg("accent", `chain · ${args.chain.length} step${args.chain.length > 1 ? "s" : ""}`);
253
+ let text = title + paint(theme, "accent", `chain · ${args.chain.length} step${args.chain.length > 1 ? "s" : ""}`);
246
254
  for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
247
255
  const step = args.chain[i];
248
256
  const clean = sanitizeLine(step.task.replace(/\{previous\}/g, ""));
249
- text += `\n ${theme.fg("muted", `${i + 1}.`)} ${theme.fg("accent", sanitizeLine(step.agent))}${theme.fg("dim", ` ${clip(clean, 40)}`)}${cwdNote(step.cwd)}`;
257
+ text += `\n ${paint(theme, "meta", `${i + 1}.`)} ${paint(theme, "accent", sanitizeLine(step.agent))}${paint(theme, "faint", ` ${clip(clean, 40)}`)}${cwdNote(step.cwd)}`;
250
258
  }
251
- if (args.chain.length > 3) text += `\n ${theme.fg("muted", `… +${args.chain.length - 3} more`)}`;
259
+ if (args.chain.length > 3) text += `\n ${paint(theme, "meta", `… +${args.chain.length - 3} more`)}`;
252
260
  return text;
253
261
  }
254
262
 
255
263
  if (args.tasks && args.tasks.length > 0) {
256
- let text = title + theme.fg("accent", `parallel · ${args.tasks.length} task${args.tasks.length > 1 ? "s" : ""}`);
264
+ let text =
265
+ title + paint(theme, "accent", `parallel · ${args.tasks.length} task${args.tasks.length > 1 ? "s" : ""}`);
257
266
  for (let i = 0; i < Math.min(args.tasks.length, 3); i++) {
258
267
  const task = args.tasks[i];
259
- text += `\n ${theme.fg("muted", `${i + 1}.`)} ${theme.fg("accent", sanitizeLine(task.agent))}${theme.fg("dim", ` ${clip(sanitizeLine(task.task), 40)}`)}${cwdNote(task.cwd)}`;
268
+ text += `\n ${paint(theme, "meta", `${i + 1}.`)} ${paint(theme, "accent", sanitizeLine(task.agent))}${paint(theme, "faint", ` ${clip(sanitizeLine(task.task), 40)}`)}${cwdNote(task.cwd)}`;
260
269
  }
261
- if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `… +${args.tasks.length - 3} more`)}`;
270
+ if (args.tasks.length > 3) text += `\n ${paint(theme, "meta", `… +${args.tasks.length - 3} more`)}`;
262
271
  return text;
263
272
  }
264
273
 
265
274
  const agentName = sanitizeLine(args.agent || "…");
266
- let text = title + theme.fg("accent", agentName) + cwdNote(args.cwd);
267
- text += `\n ${theme.fg("dim", clip(sanitizeLine(args.task ?? "…"), 60))}`;
275
+ let text = title + paint(theme, "accent", agentName) + cwdNote(args.cwd);
276
+ text += `\n ${paint(theme, "faint", clip(sanitizeLine(args.task ?? "…"), 60))}`;
268
277
  return text;
269
278
  }
270
279
 
@@ -283,8 +292,10 @@ function addExpandedResult(
283
292
  if (error) container.addChild(new Text(error, 0, 0));
284
293
 
285
294
  container.addChild(new Spacer(1));
286
- container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
287
- container.addChild(new Text(theme.fg("dim", stripControlChars(result.taskTemplate ?? result.task)), 0, 0));
295
+ container.addChild(
296
+ new Text(centeredRule(theme, visibleWidth("Task") + SECTION_RULE_PAD, "Task", SECTION_RULE_STYLE), 0, 0),
297
+ );
298
+ container.addChild(new Text(paint(theme, "faint", stripControlChars(result.taskTemplate ?? result.task)), 0, 0));
288
299
 
289
300
  const displayItems = getDisplayItems(result.messages);
290
301
  const lastItem = displayItems[displayItems.length - 1];
@@ -294,11 +305,15 @@ function addExpandedResult(
294
305
  // order and do not surface a stale earlier message as the answer.
295
306
  const trailingOutput = lastItem?.type === "text" && lastItem.text === finalOutput ? lastItem.text : "";
296
307
  container.addChild(new Spacer(1));
297
- container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
308
+ container.addChild(
309
+ new Text(centeredRule(theme, visibleWidth("Output") + SECTION_RULE_PAD, "Output", SECTION_RULE_STYLE), 0, 0),
310
+ );
298
311
  let renderedOutput = false;
299
312
  for (const item of displayItems) {
300
313
  if (item.type === "toolCall") {
301
- container.addChild(new Text(theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme, false), 0, 0));
314
+ container.addChild(
315
+ new Text(paint(theme, "meta", "→ ") + formatToolCall(item.name, item.args, theme, false), 0, 0),
316
+ );
302
317
  renderedOutput = true;
303
318
  } else if (item.text.trim() && item.text !== trailingOutput) {
304
319
  container.addChild(new Text(quoteLines(item.text, theme), 0, 0));
@@ -311,13 +326,13 @@ function addExpandedResult(
311
326
  renderedOutput = true;
312
327
  }
313
328
  if (!renderedOutput) {
314
- container.addChild(new Text(theme.fg("muted", status === "running" ? "(running…)" : "(no output)"), 0, 0));
329
+ container.addChild(new Text(paint(theme, "meta", status === "running" ? "(running…)" : "(no output)"), 0, 0));
315
330
  }
316
331
 
317
332
  const usage = usageLine(result);
318
333
  if (usage) {
319
334
  container.addChild(new Spacer(1));
320
- container.addChild(new Text(theme.fg("dim", usage), 0, 0));
335
+ container.addChild(new Text(paint(theme, "faint", usage), 0, 0));
321
336
  }
322
337
  }
323
338
 
@@ -333,7 +348,7 @@ function collapsedSingleText(result: SingleResult, theme: Theme, isPartial: bool
333
348
  text += `\n\n${collapsedBody(result, status, displayItems, theme)}`;
334
349
  if (isExpandable(result, status, displayItems)) text += `\n${expandHint(theme)}`;
335
350
  const usage = usageLine(result);
336
- if (usage) text += `\n${theme.fg("dim", usage)}`;
351
+ if (usage) text += `\n${paint(theme, "faint", usage)}`;
337
352
  return text;
338
353
  }
339
354
 
@@ -375,11 +390,11 @@ function multiHeader(details: SubagentDetails, theme: Theme, now: number): strin
375
390
  const bad = failed + aborted;
376
391
  const icon =
377
392
  running > 0
378
- ? theme.fg("warning", statusGlyph("running", now))
393
+ ? paint(theme, "warn", statusGlyph("running", now))
379
394
  : bad > 0
380
- ? theme.fg(bad === total ? "error" : "warning", bad === total ? GLYPHS.jobsFailure : GLYPHS.half)
381
- : theme.fg("success", GLYPHS.check);
382
- return `${icon} ${theme.fg("toolTitle", theme.bold(`${details.mode} · `))}${theme.fg("accent", status)}`;
395
+ ? paint(theme, bad === total ? "bad" : "warn", bad === total ? GLYPHS.jobsFailure : GLYPHS.half)
396
+ : paint(theme, "ok", GLYPHS.check);
397
+ return `${icon} ${theme.fg("toolTitle", theme.bold(`${details.mode} · `))}${paint(theme, "accent", status)}`;
383
398
  }
384
399
 
385
400
  function collapsedMultiText(details: SubagentDetails, theme: Theme, now: number): string {
@@ -394,14 +409,14 @@ function collapsedMultiText(details: SubagentDetails, theme: Theme, now: number)
394
409
  const displayItems = getDisplayItems(result.messages);
395
410
  if (isExpandable(result, status, displayItems)) hasHidden = true;
396
411
  const taskText = result.taskTemplate ?? result.task;
397
- const taskNote = taskText ? theme.fg("dim", ` ${clip(sanitizeLine(taskText), 50)}`) : "";
398
- text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", resultLabel(result, i))}${taskNote} ${statusIcon(status, theme, now)}`;
412
+ const taskNote = taskText ? paint(theme, "faint", ` ${clip(sanitizeLine(taskText), 50)}`) : "";
413
+ text += `\n\n${paint(theme, "meta", "─── ")}${paint(theme, "accent", resultLabel(result, i))}${taskNote} ${statusIcon(status, theme, now)}`;
399
414
  text += statusSuffix(result, status, theme, now);
400
415
  text += `\n${collapsedBody(result, status, displayItems, theme)}`;
401
416
  }
402
417
  if (running === 0) {
403
418
  const usage = totalUsageLine(results);
404
- if (usage) text += `\n\n${theme.fg("dim", `Total: ${usage}`)}`;
419
+ if (usage) text += `\n\n${paint(theme, "faint", `Total: ${usage}`)}`;
405
420
  }
406
421
  if (running > 0 || hasHidden) text += `\n${expandHint(theme)}`;
407
422
  return text;
@@ -421,7 +436,7 @@ function fillExpandedMulti(container: Container, details: SubagentDetails, theme
421
436
  const usage = totalUsageLine(details.results);
422
437
  if (usage) {
423
438
  container.addChild(new Spacer(1));
424
- container.addChild(new Text(theme.fg("dim", `Total: ${usage}`), 0, 0));
439
+ container.addChild(new Text(paint(theme, "faint", `Total: ${usage}`), 0, 0));
425
440
  }
426
441
  }
427
442
  }
@@ -205,7 +205,7 @@ depends on config.
205
205
  first pending); `/todos` keeps the submitted order, and the transcript result
206
206
  orders by status via `compareByActivity` so active work is never hidden. The
207
207
  transcript result draws the same indented glyph rail as the goal result
208
- (`BODY_INDENT` / `GLYPH_GAP` from [`lib/ui.ts`](../../lib/ui.ts)), wrapping
208
+ (`BODY_INDENT` / `GLYPH_GAP` from [`lib/tui/glyphs.ts`](../../lib/tui/glyphs.ts)), wrapping
209
209
  long items with continuation rows aligned under the text. Progress lives on
210
210
  the call line, so the rail carries no footer.
211
211
  - **The transcript reuses its components.** `renderCall` reuses the slot's
@@ -223,7 +223,7 @@ depends on config.
223
223
  | `state.ts` | Branch reconstruction and status queries (pure). |
224
224
  | `nudge.ts` | Lag detection: mutating-tool classification, due check, reminder text (pure). |
225
225
  | `format.ts` | Model-facing and transcript text (pure). |
226
- | `tui.ts` | Widget and scrollable `/todos` components. |
226
+ | `tui.ts` | Widget and scrollable `/todos` components, drawn from the `lib/tui/` kit (`titleRule`, `dockListFrame`, `listRow`, `emptyState`). |
227
227
  | `config.ts` | Widget config load/validation (the `todo` section of `bytefly.json`). |
228
228
  | `runtime.ts` | Session-scoped state, lag tracking, and widget synchronization. |
229
229
  | `tools.ts` | `todo` tool registration and rendering. |
@@ -4,7 +4,8 @@
4
4
 
5
5
  import type { Theme } from "@earendil-works/pi-coding-agent";
6
6
  import { sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
7
- import { BODY_INDENT, expandHint, GLYPH_GAP, GLYPHS } from "../../lib/ui.ts";
7
+ import { BODY_INDENT, expandHint, GLYPH_GAP, GLYPHS } from "../../lib/tui/glyphs.ts";
8
+ import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
8
9
  import { completedCount, currentTodo } from "./state.ts";
9
10
  import type { Todo, TodoStatus } from "./types.ts";
10
11
 
@@ -26,23 +27,43 @@ const STATUS_GLYPH: Record<TodoStatus, string> = {
26
27
  completed: GLYPHS.check,
27
28
  };
28
29
 
29
- /** Themed status glyph, shared by the widget and the transcript renderer. */
30
- export function todoGlyph(todo: Todo, theme: Theme): string {
30
+ /** The design role a status glyph is drawn in. */
31
+ export function todoGlyphRole(todo: Todo): TuiRole {
31
32
  switch (todo.status) {
32
33
  case "completed":
33
- return theme.fg("success", STATUS_GLYPH.completed);
34
+ return "ok";
34
35
  case "in_progress":
35
- return theme.fg("accent", STATUS_GLYPH.in_progress);
36
+ return "accent";
36
37
  default:
37
- return theme.fg("dim", STATUS_GLYPH.pending);
38
+ return "faint";
38
39
  }
39
40
  }
40
41
 
42
+ /** The status glyph, from the shared vocabulary. */
43
+ export function todoGlyphChar(todo: Todo): string {
44
+ return STATUS_GLYPH[todo.status];
45
+ }
46
+
47
+ /** The design role an item label is drawn in. */
48
+ export function todoLabelRole(todo: Todo): TuiRole {
49
+ if (todo.status === "completed") return "faint";
50
+ if (todo.status === "in_progress") return "label";
51
+ return "meta";
52
+ }
53
+
54
+ /** The item text: the active form while in progress, the content otherwise. */
55
+ export function todoLabelText(todo: Todo): string {
56
+ return todo.status === "in_progress" ? (todo.activeForm ?? todo.content) : todo.content;
57
+ }
58
+
59
+ /** Themed status glyph, shared by the widget and the transcript renderer. */
60
+ export function todoGlyph(todo: Todo, theme: Theme): string {
61
+ return paint(theme, todoGlyphRole(todo), todoGlyphChar(todo));
62
+ }
63
+
41
64
  /** Themed item text: completed dim, in-progress accented, pending muted. */
42
65
  export function todoLabel(todo: Todo, theme: Theme): string {
43
- if (todo.status === "completed") return theme.fg("dim", todo.content);
44
- if (todo.status === "in_progress") return theme.fg("text", todo.activeForm ?? todo.content);
45
- return theme.fg("muted", todo.content);
66
+ return paint(theme, todoLabelRole(todo), todoLabelText(todo));
46
67
  }
47
68
 
48
69
  /** "2/5" — completed over total. */
@@ -136,7 +157,7 @@ export function todoRailLines(input: TodoRailInput, theme: Theme, width: number)
136
157
  });
137
158
  }
138
159
  if (input.more && input.more > 0) {
139
- lines.push(`${continuation}${theme.fg("dim", `… ${input.more} more`)} ${expandHint(theme)}`);
160
+ lines.push(`${continuation}${paint(theme, "faint", `… ${input.more} more`)} ${expandHint(theme)}`);
140
161
  }
141
162
  return lines.map((line) => truncateToWidth(line, w, "…"));
142
163
  }
@@ -14,7 +14,8 @@ import type { JsonValue } from "@earendil-works/pi-ai";
14
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
15
  import { sanitizeLine } from "../../lib/format.ts";
16
16
  import { TODO_TOOL } from "../../lib/tool-names.ts";
17
- import { reuseText, toolDetails } from "../../lib/tui.ts";
17
+ import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
18
+ import { paint } from "../../lib/tui/tokens.ts";
18
19
  import { compareByActivity, formatCallText, formatTodoText, type TodoRailInput } from "./format.ts";
19
20
  import type { TodoRuntime } from "./runtime.ts";
20
21
  import { normalizeTodos, TodoParams, TodoResult, type TodoArgs } from "./schema.ts";
@@ -97,7 +98,7 @@ export function registerTools(pi: ExtensionAPI, runtime: TodoRuntime): void {
97
98
  const text = reuseText(context.lastComponent);
98
99
  text.setText(
99
100
  theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) +
100
- theme.fg("muted", formatCallText(args.todos as Todo[] | undefined, context.argsComplete)),
101
+ paint(theme, "meta", formatCallText(args.todos as Todo[] | undefined, context.argsComplete)),
101
102
  );
102
103
  return text;
103
104
  },
@@ -106,7 +107,7 @@ export function registerTools(pi: ExtensionAPI, runtime: TodoRuntime): void {
106
107
  const text = reuseText(context.lastComponent);
107
108
  const details = toolDetails<TodoDetails>(result.details);
108
109
  if (details?.error) {
109
- text.setText(theme.fg("error", `Error: ${sanitizeLine(details.error)}`));
110
+ text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
110
111
  return text;
111
112
  }
112
113
  // A call rejected before `execute` (argument validation or a permission
@@ -117,7 +118,7 @@ export function registerTools(pi: ExtensionAPI, runtime: TodoRuntime): void {
117
118
  return text;
118
119
  }
119
120
  if (details.todos.length === 0) {
120
- text.setText(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared the todo list"));
121
+ text.setText(paint(theme, "ok", "✓ ") + paint(theme, "meta", "Cleared the todo list"));
121
122
  return text;
122
123
  }
123
124
 
@@ -8,10 +8,23 @@
8
8
 
9
9
  import type { Theme } from "@earendil-works/pi-coding-agent";
10
10
  import { truncateToWidth, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
11
- import { clampScroll, fitRows, formatRange, isCloseKey, navigateScroll, wheelDelta } from "../../lib/list-cursor.ts";
12
- import { screenHeader, screenHint, type ViewportRowsSource } from "../../lib/tui.ts";
13
- import { BODY_INDENT } from "../../lib/ui.ts";
14
- import { progressCount, progressSummary, todoGlyph, todoLabel, todoRailLines, type TodoRailInput } from "./format.ts";
11
+ import { clampScroll, fitRows, formatRange, isCloseKey, navigateScroll, wheelDelta } from "../../lib/tui/cursor.ts";
12
+ import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
13
+ import { clip } from "../../lib/tui/columns.ts";
14
+ import { emptyState, listRow } from "../../lib/tui/rows.ts";
15
+ import { titleRule } from "../../lib/tui/rules.ts";
16
+ import { paint } from "../../lib/tui/tokens.ts";
17
+ import {
18
+ progressCount,
19
+ progressSummary,
20
+ todoGlyphChar,
21
+ todoGlyphRole,
22
+ todoLabel,
23
+ todoLabelRole,
24
+ todoLabelText,
25
+ todoRailLines,
26
+ type TodoRailInput,
27
+ } from "./format.ts";
15
28
  import { currentTodo } from "./state.ts";
16
29
  import type { Todo } from "./types.ts";
17
30
 
@@ -23,8 +36,12 @@ const SCREEN_DEFAULT_ITEMS = 12;
23
36
 
24
37
  /** One indented item line, clipped to `width`. */
25
38
  function todoRow(todo: Todo, theme: Theme, width: number): string {
26
- const indent = " ".repeat(BODY_INDENT);
27
- return truncateToWidth(`${indent}${todoGlyph(todo, theme)} ${todoLabel(todo, theme)}`, width, "…");
39
+ return listRow(theme, width, {
40
+ glyph: todoGlyphChar(todo),
41
+ glyphRole: todoGlyphRole(todo),
42
+ label: todoLabelText(todo),
43
+ labelRole: todoLabelRole(todo),
44
+ });
28
45
  }
29
46
 
30
47
  /**
@@ -33,10 +50,10 @@ function todoRow(todo: Todo, theme: Theme, width: number): string {
33
50
  * `jobs` widgets. Callers clip it to the available width.
34
51
  */
35
52
  function todoLine(todos: Todo[], theme: Theme): string {
36
- const head = `${theme.fg("accent", "Todos")} ${theme.fg("dim", "·")} ${theme.fg("dim", progressCount(todos))}`;
53
+ const head = `${paint(theme, "accent", "Todos")} ${paint(theme, "faint", "·")} ${paint(theme, "faint", progressCount(todos))}`;
37
54
  const current = currentTodo(todos);
38
- if (!current) return `${head} ${theme.fg("dim", "completed")}`;
39
- return `${head} ${theme.fg("dim", "·")} ${todoLabel(current, theme)}`;
55
+ if (!current) return `${head} ${paint(theme, "faint", "completed")}`;
56
+ return `${head} ${paint(theme, "faint", "·")} ${todoLabel(current, theme)}`;
40
57
  }
41
58
 
42
59
  /** Transcript result block: the indented glyph rail, wrapping at the render width. */
@@ -139,32 +156,34 @@ export class TodoListComponent implements Component {
139
156
 
140
157
  render(width: number): string[] {
141
158
  const w = Math.max(1, width);
142
- const lines: string[] = [screenHeader(this.theme, w, "Todos")];
159
+ const header = titleRule(this.theme, w, "Todos");
143
160
 
144
161
  if (this.todos.length === 0) {
145
- lines.push(truncateToWidth(` ${this.theme.fg("dim", "No todos yet. Ask the agent to plan some work.")}`, w));
146
- lines.push("");
147
- lines.push(screenHint(this.theme, w, ["Esc close"]));
148
- lines.push("");
149
- return lines;
162
+ return dockListFrame({
163
+ header,
164
+ body: emptyState(this.theme, w, { label: "No todos yet. Ask the agent to plan some work." }),
165
+ hint: screenHint(this.theme, w, ["Esc close"]),
166
+ });
150
167
  }
151
168
 
152
- lines.push(truncateToWidth(` ${this.theme.fg("muted", progressSummary(this.todos))}`, w));
153
- lines.push("");
154
169
  // header, summary, blank, blank-after-list, hint, blank
155
170
  const chrome = 6;
156
171
  this.visible = fitRows(this.viewportRowsSource, this.todos.length, chrome, SCREEN_DEFAULT_ITEMS);
157
172
  this.scrollTop = clampScroll(this.scrollTop, this.todos.length, this.visible);
158
173
 
159
174
  const end = Math.min(this.todos.length, this.scrollTop + this.visible);
160
- for (let i = this.scrollTop; i < end; i++) lines.push(todoRow(this.todos[i], this.theme, w));
161
- if (this.scrollTop > 0 || end < this.todos.length) {
162
- lines.push(formatRange(this.theme, w, { start: this.scrollTop, end, total: this.todos.length }));
163
- }
164
-
165
- lines.push("");
166
- lines.push(screenHint(this.theme, w, ["Esc close", "↑/↓ or k/j scroll", "PgUp/PgDn", "g/G or Home/End"]));
167
- lines.push("");
168
- return lines;
175
+ const body: string[] = [];
176
+ for (let i = this.scrollTop; i < end; i++) body.push(todoRow(this.todos[i], this.theme, w));
177
+
178
+ return dockListFrame({
179
+ header,
180
+ summary: clip(` ${paint(this.theme, "meta", progressSummary(this.todos))}`, w),
181
+ body,
182
+ range:
183
+ this.scrollTop > 0 || end < this.todos.length
184
+ ? formatRange(this.theme, w, { start: this.scrollTop, end, total: this.todos.length })
185
+ : undefined,
186
+ hint: screenHint(this.theme, w, ["Esc close", "↑/↓ or k/j scroll", "PgUp/PgDn", "g/G or Home/End"]),
187
+ });
169
188
  }
170
189
  }
@@ -200,8 +200,8 @@ turn — so a match is never dropped when the wake window is busy.
200
200
  | `runtime.ts` | The session-scoped watcher table, matcher, poll tick, and shutdown. |
201
201
  | `process.ts` | Injectable spawn and process-tree kill. |
202
202
  | `format.ts` | Pure sanitization and text formatting. |
203
- | `ui.ts` | Status chips and the match alert. |
204
- | `tui.ts` | `WatchResult` transcript block and the `/watch` dock screen. |
203
+ | `ui.ts` | Status chips and the match alert, over the shared `lib/tui/status-controller.ts`. |
204
+ | `tui.ts` | `WatchResult` transcript block and the `/watch` dock screen, drawn from the `lib/tui/` kit (`titleRule`, `listRow`, `emptyState`, `IdCursor`, `LinePane`). |
205
205
  | `commands.ts` | The `/watch` command and its completions. |
206
206
  | `config.ts` | The `watch` config section load and normalization. |
207
207
 
@@ -12,7 +12,8 @@
12
12
  import type { Theme } from "@earendil-works/pi-coding-agent";
13
13
  import { truncateToWidth } from "@earendil-works/pi-tui";
14
14
  import { sanitizeLine } from "../../lib/format.ts";
15
- import { GLYPHS } from "../../lib/ui.ts";
15
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
16
+ import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
16
17
  import type { PendingMatch, WatchRecord, WatchStatus } from "./types.ts";
17
18
 
18
19
  /** Longest label shown in the transcript or `/watch` screen. */
@@ -63,20 +64,39 @@ export function shortLabel(watch: WatchRecord): string {
63
64
  return clipLabel(watch.label || sourceLabel(watch));
64
65
  }
65
66
 
66
- /** Width-1 themed glyph for a watcher status. */
67
- export function statusGlyph(status: WatchStatus, theme: Theme): string {
67
+ /** The design role a watcher status is drawn in. */
68
+ export function statusRole(status: WatchStatus): TuiRole {
69
+ switch (status) {
70
+ case "exited":
71
+ return "ok";
72
+ case "error":
73
+ return "bad";
74
+ case "stopped":
75
+ return "meta";
76
+ default:
77
+ return "accent";
78
+ }
79
+ }
80
+
81
+ /** Width-1 glyph for a watcher status, from the shared vocabulary. */
82
+ export function statusGlyphChar(status: WatchStatus): string {
68
83
  switch (status) {
69
84
  case "exited":
70
- return theme.fg("success", GLYPHS.check);
85
+ return GLYPHS.check;
71
86
  case "error":
72
- return theme.fg("error", GLYPHS.warn);
87
+ return GLYPHS.warn;
73
88
  case "stopped":
74
- return theme.fg("muted", GLYPHS.stopped);
89
+ return GLYPHS.stopped;
75
90
  default:
76
- return theme.fg("accent", GLYPHS.target);
91
+ return GLYPHS.target;
77
92
  }
78
93
  }
79
94
 
95
+ /** Width-1 themed glyph for a watcher status. */
96
+ export function statusGlyph(status: WatchStatus, theme: Theme): string {
97
+ return paint(theme, statusRole(status), statusGlyphChar(status));
98
+ }
99
+
80
100
  /** Short status word, including an exit code or error when present. */
81
101
  export function statusWord(watch: WatchRecord): string {
82
102
  if (watch.status === "error" && watch.error) return `error (${watch.error})`;
@@ -175,22 +195,22 @@ export function formatCallText(action: string, args: Record<string, unknown>, ar
175
195
 
176
196
  /** One-line outcome for a watcher, themed for the transcript result. */
177
197
  export function formatWatchOutcomeLine(watch: WatchRecord, theme: Theme, now = Date.now()): string {
178
- const sep = theme.fg("dim", " · ");
198
+ const sep = paint(theme, "faint", " · ");
179
199
  const age = Math.max(0, (watch.endedAt ?? now) - watch.startedAt);
180
200
  const outcome =
181
201
  watch.status === "error"
182
- ? theme.fg("error", statusWord(watch))
183
- : theme.fg("dim", `${watch.matchCount} match${watch.matchCount === 1 ? "" : "es"}`);
202
+ ? paint(theme, "bad", statusWord(watch))
203
+ : paint(theme, "faint", `${watch.matchCount} match${watch.matchCount === 1 ? "" : "es"}`);
184
204
  return (
185
205
  statusGlyph(watch.status, theme) +
186
206
  " " +
187
- theme.fg("accent", watch.id) +
207
+ paint(theme, "accent", watch.id) +
188
208
  " " +
189
- theme.fg("muted", shortLabel(watch)) +
209
+ paint(theme, "meta", shortLabel(watch)) +
190
210
  sep +
191
211
  outcome +
192
212
  sep +
193
- theme.fg("dim", `${Math.round(age / 1000)}s`)
213
+ paint(theme, "faint", `${Math.round(age / 1000)}s`)
194
214
  );
195
215
  }
196
216
 
@@ -13,7 +13,7 @@
13
13
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
14
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
15
  import { isExtensionEnabled } from "../../lib/env.ts";
16
- import { STATUS_KEYS } from "../../lib/ui.ts";
16
+ import { STATUS_KEYS } from "../../lib/tui/glyphs.ts";
17
17
  import { registerCommands } from "./commands.ts";
18
18
  import { formatPendingMatch } from "./format.ts";
19
19
  import { createWatchRuntime, type WatchRuntime } from "./runtime.ts";