@gavin-hu/my-pi-agent 0.8.1 → 0.10.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 (174) hide show
  1. package/README.md +35 -8
  2. package/README.zh-CN.md +33 -8
  3. package/assets/bytefly.schema.json +17 -1
  4. package/assets/chrome.png +0 -0
  5. package/assets/file-browse.png +0 -0
  6. package/assets/job.png +0 -0
  7. package/assets/plan-review.png +0 -0
  8. package/assets/question.png +0 -0
  9. package/assets/rewind.png +0 -0
  10. package/assets/todo.png +0 -0
  11. package/assets/tool-renderings.png +0 -0
  12. package/assets/tools/ask_user_question.png +0 -0
  13. package/assets/tools/bash.png +0 -0
  14. package/assets/tools/edit.png +0 -0
  15. package/assets/tools/enter_plan_mode.png +0 -0
  16. package/assets/tools/enter_worktree.png +0 -0
  17. package/assets/tools/exit_plan_mode.png +0 -0
  18. package/assets/tools/exit_worktree.png +0 -0
  19. package/assets/tools/find.png +0 -0
  20. package/assets/tools/goal.png +0 -0
  21. package/assets/tools/grep.png +0 -0
  22. package/assets/tools/job.png +0 -0
  23. package/assets/tools/list_worktrees.png +0 -0
  24. package/assets/tools/ls.png +0 -0
  25. package/assets/tools/memory.png +0 -0
  26. package/assets/tools/notice.png +0 -0
  27. package/assets/tools/open_path.png +0 -0
  28. package/assets/tools/prune_worktrees.png +0 -0
  29. package/assets/tools/read.png +0 -0
  30. package/assets/tools/read_doc.png +0 -0
  31. package/assets/tools/run_doctor.png +0 -0
  32. package/assets/tools/send_wechat.png +0 -0
  33. package/assets/tools/set_plan_steps.png +0 -0
  34. package/assets/tools/subagent.png +0 -0
  35. package/assets/tools/todo.png +0 -0
  36. package/assets/tools/turn-rule.png +0 -0
  37. package/assets/tools/user-message.png +0 -0
  38. package/assets/tools/watch.png +0 -0
  39. package/assets/tools/web_fetch.png +0 -0
  40. package/assets/tools/web_search.png +0 -0
  41. package/assets/tools/write.png +0 -0
  42. package/assets/tools/write_plan.png +0 -0
  43. package/extensions/bytefly/README.md +306 -68
  44. package/extensions/bytefly/block/component.ts +282 -0
  45. package/extensions/bytefly/block/config.ts +24 -0
  46. package/extensions/bytefly/block/fallback.ts +120 -0
  47. package/extensions/bytefly/block/register.ts +164 -0
  48. package/extensions/bytefly/chrome.ts +97 -0
  49. package/extensions/bytefly/doctor/format.ts +36 -10
  50. package/extensions/bytefly/doctor/tools.ts +10 -6
  51. package/extensions/bytefly/footer/config.ts +1 -1
  52. package/extensions/bytefly/footer/render/format.ts +5 -18
  53. package/extensions/bytefly/footer/render/layout.ts +2 -1
  54. package/extensions/bytefly/footer/render/segments.ts +51 -78
  55. package/extensions/bytefly/header/component.ts +49 -21
  56. package/extensions/bytefly/header/config.ts +28 -21
  57. package/extensions/bytefly/header/register.ts +6 -1
  58. package/extensions/bytefly/index.ts +32 -12
  59. package/extensions/bytefly/message/config.ts +15 -0
  60. package/extensions/bytefly/message/format.ts +42 -0
  61. package/extensions/bytefly/message/register.ts +15 -0
  62. package/extensions/bytefly/notice/component.ts +107 -0
  63. package/extensions/bytefly/notice/config.ts +41 -0
  64. package/extensions/bytefly/notice/register.ts +27 -0
  65. package/extensions/bytefly/pad.ts +42 -0
  66. package/extensions/bytefly/panel/commands.ts +69 -35
  67. package/extensions/bytefly/panel/format.ts +8 -8
  68. package/extensions/bytefly/panel/tui.ts +45 -70
  69. package/extensions/bytefly/separator/component.ts +2 -1
  70. package/extensions/bytefly/separator/config.ts +16 -8
  71. package/extensions/bytefly/separator/format.ts +18 -38
  72. package/extensions/bytefly/separator/register.ts +3 -2
  73. package/extensions/bytefly/separator/types.ts +0 -10
  74. package/extensions/bytefly/working/config.ts +20 -0
  75. package/extensions/bytefly/working/format.ts +25 -0
  76. package/extensions/bytefly/working/register.ts +88 -0
  77. package/extensions/document/README.md +12 -5
  78. package/extensions/document/paging.ts +10 -10
  79. package/extensions/document/render.ts +17 -16
  80. package/extensions/document/tool.ts +1 -1
  81. package/extensions/file-browse/README.md +54 -10
  82. package/extensions/file-browse/client.ts +110 -2
  83. package/extensions/file-browse/config.ts +12 -0
  84. package/extensions/file-browse/diff.ts +187 -0
  85. package/extensions/file-browse/git.ts +180 -8
  86. package/extensions/file-browse/html.ts +401 -25
  87. package/extensions/file-browse/index.ts +15 -4
  88. package/extensions/file-browse/markdown.ts +33 -0
  89. package/extensions/file-browse/router.ts +94 -2
  90. package/extensions/file-browse/server.ts +4 -1
  91. package/extensions/file-open/README.md +6 -0
  92. package/extensions/goal/README.md +16 -10
  93. package/extensions/goal/format.ts +13 -25
  94. package/extensions/goal/tools.ts +11 -22
  95. package/extensions/goal/tui.ts +12 -40
  96. package/extensions/job/README.md +22 -13
  97. package/extensions/job/format.ts +36 -13
  98. package/extensions/job/index.ts +26 -21
  99. package/extensions/job/tools.ts +15 -12
  100. package/extensions/job/tui.ts +148 -222
  101. package/extensions/job/ui.ts +24 -49
  102. package/extensions/memory/README.md +7 -1
  103. package/extensions/memory/format.ts +5 -3
  104. package/extensions/memory/tools.ts +4 -3
  105. package/extensions/memory/tui.ts +32 -36
  106. package/extensions/plan/README.md +86 -36
  107. package/extensions/plan/blocks.ts +44 -2
  108. package/extensions/plan/feedback.ts +20 -5
  109. package/extensions/plan/index.ts +1 -1
  110. package/extensions/plan/runtime.ts +3 -2
  111. package/extensions/plan/tools.ts +43 -27
  112. package/extensions/plan/tui.ts +21 -27
  113. package/extensions/plan/web-client.ts +386 -40
  114. package/extensions/plan/web-page.ts +138 -33
  115. package/extensions/plan/web-router.ts +46 -13
  116. package/extensions/plan/web.ts +63 -12
  117. package/extensions/question/README.md +14 -2
  118. package/extensions/question/answers.ts +25 -18
  119. package/extensions/question/tools.ts +22 -16
  120. package/extensions/question/tui.ts +132 -76
  121. package/extensions/rewind/README.md +3 -1
  122. package/extensions/rewind/format.ts +35 -2
  123. package/extensions/rewind/index.ts +3 -1
  124. package/extensions/rewind/rewind-tui.ts +66 -62
  125. package/extensions/rewind/snapshot-run.ts +1 -1
  126. package/extensions/rewind/status.ts +3 -2
  127. package/extensions/subagent/README.md +6 -0
  128. package/extensions/subagent/index.ts +1 -1
  129. package/extensions/subagent/render.ts +108 -66
  130. package/extensions/todo/README.md +20 -13
  131. package/extensions/todo/format.ts +47 -34
  132. package/extensions/todo/tools.ts +6 -4
  133. package/extensions/todo/tui.ts +46 -26
  134. package/extensions/watch/README.md +16 -8
  135. package/extensions/watch/format.ts +33 -13
  136. package/extensions/watch/index.ts +29 -24
  137. package/extensions/watch/tools.ts +16 -9
  138. package/extensions/watch/tui.ts +142 -196
  139. package/extensions/watch/ui.ts +24 -46
  140. package/extensions/web-access/README.md +16 -7
  141. package/extensions/web-access/fetch/tool.ts +12 -11
  142. package/extensions/web-access/search/tool.ts +16 -13
  143. package/extensions/wechat/README.md +8 -2
  144. package/extensions/wechat/index.ts +4 -3
  145. package/extensions/wechat/login-screen.ts +8 -5
  146. package/extensions/wechat/status-screen.ts +41 -22
  147. package/extensions/wechat/tools.ts +6 -5
  148. package/extensions/worktree/README.md +6 -0
  149. package/extensions/worktree/enter.ts +1 -1
  150. package/extensions/worktree/index.ts +1 -1
  151. package/extensions/worktree/runtime.ts +1 -1
  152. package/lib/README.md +11 -7
  153. package/lib/format.ts +17 -0
  154. package/lib/notice.ts +87 -0
  155. package/lib/requirements.ts +2 -2
  156. package/lib/{tui.ts → tui/chrome.ts} +17 -34
  157. package/lib/tui/columns.ts +96 -0
  158. package/lib/{list-cursor.ts → tui/cursor.ts} +58 -4
  159. package/lib/tui/gauge.ts +78 -0
  160. package/lib/{ui.ts → tui/glyphs.ts} +10 -11
  161. package/lib/tui/indent.ts +166 -0
  162. package/lib/tui/index.ts +27 -0
  163. package/lib/tui/line-pane.ts +173 -0
  164. package/lib/tui/rows.ts +143 -0
  165. package/lib/tui/rules.ts +94 -0
  166. package/lib/tui/status-controller.ts +107 -0
  167. package/lib/tui/tokens.ts +72 -0
  168. package/lib/web/base.ts +2 -1
  169. package/lib/web/components.ts +3 -1
  170. package/lib/web/layout.ts +5 -1
  171. package/package.json +7 -3
  172. package/themes/bytefly-dark.json +7 -8
  173. package/themes/bytefly-light.json +10 -11
  174. package/assets/bytefly-chrome.png +0 -0
@@ -9,26 +9,15 @@
9
9
  */
10
10
 
11
11
  import type { Theme } from "@earendil-works/pi-coding-agent";
12
- import {
13
- Key,
14
- matchesKey,
15
- truncateToWidth,
16
- type Component,
17
- type TuiMouseEvent,
18
- type TuiMouseEventResult,
19
- } from "@earendil-works/pi-tui";
20
- import {
21
- fitRows,
22
- formatRange,
23
- isCloseKey,
24
- ListCursor,
25
- navigateList,
26
- scrollList,
27
- selectionMarker,
28
- } from "../../lib/list-cursor.ts";
29
- import { screenHeader, screenHint, type ViewportRowsSource } from "../../lib/tui.ts";
30
- import { SEPARATORS } from "../../lib/ui.ts";
31
- import { formatRewindDetail, formatRewindRow } from "./format.ts";
12
+ import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
13
+ import { fitRows, formatRange, isCloseKey, ListCursor, navigateList, scrollList } from "../../lib/tui/cursor.ts";
14
+ import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
15
+ import { clip } from "../../lib/tui/columns.ts";
16
+ import { SEPARATORS } from "../../lib/tui/glyphs.ts";
17
+ import { emptyState, listRow } from "../../lib/tui/rows.ts";
18
+ import { titleRule } from "../../lib/tui/rules.ts";
19
+ import { paint } from "../../lib/tui/tokens.ts";
20
+ import { formatRewindDetail, formatRewindParts } from "./format.ts";
32
21
  import type { RewindPoint } from "./timeline.ts";
33
22
 
34
23
  export interface RewindListOptions {
@@ -43,7 +32,7 @@ export interface RewindListOptions {
43
32
  /** Rows the screen shows when the terminal height is unknown. */
44
33
  const SCREEN_DEFAULT_ITEMS = 12;
45
34
  /** Header, summary, blanks, and footer rows around the list (excluding the optional range/detail rows). */
46
- const SCREEN_CHROME_ROWS = 7;
35
+ const SCREEN_CHROME_ROWS = 6;
47
36
 
48
37
  /** Selectable, scrollable timeline of prompts opened by `/rewind`. */
49
38
  export class RewindListComponent implements Component {
@@ -83,53 +72,68 @@ export class RewindListComponent implements Component {
83
72
 
84
73
  invalidate(): void {}
85
74
 
86
- // Kept off `dockListFrame`: `/rewind` leads with a blank after the header and
87
- // has no blank before the hint, so the shared frame would change its output.
88
- // Its blank-line convention is deliberately local.
75
+ // The shared dock frame, like every other list screen.
89
76
  render(width: number): string[] {
90
77
  const w = Math.max(1, width);
91
- const lines: string[] = [screenHeader(this.theme, w, "Rewind"), ""];
78
+ const header = titleRule(this.theme, w, "Rewind");
92
79
 
93
80
  if (this.points.length === 0) {
94
- lines.push(truncateToWidth(` ${this.theme.fg("dim", "No prompts on this branch yet.")}`, w));
95
- } else {
96
- const count = `${this.points.length} prompt${this.points.length === 1 ? "" : "s"}`;
97
- lines.push(truncateToWidth(` ${this.theme.fg("muted", `${count}${SEPARATORS.item}newest first`)}`, w));
98
- lines.push("");
99
- const cursor = this.cursor;
100
- const focused = this.points[cursor.selected];
101
- const detail = focused ? formatRewindDetail(Boolean(focused.snapshot), focused.snapshot?.id) : "";
102
- cursor.visible = fitRows(
103
- this.options.viewportRows,
104
- this.points.length,
105
- SCREEN_CHROME_ROWS + (detail ? 1 : 0),
106
- SCREEN_DEFAULT_ITEMS,
107
- );
108
-
109
- const { start, end } = cursor.window(this.points.length);
110
- for (let i = start; i < end; i++) {
111
- const selected = i === cursor.selected;
112
- const row = formatRewindRow(
113
- this.points[i].summary,
114
- Boolean(this.points[i].snapshot),
115
- this.points[i].timestamp,
116
- Date.now(),
117
- Math.max(1, w - 2),
118
- );
119
- const body = selected ? this.theme.fg("accent", row) : this.theme.fg("muted", row);
120
- lines.push(truncateToWidth(selectionMarker(this.theme, selected) + body, w));
121
- }
122
- if (cursor.clipped(this.points.length)) {
123
- lines.push(formatRange(this.theme, w, { start, end, total: this.points.length }));
124
- }
125
- lines.push("");
126
- if (detail) lines.push(truncateToWidth(` ${this.theme.fg("muted", detail)}`, w));
81
+ return dockListFrame({
82
+ header,
83
+ body: emptyState(this.theme, w, { label: "No prompts on this branch yet." }),
84
+ hint: this.hint(w),
85
+ });
127
86
  }
128
87
 
129
- lines.push(
130
- screenHint(this.theme, w, ["Esc close", { text: "Enter choose", color: "accent" }, "↑/↓ or k/j select"]),
88
+ const count = `${this.points.length} prompt${this.points.length === 1 ? "" : "s"}`;
89
+ const cursor = this.cursor;
90
+ const focused = this.points[cursor.selected];
91
+ const detail = focused ? formatRewindDetail(Boolean(focused.snapshot), focused.snapshot?.id) : "";
92
+ cursor.visible = fitRows(
93
+ this.options.viewportRows,
94
+ this.points.length,
95
+ SCREEN_CHROME_ROWS + (detail ? 1 : 0),
96
+ SCREEN_DEFAULT_ITEMS,
131
97
  );
132
- lines.push("");
133
- return lines;
98
+
99
+ const { start, end } = cursor.window(this.points.length);
100
+ const body: string[] = [];
101
+ for (let i = start; i < end; i++) {
102
+ const selected = i === cursor.selected;
103
+ const parts = formatRewindParts(
104
+ this.points[i].summary,
105
+ Boolean(this.points[i].snapshot),
106
+ this.points[i].timestamp,
107
+ Date.now(),
108
+ );
109
+ const tone = selected ? "accent" : "meta";
110
+ body.push(
111
+ listRow(this.theme, w, {
112
+ selected,
113
+ glyph: parts.glyph,
114
+ glyphRole: tone,
115
+ label: parts.label,
116
+ labelRole: tone,
117
+ trailing: parts.trailing,
118
+ trailingRole: tone,
119
+ }),
120
+ );
121
+ }
122
+
123
+ return dockListFrame({
124
+ header,
125
+ summary: clip(` ${paint(this.theme, "meta", `${count}${SEPARATORS.item}newest first`)}`, w),
126
+ body,
127
+ range: cursor.clipped(this.points.length)
128
+ ? formatRange(this.theme, w, { start, end, total: this.points.length })
129
+ : undefined,
130
+ detail: detail ? [clip(` ${paint(this.theme, "meta", detail)}`, w)] : undefined,
131
+ hint: this.hint(w),
132
+ });
133
+ }
134
+
135
+ /** The key-hint row, with the primary action in the accent role. */
136
+ private hint(width: number): string {
137
+ return screenHint(this.theme, width, ["Esc close", { text: "Enter choose", color: "accent" }, "↑/↓ or k/j select"]);
134
138
  }
135
139
  }
@@ -54,7 +54,7 @@ export function createSnapshotRun(pi: ExtensionAPI, runtime: RewindRuntime): Sna
54
54
  runAutoSnapshots = false;
55
55
  };
56
56
 
57
- const beginRun = async (prompt: string, ctx: ExtensionContext): Promise<void> => {
57
+ const beginRun = async (prompt: string | undefined, ctx: ExtensionContext): Promise<void> => {
58
58
  reset();
59
59
  pendingPrompt = summarizePrompt(prompt);
60
60
  try {
@@ -8,7 +8,8 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
- import { GLYPHS, STATUS_KEYS } from "../../lib/ui.ts";
11
+ import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
12
+ import { paint } from "../../lib/tui/tokens.ts";
12
13
  import type { RewindConfig } from "./config.ts";
13
14
  import { userMessagesFromBranch } from "./timeline.ts";
14
15
 
@@ -45,7 +46,7 @@ export function createStatusChip(deps: StatusChipDeps): StatusChip {
45
46
  const label = `${STATUS_GLYPH} ${count}`;
46
47
  let themed = label;
47
48
  try {
48
- themed = ctx.ui.theme.fg("accent", label);
49
+ themed = paint(ctx.ui.theme, "accent", label);
49
50
  } catch {
50
51
  // No initialized theme in a headless run.
51
52
  }
@@ -10,6 +10,12 @@ pi -e . # load the whole @gavin-hu/my-pi-agent p
10
10
  pi install ./ # install the package
11
11
  ```
12
12
 
13
+ ## Tool rendering
14
+
15
+ How the `subagent` call and its result look in the transcript — the call line as a headline and the body indented under it:
16
+
17
+ <img src="../../assets/tools/subagent.png" alt="The subagent tool's call and result: the call line and an indented body with the two tasks, each agent's section rule, its summary, and the usage total" width="100%">
18
+
13
19
  ## What it does
14
20
 
15
21
  - **Isolated context.** Each subagent is a separate `pi` process with its own
@@ -168,7 +168,7 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
168
168
  },
169
169
 
170
170
  renderCall(args, theme, context) {
171
- return renderSubagentCall(args as SubagentArgs, theme, {
171
+ return renderSubagentCall(args, theme, {
172
172
  cwd: context.cwd,
173
173
  lastComponent: context.lastComponent,
174
174
  });
@@ -12,10 +12,13 @@
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, SEPARATORS } from "../../lib/tui/glyphs.ts";
19
+ import { centeredRule } from "../../lib/tui/rules.ts";
20
+ import { BODY_INDENT } from "../../lib/tui/indent.ts";
21
+ import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
19
22
  import { aggregateUsage, clip, formatToolCall, formatUsageStats } from "./format.ts";
20
23
  import { COLLAPSED_ERROR_MAX, COLLAPSED_ITEM_COUNT, COLLAPSED_TEXT_LINES, type SubagentArgs } from "./schema.ts";
21
24
  import { getFinalOutput, isFailedResult } from "./stream.ts";
@@ -24,6 +27,11 @@ import type { SingleResult, SubagentDetails } from "./types.ts";
24
27
  type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, unknown> };
25
28
  type ResultStatus = "running" | "aborted" | "failed" | "success";
26
29
 
30
+ /** Columns a section rule adds around its label: a space and three dashes each side. */
31
+ const SECTION_RULE_PAD = 8;
32
+ /** A section rule keeps the `meta` colour the hand-rolled `─── label ───` used. */
33
+ const SECTION_RULE_STYLE = { pad: 1, fillRole: "meta", labelRole: "meta" } as const;
34
+
27
35
  /** Text and tool-call parts of every assistant message, in order. */
28
36
  function getDisplayItems(messages: SingleResult["messages"]): DisplayItem[] {
29
37
  const items: DisplayItem[] = [];
@@ -45,12 +53,12 @@ function hasExpandableContent(items: DisplayItem[]): boolean {
45
53
  );
46
54
  }
47
55
 
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",
56
+ /** Design roles per status; the running glyph is time-based, so it is not a value here. */
57
+ const STATUS_ROLE: Record<ResultStatus, TuiRole> = {
58
+ running: "warn",
59
+ aborted: "warn",
60
+ failed: "bad",
61
+ success: "ok",
54
62
  };
55
63
 
56
64
  /** Static glyphs for terminal states, from the shared narrow-glyph vocabulary. */
@@ -77,12 +85,12 @@ function resultStatus(result: SingleResult, isPartial = false): ResultStatus {
77
85
  }
78
86
 
79
87
  function statusIcon(status: ResultStatus, theme: Theme, now: number): string {
80
- return theme.fg(STATUS_COLOR[status], statusGlyph(status, now));
88
+ return paint(theme, STATUS_ROLE[status], statusGlyph(status, now));
81
89
  }
82
90
 
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];
91
+ /** The role the status uses, so the `[stopReason]` bracket matches its icon. */
92
+ function statusRole(status: ResultStatus): TuiRole {
93
+ return STATUS_ROLE[status];
86
94
  }
87
95
 
88
96
  /** The message a failed/aborted result should show, falling back to stderr. */
@@ -99,7 +107,7 @@ function renderError(result: SingleResult, status: ResultStatus, theme: Theme, m
99
107
  const detail = errorDetail(result, status, max);
100
108
  if (!detail) return undefined;
101
109
  const label = status === "aborted" ? "Aborted" : "Error";
102
- return theme.fg(status === "aborted" ? "warning" : "error", `${label}: ${detail}`);
110
+ return paint(theme, status === "aborted" ? "warn" : "bad", `${label}: ${detail}`);
103
111
  }
104
112
 
105
113
  /** Whether the collapsed error/stderr text is truncated, so expanding shows more. */
@@ -114,7 +122,7 @@ function hasClippedError(result: SingleResult, status: ResultStatus): boolean {
114
122
  function collapsedBody(result: SingleResult, status: ResultStatus, items: DisplayItem[], theme: Theme): string {
115
123
  const error = renderError(result, status, theme, COLLAPSED_ERROR_MAX);
116
124
  if (error) return error;
117
- if (items.length === 0) return theme.fg("muted", status === "running" ? "(running…)" : "(no output)");
125
+ if (items.length === 0) return paint(theme, "meta", status === "running" ? "(running…)" : "(no output)");
118
126
  return renderDisplayItems(items, theme, COLLAPSED_ITEM_COUNT);
119
127
  }
120
128
 
@@ -164,11 +172,11 @@ function durationSuffix(result: SingleResult, status: ResultStatus, now: number)
164
172
  function statusSuffix(result: SingleResult, status: ResultStatus, theme: Theme, now: number): string {
165
173
  let suffix = "";
166
174
  const duration = durationSuffix(result, status, now);
167
- if (duration) suffix += theme.fg("dim", duration);
175
+ if (duration) suffix += paint(theme, "faint", duration);
168
176
  const showReason = status === "failed" || status === "aborted";
169
177
  const stop = showReason && result.stopReason && result.stopReason !== "end" ? result.stopReason : undefined;
170
178
  const reason = stop ?? (status === "failed" ? `exit ${result.exitCode}` : undefined);
171
- if (reason) suffix += ` ${theme.fg(statusColor(status), `[${reason}]`)}`;
179
+ if (reason) suffix += ` ${paint(theme, statusRole(status), `[${reason}]`)}`;
172
180
  return suffix;
173
181
  }
174
182
 
@@ -180,20 +188,21 @@ function resultHeader(
180
188
  stepPrefix: string,
181
189
  now: number,
182
190
  ): string {
183
- let header = `${statusIcon(status, theme, now)} ${theme.fg("toolTitle", theme.bold(sanitizeLine(result.agent)))}`;
191
+ let header = `${statusIcon(status, theme, now)} ${paint(theme, "accent", theme.bold(sanitizeLine(result.agent)))}`;
184
192
  if (result.agentSource && result.agentSource !== "builtin" && result.agentSource !== "unknown") {
185
- header += theme.fg("dim", ` [${sanitizeLine(result.agentSource)}]`);
193
+ header += paint(theme, "faint", ` [${sanitizeLine(result.agentSource)}]`);
186
194
  }
187
- if (result.step) header = `${theme.fg("muted", `${stepPrefix} ${result.step} `)}` + header;
195
+ if (result.step) header = `${paint(theme, "meta", `${stepPrefix} ${result.step} `)}` + header;
188
196
  return header + statusSuffix(result, status, theme, now);
189
197
  }
190
198
 
191
- /** Prefix each line of intermediate assistant text with a muted gutter. */
192
- function quoteLines(text: string, theme: Theme): string {
199
+ /** Prefix each line of intermediate assistant text with the block's body indent. */
200
+ function indentBody(text: string, theme: Theme): string {
201
+ const indent = " ".repeat(BODY_INDENT);
193
202
  return stripControlChars(text)
194
203
  .trim()
195
204
  .split("\n")
196
- .map((line) => theme.fg("muted", "│ ") + theme.fg("toolOutput", line))
205
+ .map((line) => indent + theme.fg("toolOutput", line))
197
206
  .join("\n");
198
207
  }
199
208
 
@@ -201,14 +210,14 @@ function renderDisplayItems(items: DisplayItem[], theme: Theme, limit?: number,
201
210
  const shown = limit ? items.slice(-limit) : items;
202
211
  const skipped = limit && items.length > limit ? items.length - limit : 0;
203
212
  const lines: string[] = [];
204
- if (skipped > 0) lines.push(theme.fg("muted", `… ${skipped} earlier items`));
213
+ if (skipped > 0) lines.push(paint(theme, "meta", `… ${skipped} earlier items`));
205
214
  for (const item of shown) {
206
215
  if (item.type === "text") {
207
216
  const safe = stripControlChars(item.text);
208
217
  const text = preview ? safe.split("\n").slice(0, COLLAPSED_TEXT_LINES).join("\n") : safe;
209
218
  lines.push(theme.fg("toolOutput", text));
210
219
  } else {
211
- lines.push(theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme, preview));
220
+ lines.push(paint(theme, "meta", "→ ") + formatToolCall(item.name, item.args, theme, preview));
212
221
  }
213
222
  }
214
223
  return lines.join("\n");
@@ -226,45 +235,72 @@ export interface SubagentCallContext {
226
235
  lastComponent?: Component;
227
236
  }
228
237
 
238
+ /**
239
+ * The call's arguments as Pi hands them to `renderCall`. While the model streams
240
+ * a call, a field can still be missing, or arrive with a type the schema will
241
+ * later reject, so the call line reads every one defensively and never throws: a
242
+ * throwing renderer makes Pi fall back to its own generic call line.
243
+ */
244
+ export type StreamedSubagentArgs = Partial<Record<keyof SubagentArgs, unknown>>;
245
+
246
+ /** One field of a partially-streamed element, or undefined when it has not arrived. */
247
+ function streamedText(element: unknown, key: "agent" | "task" | "cwd"): string | undefined {
248
+ if (typeof element !== "object" || element === null) return undefined;
249
+ const value = (element as Record<string, unknown>)[key];
250
+ return typeof value === "string" ? value : undefined;
251
+ }
252
+
229
253
  /** Preview the requested mode and its agents, reusing the slot's `Text`. */
230
- export function renderSubagentCall(args: SubagentArgs, theme: Theme, context?: SubagentCallContext): Component {
254
+ export function renderSubagentCall(
255
+ args: StreamedSubagentArgs | undefined,
256
+ theme: Theme,
257
+ context?: SubagentCallContext,
258
+ ): Component {
231
259
  const text = reuseText(context?.lastComponent);
232
- text.setText(callText(args, theme, context));
260
+ text.setText(callText(args ?? {}, theme, context));
233
261
  return text;
234
262
  }
235
263
 
236
- /** The call-line string for the requested mode. */
237
- 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 ") : "");
239
- const cwdNote = (cwd?: string) => {
240
- if (!cwd || cwd === context?.cwd) return "";
241
- return theme.fg("dim", ` in ${shortenPath(cwd)}`);
264
+ /**
265
+ * The call-line string for the requested mode. Continuation lines carry no
266
+ * indent of their own: the tool-block frame headlines the first line and
267
+ * indents the rest by `BODY_INDENT`.
268
+ */
269
+ function callText(args: StreamedSubagentArgs, theme: Theme, context?: { cwd?: string }): string {
270
+ const title =
271
+ theme.fg("toolTitle", theme.bold("subagent ")) + (args.readOnly ? paint(theme, "faint", "read-only ") : "");
272
+ const cwdNote = (cwd?: unknown) => {
273
+ if (typeof cwd !== "string" || !cwd || cwd === context?.cwd) return "";
274
+ return paint(theme, "faint", ` in ${shortenPath(cwd)}`);
242
275
  };
243
-
244
- if (args.chain && args.chain.length > 0) {
245
- let text = title + theme.fg("accent", `chain · ${args.chain.length} step${args.chain.length > 1 ? "s" : ""}`);
246
- for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
247
- const step = args.chain[i];
248
- 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)}`;
276
+ const chain = Array.isArray(args.chain) ? args.chain : [];
277
+ if (chain.length > 0) {
278
+ let text =
279
+ title + paint(theme, "label", `chain${SEPARATORS.item}${chain.length} step${chain.length > 1 ? "s" : ""}`);
280
+ for (let i = 0; i < Math.min(chain.length, 3); i++) {
281
+ const step = chain[i];
282
+ const task = (streamedText(step, "task") ?? "…").replace(/\{previous\}/g, "");
283
+ text += `\n${paint(theme, "meta", `${i + 1}.`)} ${paint(theme, "label", sanitizeLine(streamedText(step, "agent") || "…"))}${paint(theme, "meta", ` ${clip(sanitizeLine(task), 40)}`)}${cwdNote(streamedText(step, "cwd"))}`;
250
284
  }
251
- if (args.chain.length > 3) text += `\n ${theme.fg("muted", `… +${args.chain.length - 3} more`)}`;
285
+ if (chain.length > 3) text += `\n${paint(theme, "meta", `… +${chain.length - 3} more`)}`;
252
286
  return text;
253
287
  }
254
288
 
255
- if (args.tasks && args.tasks.length > 0) {
256
- let text = title + theme.fg("accent", `parallel · ${args.tasks.length} task${args.tasks.length > 1 ? "s" : ""}`);
257
- for (let i = 0; i < Math.min(args.tasks.length, 3); i++) {
258
- 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)}`;
289
+ const tasks = Array.isArray(args.tasks) ? args.tasks : [];
290
+ if (tasks.length > 0) {
291
+ let text =
292
+ title + paint(theme, "label", `parallel${SEPARATORS.item}${tasks.length} task${tasks.length > 1 ? "s" : ""}`);
293
+ for (let i = 0; i < Math.min(tasks.length, 3); i++) {
294
+ const task = tasks[i];
295
+ text += `\n${paint(theme, "meta", `${i + 1}.`)} ${paint(theme, "label", sanitizeLine(streamedText(task, "agent") || "…"))}${paint(theme, "meta", ` ${clip(sanitizeLine(streamedText(task, "task") ?? "…"), 40)}`)}${cwdNote(streamedText(task, "cwd"))}`;
260
296
  }
261
- if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `… +${args.tasks.length - 3} more`)}`;
297
+ if (tasks.length > 3) text += `\n${paint(theme, "meta", `… +${tasks.length - 3} more`)}`;
262
298
  return text;
263
299
  }
264
300
 
265
- 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))}`;
301
+ const agentName = sanitizeLine(streamedText(args, "agent") || "…");
302
+ let text = title + paint(theme, "label", agentName) + cwdNote(streamedText(args, "cwd"));
303
+ text += `\n${paint(theme, "meta", clip(sanitizeLine(streamedText(args, "task") ?? "…"), 60))}`;
268
304
  return text;
269
305
  }
270
306
 
@@ -283,8 +319,10 @@ function addExpandedResult(
283
319
  if (error) container.addChild(new Text(error, 0, 0));
284
320
 
285
321
  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));
322
+ container.addChild(
323
+ new Text(centeredRule(theme, visibleWidth("Task") + SECTION_RULE_PAD, "Task", SECTION_RULE_STYLE), 0, 0),
324
+ );
325
+ container.addChild(new Text(paint(theme, "faint", stripControlChars(result.taskTemplate ?? result.task)), 0, 0));
288
326
 
289
327
  const displayItems = getDisplayItems(result.messages);
290
328
  const lastItem = displayItems[displayItems.length - 1];
@@ -294,14 +332,18 @@ function addExpandedResult(
294
332
  // order and do not surface a stale earlier message as the answer.
295
333
  const trailingOutput = lastItem?.type === "text" && lastItem.text === finalOutput ? lastItem.text : "";
296
334
  container.addChild(new Spacer(1));
297
- container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
335
+ container.addChild(
336
+ new Text(centeredRule(theme, visibleWidth("Output") + SECTION_RULE_PAD, "Output", SECTION_RULE_STYLE), 0, 0),
337
+ );
298
338
  let renderedOutput = false;
299
339
  for (const item of displayItems) {
300
340
  if (item.type === "toolCall") {
301
- container.addChild(new Text(theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme, false), 0, 0));
341
+ container.addChild(
342
+ new Text(paint(theme, "meta", "→ ") + formatToolCall(item.name, item.args, theme, false), 0, 0),
343
+ );
302
344
  renderedOutput = true;
303
345
  } else if (item.text.trim() && item.text !== trailingOutput) {
304
- container.addChild(new Text(quoteLines(item.text, theme), 0, 0));
346
+ container.addChild(new Text(indentBody(item.text, theme), 0, 0));
305
347
  renderedOutput = true;
306
348
  }
307
349
  }
@@ -311,13 +353,13 @@ function addExpandedResult(
311
353
  renderedOutput = true;
312
354
  }
313
355
  if (!renderedOutput) {
314
- container.addChild(new Text(theme.fg("muted", status === "running" ? "(running…)" : "(no output)"), 0, 0));
356
+ container.addChild(new Text(paint(theme, "meta", status === "running" ? "(running…)" : "(no output)"), 0, 0));
315
357
  }
316
358
 
317
359
  const usage = usageLine(result);
318
360
  if (usage) {
319
361
  container.addChild(new Spacer(1));
320
- container.addChild(new Text(theme.fg("dim", usage), 0, 0));
362
+ container.addChild(new Text(paint(theme, "faint", usage), 0, 0));
321
363
  }
322
364
  }
323
365
 
@@ -333,7 +375,7 @@ function collapsedSingleText(result: SingleResult, theme: Theme, isPartial: bool
333
375
  text += `\n\n${collapsedBody(result, status, displayItems, theme)}`;
334
376
  if (isExpandable(result, status, displayItems)) text += `\n${expandHint(theme)}`;
335
377
  const usage = usageLine(result);
336
- if (usage) text += `\n${theme.fg("dim", usage)}`;
378
+ if (usage) text += `\n${paint(theme, "faint", usage)}`;
337
379
  return text;
338
380
  }
339
381
 
@@ -375,11 +417,11 @@ function multiHeader(details: SubagentDetails, theme: Theme, now: number): strin
375
417
  const bad = failed + aborted;
376
418
  const icon =
377
419
  running > 0
378
- ? theme.fg("warning", statusGlyph("running", now))
420
+ ? paint(theme, "warn", statusGlyph("running", now))
379
421
  : 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)}`;
422
+ ? paint(theme, bad === total ? "bad" : "warn", bad === total ? GLYPHS.jobsFailure : GLYPHS.half)
423
+ : paint(theme, "ok", GLYPHS.check);
424
+ return `${icon} ${theme.fg("toolTitle", theme.bold(`${details.mode} · `))}${paint(theme, "accent", status)}`;
383
425
  }
384
426
 
385
427
  function collapsedMultiText(details: SubagentDetails, theme: Theme, now: number): string {
@@ -394,14 +436,14 @@ function collapsedMultiText(details: SubagentDetails, theme: Theme, now: number)
394
436
  const displayItems = getDisplayItems(result.messages);
395
437
  if (isExpandable(result, status, displayItems)) hasHidden = true;
396
438
  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)}`;
439
+ const taskNote = taskText ? paint(theme, "faint", ` ${clip(sanitizeLine(taskText), 50)}`) : "";
440
+ text += `\n\n${paint(theme, "meta", "─── ")}${paint(theme, "accent", resultLabel(result, i))}${taskNote} ${statusIcon(status, theme, now)}`;
399
441
  text += statusSuffix(result, status, theme, now);
400
442
  text += `\n${collapsedBody(result, status, displayItems, theme)}`;
401
443
  }
402
444
  if (running === 0) {
403
445
  const usage = totalUsageLine(results);
404
- if (usage) text += `\n\n${theme.fg("dim", `Total: ${usage}`)}`;
446
+ if (usage) text += `\n\n${paint(theme, "faint", `Total: ${usage}`)}`;
405
447
  }
406
448
  if (running > 0 || hasHidden) text += `\n${expandHint(theme)}`;
407
449
  return text;
@@ -421,7 +463,7 @@ function fillExpandedMulti(container: Container, details: SubagentDetails, theme
421
463
  const usage = totalUsageLine(details.results);
422
464
  if (usage) {
423
465
  container.addChild(new Spacer(1));
424
- container.addChild(new Text(theme.fg("dim", `Total: ${usage}`), 0, 0));
466
+ container.addChild(new Text(paint(theme, "faint", `Total: ${usage}`), 0, 0));
425
467
  }
426
468
  }
427
469
  }
@@ -8,12 +8,20 @@ about to end after mutating work without a todo update, the extension injects
8
8
  one short reminder and forces a continuation, so the list does not silently
9
9
  drift behind the work.
10
10
 
11
+ <img src="../../assets/todo.png" alt="The /todos screen: a Todos title rule, a 3/7 completed summary, and the list with check, spinner, and circle glyphs" width="100%">
12
+
11
13
  ```
12
14
  pi --extension ./extensions/todo # load just this extension
13
15
  pi -e . # load the whole @gavin-hu/my-pi-agent package
14
16
  pi install ./ # install the package
15
17
  ```
16
18
 
19
+ ## Tool rendering
20
+
21
+ How the `todo` call and its result look in the transcript — the call line as a headline and the body indented under it:
22
+
23
+ <img src="../../assets/tools/todo.png" alt="The todo tool's result: the call line and an indented body with the progress line and the task rows below it, each led by a check, spinner, or circle glyph" width="100%">
24
+
17
25
  ## What it does
18
26
 
19
27
  - Registers one model-callable tool, `todo`, that **replaces** the task list.
@@ -49,22 +57,20 @@ Todos · 1/3 · Writing the tests
49
57
  `/todos` is the expanded view and keeps the model's list order (use it to see
50
58
  the plan as written).
51
59
 
52
- In the transcript, the call line pairs progress with the active item
53
- (`todo → 1/4 · Writing tests`), and the result keeps the shared
54
- `✓` / `◒` / `◌` rail used by the goal result, separated from the call line by a
55
- blank line. Collapsed, it leads with active work; expanded, it keeps the
56
- submitted order:
60
+ In the transcript, the call line carries progress only (`todo → 1/4`); the
61
+ result keeps the `✓` / `◒` / `◌` rows, directly under the call line. The item
62
+ text lives on the rows, so the call line never repeats it. Collapsed, it leads
63
+ with active work; expanded, it keeps the submitted order:
57
64
 
58
65
  ```
59
- todo → 1/4 · Writing tests
60
-
66
+ todo → 1/4
61
67
  ◒ Writing tests
62
68
  ◌ Ship it
63
69
  ◌ Write docs
64
70
  ✓ Write schema
65
71
  ```
66
72
 
67
- Progress lives on the call line, so the rail has no footer; the persistent
73
+ Progress lives on the call line, so the rows have no footer; the persistent
68
74
  widget and `/todos` still show the count. A capped collapsed rail ends with
69
75
  `… N more` and the `app.tools.expand` hint.
70
76
 
@@ -204,10 +210,11 @@ depends on config.
204
210
  widget is a single line (`currentTodo` picks the `in_progress` item, else the
205
211
  first pending); `/todos` keeps the submitted order, and the transcript result
206
212
  orders by status via `compareByActivity` so active work is never hidden. The
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
209
- long items with continuation rows aligned under the text. Progress lives on
210
- the call line, so the rail carries no footer.
213
+ transcript result draws the glyph rows at the block's body indent (the frame
214
+ supplies `BODY_INDENT`; `GLYPH_GAP` comes from
215
+ [`lib/tui/glyphs.ts`](../../lib/tui/glyphs.ts)), wrapping long items with
216
+ continuation rows aligned under the text. Progress lives on the call line, so
217
+ the rows carry no footer.
211
218
  - **The transcript reuses its components.** `renderCall` reuses the slot's
212
219
  `Text`, and `renderResult` reuses the `TodoResult` view via
213
220
  `context.lastComponent`, updating each in place instead of allocating on every
@@ -223,7 +230,7 @@ depends on config.
223
230
  | `state.ts` | Branch reconstruction and status queries (pure). |
224
231
  | `nudge.ts` | Lag detection: mutating-tool classification, due check, reminder text (pure). |
225
232
  | `format.ts` | Model-facing and transcript text (pure). |
226
- | `tui.ts` | Widget and scrollable `/todos` components. |
233
+ | `tui.ts` | Widget and scrollable `/todos` components, drawn from the `lib/tui/` kit (`titleRule`, `dockListFrame`, `listRow`, `emptyState`). |
227
234
  | `config.ts` | Widget config load/validation (the `todo` section of `bytefly.json`). |
228
235
  | `runtime.ts` | Session-scoped state, lag tracking, and widget synchronization. |
229
236
  | `tools.ts` | `todo` tool registration and rendering. |