@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
@@ -6,34 +6,24 @@
6
6
  * computed from `Date.now()` at render, so the command can refresh the screen
7
7
  * without rebuilding the component.
8
8
  *
9
- * Every line is clipped with `truncateToWidth`; log text arrives already
10
- * sanitized by the runtime, so no untrusted escape sequence reaches the
11
- * terminal through here.
9
+ * The screen is drawn from the design system: a label-first `titleRule`, one
10
+ * `listRow` grammar, `emptyState`, and the shared `IdCursor` (a live job list is
11
+ * re-sorted under the focus) and `LinePane` (the log tail).
12
+ *
13
+ * Log text arrives already sanitized by the runtime, so no untrusted escape
14
+ * sequence reaches the terminal through here.
12
15
  */
13
16
 
14
17
  import type { Theme } from "@earendil-works/pi-coding-agent";
15
- import {
16
- Key,
17
- matchesKey,
18
- truncateToWidth,
19
- visibleWidth,
20
- type Component,
21
- type TuiMouseEvent,
22
- type TuiMouseEventResult,
23
- } from "@earendil-works/pi-tui";
24
- import {
25
- clampScroll,
26
- fitRows,
27
- formatRange,
28
- isCloseKey,
29
- keepVisible,
30
- navIntent,
31
- navigateScroll,
32
- selectionMarker,
33
- wheelDelta,
34
- } from "../../lib/list-cursor.ts";
35
- import { screenHeader, screenHint, type ViewportRowsSource } from "../../lib/tui.ts";
36
- import { expandHint } from "../../lib/ui.ts";
18
+ import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
19
+ import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
20
+ import { clip, fitCells } from "../../lib/tui/columns.ts";
21
+ import { fitRows, formatRange, IdCursor, isCloseKey, navIntent, wheelDelta } from "../../lib/tui/cursor.ts";
22
+ import { expandHint, SEPARATORS } from "../../lib/tui/glyphs.ts";
23
+ import { LinePane } from "../../lib/tui/line-pane.ts";
24
+ import { emptyState, statusRow, type StatusRow } from "../../lib/tui/rows.ts";
25
+ import { titleRule } from "../../lib/tui/rules.ts";
26
+ import { paint } from "../../lib/tui/tokens.ts";
37
27
  import {
38
28
  compareJobs,
39
29
  elapsedMs,
@@ -42,7 +32,8 @@ import {
42
32
  jobCounts,
43
33
  sanitizeLogLine,
44
34
  shortLabel,
45
- statusGlyph,
35
+ statusGlyphChar,
36
+ statusRole,
46
37
  type JobCounts,
47
38
  } from "./format.ts";
48
39
  import type { JobRecord } from "./types.ts";
@@ -50,18 +41,30 @@ import type { JobRecord } from "./types.ts";
50
41
  /** Rows the `/jobs` screen body shows when the terminal height is unknown. */
51
42
  const SCREEN_DEFAULT_ROWS = 14;
52
43
 
53
- /**
54
- * One job row: selection marker, status glyph, id, label, and a right-aligned
55
- * elapsed value. The label is padded so the elapsed column lines up; the final
56
- * `truncateToWidth` keeps the row safe at any width.
57
- */
58
- function jobRow(job: JobRecord, theme: Theme, width: number, selected = false): string {
59
- const head = `${selectionMarker(theme, selected)}${statusGlyph(job.status, theme)} ${job.id} `;
60
- const elapsed = formatDuration(elapsedMs(job, Date.now()));
61
- const available = Math.max(1, width - visibleWidth(head) - elapsed.length - 1);
62
- const clipped = truncateToWidth(shortLabel(job), available, "…");
63
- const pad = " ".repeat(Math.max(0, available - visibleWidth(clipped)));
64
- return truncateToWidth(`${head}${clipped}${pad} ${theme.fg("dim", elapsed)}`, width);
44
+ /** Fixed rows around the list body: header, summary, blank, blank, hint, blank. */
45
+ const LIST_CHROME_ROWS = 7;
46
+
47
+ /** Fixed rows around the log body: header, subtitle, blank, blank, hint, blank. */
48
+ const LOG_CHROME_ROWS = 6;
49
+
50
+ /** A job's stable identity, for the id-anchored cursor. */
51
+ const jobId = (job: JobRecord): string => job.id;
52
+
53
+ /** One job row: selection marker, status glyph, id, label, and a right-aligned
54
+ * elapsed value. `statusRow` owns the clipping and the alignment. */
55
+ function jobRow(
56
+ job: JobRecord,
57
+ theme: Theme,
58
+ width: number,
59
+ options: Pick<StatusRow, "selected" | "marker"> = {},
60
+ ): string {
61
+ return statusRow(theme, width, {
62
+ ...options,
63
+ glyph: statusGlyphChar(job.status),
64
+ glyphRole: statusRole(job.status),
65
+ label: `${job.id} ${shortLabel(job)}`,
66
+ trailing: formatDuration(elapsedMs(job, Date.now())),
67
+ });
65
68
  }
66
69
 
67
70
  /** Input for the transcript result: a themed job list or a log tail. */
@@ -96,24 +99,18 @@ export class JobResult implements Component {
96
99
  // A leading blank line separates the result from the call header.
97
100
  const lines: string[] = [""];
98
101
  if (this.input.kind === "list") {
99
- for (const job of this.input.jobs) lines.push(jobRow(job, this.theme, w));
102
+ for (const job of this.input.jobs) lines.push(jobRow(job, this.theme, w, { marker: false }));
100
103
  if (this.input.more > 0) {
101
- lines.push(
102
- truncateToWidth(` ${this.theme.fg("dim", `… ${this.input.more} more`)} ${expandHint(this.theme)}`, w, "…"),
103
- );
104
+ const note = `${paint(this.theme, "faint", `… ${this.input.more} more`)} ${expandHint(this.theme)}`;
105
+ lines.push(clip(` ${note}`, w));
104
106
  }
105
107
  } else {
106
108
  if (this.input.earlier > 0) {
107
- lines.push(
108
- truncateToWidth(
109
- `${this.theme.fg("dim", `… ${this.input.earlier} earlier lines`)} ${expandHint(this.theme)}`,
110
- w,
111
- "…",
112
- ),
113
- );
109
+ const note = `${paint(this.theme, "faint", `… ${this.input.earlier} earlier lines`)} ${expandHint(this.theme)}`;
110
+ lines.push(clip(note, w));
114
111
  }
115
112
  for (const line of this.input.lines) {
116
- lines.push(this.theme.fg("toolOutput", truncateToWidth(line, w, "…")));
113
+ lines.push(this.theme.fg("toolOutput", clip(line, w)));
117
114
  }
118
115
  }
119
116
  return lines;
@@ -135,24 +132,14 @@ type PendingConfirm =
135
132
  /** Dismissible `/jobs` screen: a selectable list with a focus pane and log view. */
136
133
  export class JobListComponent implements Component {
137
134
  private mode: "list" | "logs" = "list";
138
- /** Stable selection identity; survives the live re-sort of the job list. */
139
- private selectedId: string | undefined;
140
- private listScroll = 0;
141
- /** Job whose log pane is open; captured so a re-sort cannot switch it. */
142
- private logJobId: string | undefined;
143
- private logScroll = 0;
144
- private logLines: string[] = [];
145
- private logTitle = "";
146
- /** Absolute log-file path of the open job, and whether earlier lines exist. */
147
- private logPath: string | undefined;
148
- private logMore = false;
149
- private follow = true;
135
+ /** Focus anchored to a job id; the list is re-sorted from live state each render. */
136
+ private readonly cursor = new IdCursor();
137
+ /** The open log tail. */
138
+ private readonly pane = new LinePane();
150
139
  /** Pending destructive action awaiting y/N. */
151
140
  private confirm: PendingConfirm | undefined;
152
141
  /** Transient feedback shown in place of the hint, e.g. a no-op action. */
153
142
  private notice: string | undefined;
154
- /** Rows shown at once; recomputed from the terminal height on every render. */
155
- private visible = SCREEN_DEFAULT_ROWS;
156
143
 
157
144
  constructor(
158
145
  private readonly jobs: () => JobRecord[],
@@ -167,94 +154,47 @@ export class JobListComponent implements Component {
167
154
 
168
155
  /** Replace the log pane contents (called by the command's poll loop). */
169
156
  setLogs(title: string, lines: string[], meta: { more?: boolean; logPath?: string } = {}): void {
170
- const titleChanged = title !== this.logTitle;
171
- const linesChanged = !this.sameLines(lines);
172
- const moreChanged = meta.more !== undefined && meta.more !== this.logMore;
173
- const pathChanged = meta.logPath !== undefined && meta.logPath !== this.logPath;
174
- this.logTitle = title;
175
- this.logLines = lines;
176
- if (meta.more !== undefined) this.logMore = meta.more;
177
- if (meta.logPath !== undefined) this.logPath = meta.logPath;
178
- if (this.follow) this.logScroll = this.maxLogScroll;
179
- else this.logScroll = Math.min(this.logScroll, this.maxLogScroll);
180
- // The poll fires every 500ms; skip the repaint when nothing changed so an
181
- // idle log pane does not redraw (and fight the user's scroll) constantly.
182
- if (titleChanged || linesChanged || moreChanged || pathChanged) this.requestRender();
183
- }
184
-
185
- private sameLines(next: string[]): boolean {
186
- if (next.length !== this.logLines.length) return false;
187
- for (let i = 0; i < next.length; i++) {
188
- if (next[i] !== this.logLines[i]) return false;
189
- }
190
- return true;
157
+ // The poll fires every 500ms; `LinePane.update` skips the repaint when
158
+ // nothing changed so an idle log pane does not redraw (and fight the
159
+ // user's scroll) constantly.
160
+ if (this.pane.update({ title, lines, more: meta.more, subtitle: meta.logPath })) this.requestRender();
191
161
  }
192
162
 
193
163
  /** The job whose log pane is open, or undefined in list mode. */
194
164
  currentLogId(): string | undefined {
195
- return this.mode === "logs" ? this.logJobId : undefined;
165
+ return this.mode === "logs" && this.pane.id ? this.pane.id : undefined;
196
166
  }
197
167
 
198
168
  /** Re-read the open log pane from the runtime. */
199
169
  refreshLogs(id: string): void {
200
170
  const result = this.callbacks.logs(id);
201
- if (result) this.setLogs(this.logTitle, result.lines, { more: result.more });
171
+ if (result && this.pane.update({ lines: result.lines, more: result.more })) this.requestRender();
202
172
  }
203
173
 
204
174
  private get ordered(): JobRecord[] {
205
175
  return [...this.jobs()].sort(compareJobs);
206
176
  }
207
177
 
208
- /** Index of the selected job in `ordered`, re-derived from its id each time. */
209
- private indexOfSelected(ordered: JobRecord[]): number {
210
- if (ordered.length === 0) return 0;
211
- const index = this.selectedId ? ordered.findIndex((job) => job.id === this.selectedId) : -1;
212
- return index >= 0 ? index : 0;
213
- }
214
-
215
- /** Scroll the list so `index` is visible. */
216
- private ensureVisible(index: number): void {
217
- this.listScroll = Math.max(0, keepVisible(this.listScroll, index, this.visible));
218
- }
219
-
220
- private get maxLogScroll(): number {
221
- return Math.max(0, this.logLines.length - this.visible);
222
- }
223
-
224
- private setSelection(next: number): void {
225
- const ordered = this.ordered;
226
- if (ordered.length === 0) return;
227
- const index = Math.min(Math.max(0, next), ordered.length - 1);
228
- this.selectedId = ordered[index].id;
229
- this.ensureVisible(index);
178
+ /** Focus job `next` (clamped) and keep it visible. */
179
+ private select(next: number): void {
180
+ const jobs = this.ordered;
181
+ if (jobs.length === 0) return;
182
+ this.cursor.select(jobs, jobId, next);
230
183
  this.requestRender();
231
184
  }
232
185
 
233
- private clampSelection(): void {
234
- const ordered = this.ordered;
235
- if (ordered.length === 0) {
236
- this.selectedId = undefined;
237
- this.listScroll = 0;
238
- return;
239
- }
240
- this.selectedId = ordered[this.indexOfSelected(ordered)].id;
241
- this.listScroll = clampScroll(this.listScroll, ordered.length, this.visible);
242
- }
243
-
244
186
  private openLogs(): void {
245
- const job = this.ordered[this.indexOfSelected(this.ordered)];
187
+ const job = this.ordered[this.cursor.indexOf(this.ordered, jobId)];
246
188
  if (!job) return;
247
189
  this.mode = "logs";
248
- this.logJobId = job.id;
249
- this.logScroll = 0;
250
- this.follow = true;
251
- this.logLines = [];
252
- this.logTitle = `${job.id} ${shortLabel(job)}`;
253
- this.logPath = job.logPath;
254
- this.logMore = false;
255
190
  const result = this.callbacks.logs(job.id);
256
- if (result) this.setLogs(this.logTitle, result.lines, { more: result.more, logPath: job.logPath });
257
- else this.requestRender();
191
+ this.pane.open(job.id, {
192
+ title: `${job.id} ${shortLabel(job)}`,
193
+ lines: result?.lines ?? [],
194
+ more: result?.more ?? false,
195
+ subtitle: job.logPath ? sanitizeLogLine(job.logPath) : undefined,
196
+ });
197
+ this.requestRender();
258
198
  }
259
199
 
260
200
  handleInput(data: string): void {
@@ -272,9 +212,10 @@ export class JobListComponent implements Component {
272
212
  if (delta === undefined) return undefined;
273
213
  if (this.confirm) return { handled: true };
274
214
  if (this.mode === "list") {
275
- this.setSelection(this.indexOfSelected(this.ordered) + delta);
215
+ this.select(this.cursor.indexOf(this.ordered, jobId) + delta);
276
216
  } else {
277
- this.setLogScroll(this.logScroll + delta);
217
+ this.pane.setScroll(this.pane.scroll + delta);
218
+ this.requestRender();
278
219
  }
279
220
  return { handled: true };
280
221
  }
@@ -290,25 +231,25 @@ export class JobListComponent implements Component {
290
231
  this.requestRender();
291
232
  }
292
233
  const ordered = this.ordered;
293
- const index = this.indexOfSelected(ordered);
234
+ const index = this.cursor.indexOf(ordered, jobId);
294
235
  switch (navIntent(data)) {
295
236
  case "up":
296
- this.setSelection(index - 1);
237
+ this.select(index - 1);
297
238
  return;
298
239
  case "down":
299
- this.setSelection(index + 1);
240
+ this.select(index + 1);
300
241
  return;
301
242
  case "pageUp":
302
- this.setSelection(index - this.visible);
243
+ this.select(index - this.cursor.visible);
303
244
  return;
304
245
  case "pageDown":
305
- this.setSelection(index + this.visible);
246
+ this.select(index + this.cursor.visible);
306
247
  return;
307
248
  case "home":
308
- this.setSelection(0);
249
+ this.select(0);
309
250
  return;
310
251
  case "end":
311
- this.setSelection(ordered.length - 1);
252
+ this.select(ordered.length - 1);
312
253
  return;
313
254
  }
314
255
  if (matchesKey(data, Key.enter) || data === "l") this.openLogs();
@@ -346,7 +287,7 @@ export class JobListComponent implements Component {
346
287
  if (pending.kind === "kill") this.callbacks.kill(pending.id);
347
288
  else {
348
289
  this.callbacks.clear();
349
- this.clampSelection();
290
+ this.cursor.clamp(this.ordered, jobId);
350
291
  }
351
292
  this.requestRender();
352
293
  return;
@@ -369,35 +310,22 @@ export class JobListComponent implements Component {
369
310
  return `Clear ${pending.count} finished job${pending.count === 1 ? "" : "s"}?${note} y/N`;
370
311
  }
371
312
 
372
- private setLogScroll(next: number): void {
373
- this.logScroll = clampScroll(next, this.logLines.length, this.visible);
374
- // Follow the tail only while the view is parked at the bottom.
375
- this.follow = this.logScroll >= this.maxLogScroll;
376
- this.requestRender();
377
- }
378
-
379
313
  private handleLogInput(data: string): void {
380
314
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.backspace) || data === "q") {
381
315
  this.mode = "list";
382
- this.logJobId = undefined;
316
+ this.pane.close();
383
317
  this.requestRender();
384
318
  return;
385
319
  }
386
- const next = navigateScroll(data, this.logScroll, this.logLines.length, this.visible);
387
- if (next !== undefined) {
388
- this.setLogScroll(next);
389
- return;
390
- }
391
- if (data === "g") this.setLogScroll(0);
392
- else if (data === "G") this.setLogScroll(this.maxLogScroll);
320
+ if (this.pane.handleKey(data)) this.requestRender();
393
321
  }
394
322
 
395
323
  private summary(counts: JobCounts, width: number): string {
396
- const sep = this.theme.fg("muted", " · ");
397
- let text = this.theme.fg("muted", `${counts.total} job${counts.total === 1 ? "" : "s"}`);
398
- if (counts.running > 0) text += sep + this.theme.fg("muted", `${counts.running} running`);
399
- if (counts.failed > 0) text += sep + this.theme.fg("error", `${counts.failed} failed`);
400
- return truncateToWidth(` ${text}`, width);
324
+ const cells = [paint(this.theme, "meta", `${counts.total} job${counts.total === 1 ? "" : "s"}`)];
325
+ if (counts.running > 0) cells.push(paint(this.theme, "meta", `${counts.running} running`));
326
+ if (counts.failed > 0) cells.push(paint(this.theme, "bad", `${counts.failed} failed`));
327
+ const separator = paint(this.theme, "meta", SEPARATORS.item);
328
+ return clip(` ${fitCells(cells, Math.max(1, width - 2), separator)}`, width);
401
329
  }
402
330
 
403
331
  render(width: number): string[] {
@@ -407,80 +335,78 @@ export class JobListComponent implements Component {
407
335
 
408
336
  private renderList(w: number): string[] {
409
337
  const jobs = this.ordered;
410
- this.clampSelection();
411
- const index = this.indexOfSelected(jobs);
338
+ this.cursor.clamp(jobs, jobId);
339
+ const index = this.cursor.indexOf(jobs, jobId);
412
340
  const focused = jobs[index];
413
341
  const detail = focused ? formatJobDetail(focused, Date.now()) : [];
414
- // header, summary, blank, blank-after-list, blank-after-detail, hint, blank
415
- const chrome = 7 + detail.length;
416
- this.visible = fitRows(this.viewportRowsSource, jobs.length, chrome, SCREEN_DEFAULT_ROWS);
417
- this.clampSelection();
418
- this.ensureVisible(this.indexOfSelected(jobs));
342
+ this.cursor.visible = fitRows(
343
+ this.viewportRowsSource,
344
+ jobs.length,
345
+ LIST_CHROME_ROWS + detail.length,
346
+ SCREEN_DEFAULT_ROWS,
347
+ );
348
+ this.cursor.clamp(jobs, jobId);
349
+ this.cursor.ensureVisible(this.cursor.indexOf(jobs, jobId));
419
350
 
420
- const lines: string[] = [screenHeader(this.theme, w, "Jobs"), this.summary(jobCounts(jobs), w), ""];
351
+ const body: string[] = [];
352
+ let range: string | undefined;
421
353
  if (jobs.length === 0) {
422
- lines.push(truncateToWidth(` ${this.theme.fg("dim", "No background jobs.")}`, w));
354
+ body.push(...emptyState(this.theme, w, { label: "No background jobs." }));
423
355
  } else {
424
- const end = Math.min(jobs.length, this.listScroll + this.visible);
425
- for (let i = this.listScroll; i < end; i++) {
426
- lines.push(jobRow(jobs[i], this.theme, w, i === index));
427
- }
428
- if (this.listScroll > 0 || end < jobs.length) {
429
- lines.push(formatRange(this.theme, w, { start: this.listScroll, end, total: jobs.length }));
356
+ const end = Math.min(jobs.length, this.cursor.scroll + this.cursor.visible);
357
+ for (let i = this.cursor.scroll; i < end; i++)
358
+ body.push(jobRow(jobs[i], this.theme, w, { selected: i === index }));
359
+ if (this.cursor.scroll > 0 || end < jobs.length) {
360
+ range = formatRange(this.theme, w, { start: this.cursor.scroll, end, total: jobs.length });
430
361
  }
431
362
  }
432
- lines.push("");
433
- for (const line of detail) lines.push(truncateToWidth(` ${this.theme.fg("muted", line)}`, w));
434
- lines.push("");
435
- lines.push(
436
- this.confirm
437
- ? truncateToWidth(` ${this.theme.fg("warning", this.confirmPrompt())}`, w)
438
- : this.notice
439
- ? truncateToWidth(` ${this.theme.fg("muted", this.notice)}`, w)
440
- : screenHint(this.theme, w, [
441
- "Esc close",
442
- "↑/↓ or j/k select",
443
- "Enter or l logs",
444
- "d or K kill",
445
- "x clear finished",
446
- "PgUp/PgDn",
447
- ]),
448
- );
449
- lines.push("");
450
- return lines;
363
+
364
+ const hint = this.confirm
365
+ ? clip(` ${paint(this.theme, "warn", this.confirmPrompt())}`, w)
366
+ : this.notice
367
+ ? clip(` ${paint(this.theme, "meta", this.notice)}`, w)
368
+ : screenHint(this.theme, w, [
369
+ "Esc close",
370
+ "↑/↓ or j/k select",
371
+ "Enter or l logs",
372
+ "d or K kill",
373
+ "x clear finished",
374
+ "PgUp/PgDn",
375
+ ]);
376
+
377
+ return dockListFrame({
378
+ header: titleRule(this.theme, w, "Jobs"),
379
+ summary: this.summary(jobCounts(jobs), w),
380
+ body,
381
+ range,
382
+ // The detail pane is separated from the list by a blank line.
383
+ detail: ["", ...detail.map((line) => clip(` ${paint(this.theme, "meta", line)}`, w))],
384
+ hint,
385
+ });
451
386
  }
452
387
 
453
388
  private renderLogs(w: number): string[] {
454
- // header, path, blank, blank-after-log, hint, blank
455
- const chrome = 6;
456
- this.visible = fitRows(this.viewportRowsSource, this.logLines.length, chrome, SCREEN_DEFAULT_ROWS);
457
- this.logScroll = clampScroll(this.logScroll, this.logLines.length, this.visible);
458
- if (this.follow) this.logScroll = this.maxLogScroll;
459
-
460
- const label = this.logTitle ? `Job Logs · ${this.logTitle}` : "Job Logs";
461
- const parts: string[] = [];
462
- if (this.logPath) parts.push(sanitizeLogLine(this.logPath));
463
- if (this.logMore && this.logLines.length > 0) parts.push(`showing last ${this.logLines.length} lines`);
464
- // Say whether the pane follows the tail or the user has scrolled away from it.
465
- if (this.logLines.length > 0) parts.push(this.follow ? "following" : "paused");
466
- const pathLine = truncateToWidth(` ${this.theme.fg("dim", parts.join(" · "))}`, w);
467
- const lines: string[] = [screenHeader(this.theme, w, label), pathLine, ""];
468
- if (this.logLines.length === 0) {
469
- lines.push(truncateToWidth(` ${this.theme.fg("dim", "No output yet.")}`, w));
389
+ const visible = fitRows(this.viewportRowsSource, this.pane.count, LOG_CHROME_ROWS, SCREEN_DEFAULT_ROWS);
390
+ this.pane.fit(visible);
391
+
392
+ const body: string[] = [];
393
+ let range: string | undefined;
394
+ if (this.pane.count === 0) {
395
+ body.push(...emptyState(this.theme, w, { label: "No output yet." }));
470
396
  } else {
471
- const end = Math.min(this.logLines.length, this.logScroll + this.visible);
472
- for (let i = this.logScroll; i < end; i++) {
473
- lines.push(truncateToWidth(` ${this.logLines[i]}`, w));
474
- }
475
- if (this.logScroll > 0 || end < this.logLines.length) {
476
- lines.push(
477
- formatRange(this.theme, w, { start: this.logScroll, end, total: this.logLines.length, label: "line" }),
478
- );
397
+ const { start, end } = this.pane.window();
398
+ for (let i = start; i < end; i++) body.push(clip(` ${this.pane.lines[i]}`, w));
399
+ if (start > 0 || end < this.pane.count) {
400
+ range = formatRange(this.theme, w, { start, end, total: this.pane.count, label: "line" });
479
401
  }
480
402
  }
481
- lines.push("");
482
- lines.push(screenHint(this.theme, w, ["Esc back", "↑/↓ scroll", "g/G ends", "PgUp/PgDn"]));
483
- lines.push("");
484
- return lines;
403
+
404
+ return dockListFrame({
405
+ header: titleRule(this.theme, w, this.pane.title("Job Logs")),
406
+ summary: clip(` ${paint(this.theme, "faint", this.pane.subtitleLine())}`, w),
407
+ body,
408
+ range,
409
+ hint: screenHint(this.theme, w, ["Esc back", "↑/↓ scroll", "g/G ends", "PgUp/PgDn"]),
410
+ });
485
411
  }
486
412
  }
@@ -2,27 +2,19 @@
2
2
  * Presentation for the jobs extension: the footer status chips.
3
3
  *
4
4
  * Kept separate from the runtime so the runtime owns lifecycle (the job table,
5
- * registry, clock) and this owns "how jobs are shown". It holds only the last
6
- * context it was attached to; the runtime re-attaches on load and start, and
7
- * every method no-ops if nothing is attached or the UI is unavailable in the
8
- * current mode.
5
+ * registry, clock) and this owns "how jobs are shown". The chips themselves come
6
+ * from the shared `lib/tui/status-controller.ts`, so only the vocabulary — the
7
+ * keys, glyphs, and counts — is job-specific.
9
8
  */
10
9
 
11
10
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
- import { GLYPHS, STATUS_KEYS } from "../../lib/ui.ts";
11
+ import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
12
+ import { createStatusController, type StatusChip, type StatusController } from "../../lib/tui/status-controller.ts";
13
13
  import type { JobsConfig } from "./config.ts";
14
14
  import { pendingFailures, shortLabel } from "./format.ts";
15
15
  import type { JobRecord } from "./types.ts";
16
16
 
17
- export interface UiController {
18
- /** Remember the live context; later calls use it when no context is passed. */
19
- attach(ctx: ExtensionContext): void;
20
- /** Forget the context, for example on shutdown or reload. */
21
- detach(): void;
22
- /** Repaint the chips using the attached context. */
23
- paint(): void;
24
- /** Publish the footer chips (and attach `ctx` when given). */
25
- setStatus(ctx?: ExtensionContext): void;
17
+ export interface UiController extends StatusController {
26
18
  /** Notify the user that a job failed, when a UI is attached. */
27
19
  notifyFailure(job: JobRecord): void;
28
20
  }
@@ -34,43 +26,21 @@ export interface UiOptions {
34
26
 
35
27
  export function createUiController(options: UiOptions): UiController {
36
28
  const { getJobs, getConfig } = options;
29
+ // The notification also needs the context, so it is mirrored here.
37
30
  let ctx: ExtensionContext | undefined;
38
31
 
39
- const theme = (target: ExtensionContext, color: "accent" | "error", text: string): string => {
40
- try {
41
- return target.ui.theme.fg(color, text);
42
- } catch {
43
- return text;
44
- }
45
- };
46
-
47
- const setStatus = (next?: ExtensionContext): void => {
48
- if (next) ctx = next;
49
- const target = ctx;
50
- if (!target) return;
51
- try {
52
- if (!getConfig().showStatus) {
53
- target.ui.setStatus(STATUS_KEYS.jobs, undefined);
54
- target.ui.setStatus(STATUS_KEYS.jobsFailure, undefined);
55
- return;
56
- }
32
+ const controller = createStatusController({
33
+ keys: [STATUS_KEYS.jobs, STATUS_KEYS.jobsFailure],
34
+ enabled: () => getConfig().showStatus,
35
+ chips: (): StatusChip[] => {
57
36
  const jobs = [...getJobs()];
58
37
  const running = jobs.filter((job) => job.status === "running").length;
59
- const unseenFailures = pendingFailures(jobs).length;
60
- // A space before the count keeps the compact chrome form (`▸2`),
61
- // while the two chips stay separate segments in the full form.
62
- target.ui.setStatus(
63
- STATUS_KEYS.jobs,
64
- running > 0 ? theme(target, "accent", `${GLYPHS.jobsRunning} ${running}`) : undefined,
65
- );
66
- target.ui.setStatus(
67
- STATUS_KEYS.jobsFailure,
68
- unseenFailures > 0 ? theme(target, "error", `${GLYPHS.jobsFailure} ${unseenFailures}`) : undefined,
69
- );
70
- } catch {
71
- // UI may be unavailable in non-interactive modes.
72
- }
73
- };
38
+ return [
39
+ { key: STATUS_KEYS.jobs, glyph: GLYPHS.jobsRunning, count: running, role: "accent" },
40
+ { key: STATUS_KEYS.jobsFailure, glyph: GLYPHS.jobsFailure, count: pendingFailures(jobs).length, role: "bad" },
41
+ ];
42
+ },
43
+ });
74
44
 
75
45
  const notifyFailure = (job: JobRecord): void => {
76
46
  const target = ctx;
@@ -86,14 +56,19 @@ export function createUiController(options: UiOptions): UiController {
86
56
  return {
87
57
  attach: (next) => {
88
58
  ctx = next;
59
+ controller.attach(next);
89
60
  },
90
61
  detach: () => {
91
62
  ctx = undefined;
63
+ controller.detach();
92
64
  },
93
65
  paint: () => {
94
- setStatus();
66
+ controller.paint();
67
+ },
68
+ setStatus: (next) => {
69
+ if (next) ctx = next;
70
+ controller.setStatus(next);
95
71
  },
96
- setStatus,
97
72
  notifyFailure,
98
73
  };
99
74
  }
@@ -13,6 +13,12 @@ pi -e . # load the whole @gavin-hu/my-pi-agent pac
13
13
  pi install ./ # install the package
14
14
  ```
15
15
 
16
+ ## Tool rendering
17
+
18
+ How the `memory` call and its result look in the transcript — the call line as a headline and the body indented under it:
19
+
20
+ <img src="../../assets/tools/memory.png" alt="The memory tool's result: the call line and an indented body confirming the write and the new project and global counts" width="100%">
21
+
16
22
  ## What it does
17
23
 
18
24
  - Registers one model-callable tool, `memory`, with four actions: `add` a note,
@@ -205,7 +211,7 @@ when consolidating.
205
211
  | `runtime.ts` | Session view of both stores, stamp-based refresh, trust gating, mutations. |
206
212
  | `format.ts` | Pure call/result/notice text and the bounded injection body. |
207
213
  | `errors.ts` | `messageOf`, the shared error-text extraction for the tool and command. |
208
- | `tui.ts` | The `/memory` picker component (note list, inline editor, scope filter). |
214
+ | `tui.ts` | The `/memory` picker component (note list, inline editor, scope filter), drawn from the `lib/tui/` kit (`titleRule`, `dockListFrame`, `listRow`, `emptyState`). |
209
215
  | `tools.ts` | `registerTool("memory", …)`. |
210
216
  | `commands.ts` | `registerCommand("memory", …)`. |
211
217