@gavin-hu/my-pi-agent 0.8.0 → 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 +9 -8
  2. package/README.zh-CN.md +8 -8
  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 +62 -14
  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 +412 -23
  30. package/extensions/file-browse/index.ts +15 -4
  31. package/extensions/file-browse/markdown.ts +98 -5
  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 +2 -2
  98. /package/lib/{ui.ts → tui/glyphs.ts} +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, listRow } 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,26 @@ 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
- */
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. `listRow` owns the clipping and the alignment. */
58
55
  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);
56
+ return listRow(theme, width, {
57
+ selected,
58
+ glyph: statusGlyphChar(job.status),
59
+ glyphRole: statusRole(job.status),
60
+ label: `${job.id} ${shortLabel(job)}`,
61
+ trailing: formatDuration(elapsedMs(job, Date.now())),
62
+ trailingRole: "faint",
63
+ });
65
64
  }
66
65
 
67
66
  /** Input for the transcript result: a themed job list or a log tail. */
@@ -98,22 +97,16 @@ export class JobResult implements Component {
98
97
  if (this.input.kind === "list") {
99
98
  for (const job of this.input.jobs) lines.push(jobRow(job, this.theme, w));
100
99
  if (this.input.more > 0) {
101
- lines.push(
102
- truncateToWidth(` ${this.theme.fg("dim", `… ${this.input.more} more`)} ${expandHint(this.theme)}`, w, "…"),
103
- );
100
+ const note = `${paint(this.theme, "faint", `… ${this.input.more} more`)} ${expandHint(this.theme)}`;
101
+ lines.push(clip(` ${note}`, w));
104
102
  }
105
103
  } else {
106
104
  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
- );
105
+ const note = `${paint(this.theme, "faint", `… ${this.input.earlier} earlier lines`)} ${expandHint(this.theme)}`;
106
+ lines.push(clip(note, w));
114
107
  }
115
108
  for (const line of this.input.lines) {
116
- lines.push(this.theme.fg("toolOutput", truncateToWidth(line, w, "…")));
109
+ lines.push(this.theme.fg("toolOutput", clip(line, w)));
117
110
  }
118
111
  }
119
112
  return lines;
@@ -135,24 +128,14 @@ type PendingConfirm =
135
128
  /** Dismissible `/jobs` screen: a selectable list with a focus pane and log view. */
136
129
  export class JobListComponent implements Component {
137
130
  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;
131
+ /** Focus anchored to a job id; the list is re-sorted from live state each render. */
132
+ private readonly cursor = new IdCursor();
133
+ /** The open log tail. */
134
+ private readonly pane = new LinePane();
150
135
  /** Pending destructive action awaiting y/N. */
151
136
  private confirm: PendingConfirm | undefined;
152
137
  /** Transient feedback shown in place of the hint, e.g. a no-op action. */
153
138
  private notice: string | undefined;
154
- /** Rows shown at once; recomputed from the terminal height on every render. */
155
- private visible = SCREEN_DEFAULT_ROWS;
156
139
 
157
140
  constructor(
158
141
  private readonly jobs: () => JobRecord[],
@@ -167,94 +150,47 @@ export class JobListComponent implements Component {
167
150
 
168
151
  /** Replace the log pane contents (called by the command's poll loop). */
169
152
  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;
153
+ // The poll fires every 500ms; `LinePane.update` skips the repaint when
154
+ // nothing changed so an idle log pane does not redraw (and fight the
155
+ // user's scroll) constantly.
156
+ if (this.pane.update({ title, lines, more: meta.more, subtitle: meta.logPath })) this.requestRender();
191
157
  }
192
158
 
193
159
  /** The job whose log pane is open, or undefined in list mode. */
194
160
  currentLogId(): string | undefined {
195
- return this.mode === "logs" ? this.logJobId : undefined;
161
+ return this.mode === "logs" && this.pane.id ? this.pane.id : undefined;
196
162
  }
197
163
 
198
164
  /** Re-read the open log pane from the runtime. */
199
165
  refreshLogs(id: string): void {
200
166
  const result = this.callbacks.logs(id);
201
- if (result) this.setLogs(this.logTitle, result.lines, { more: result.more });
167
+ if (result && this.pane.update({ lines: result.lines, more: result.more })) this.requestRender();
202
168
  }
203
169
 
204
170
  private get ordered(): JobRecord[] {
205
171
  return [...this.jobs()].sort(compareJobs);
206
172
  }
207
173
 
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);
174
+ /** Focus job `next` (clamped) and keep it visible. */
175
+ private select(next: number): void {
176
+ const jobs = this.ordered;
177
+ if (jobs.length === 0) return;
178
+ this.cursor.select(jobs, jobId, next);
230
179
  this.requestRender();
231
180
  }
232
181
 
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
182
  private openLogs(): void {
245
- const job = this.ordered[this.indexOfSelected(this.ordered)];
183
+ const job = this.ordered[this.cursor.indexOf(this.ordered, jobId)];
246
184
  if (!job) return;
247
185
  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
186
  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();
187
+ this.pane.open(job.id, {
188
+ title: `${job.id} ${shortLabel(job)}`,
189
+ lines: result?.lines ?? [],
190
+ more: result?.more ?? false,
191
+ subtitle: job.logPath ? sanitizeLogLine(job.logPath) : undefined,
192
+ });
193
+ this.requestRender();
258
194
  }
259
195
 
260
196
  handleInput(data: string): void {
@@ -272,9 +208,10 @@ export class JobListComponent implements Component {
272
208
  if (delta === undefined) return undefined;
273
209
  if (this.confirm) return { handled: true };
274
210
  if (this.mode === "list") {
275
- this.setSelection(this.indexOfSelected(this.ordered) + delta);
211
+ this.select(this.cursor.indexOf(this.ordered, jobId) + delta);
276
212
  } else {
277
- this.setLogScroll(this.logScroll + delta);
213
+ this.pane.setScroll(this.pane.scroll + delta);
214
+ this.requestRender();
278
215
  }
279
216
  return { handled: true };
280
217
  }
@@ -290,25 +227,25 @@ export class JobListComponent implements Component {
290
227
  this.requestRender();
291
228
  }
292
229
  const ordered = this.ordered;
293
- const index = this.indexOfSelected(ordered);
230
+ const index = this.cursor.indexOf(ordered, jobId);
294
231
  switch (navIntent(data)) {
295
232
  case "up":
296
- this.setSelection(index - 1);
233
+ this.select(index - 1);
297
234
  return;
298
235
  case "down":
299
- this.setSelection(index + 1);
236
+ this.select(index + 1);
300
237
  return;
301
238
  case "pageUp":
302
- this.setSelection(index - this.visible);
239
+ this.select(index - this.cursor.visible);
303
240
  return;
304
241
  case "pageDown":
305
- this.setSelection(index + this.visible);
242
+ this.select(index + this.cursor.visible);
306
243
  return;
307
244
  case "home":
308
- this.setSelection(0);
245
+ this.select(0);
309
246
  return;
310
247
  case "end":
311
- this.setSelection(ordered.length - 1);
248
+ this.select(ordered.length - 1);
312
249
  return;
313
250
  }
314
251
  if (matchesKey(data, Key.enter) || data === "l") this.openLogs();
@@ -346,7 +283,7 @@ export class JobListComponent implements Component {
346
283
  if (pending.kind === "kill") this.callbacks.kill(pending.id);
347
284
  else {
348
285
  this.callbacks.clear();
349
- this.clampSelection();
286
+ this.cursor.clamp(this.ordered, jobId);
350
287
  }
351
288
  this.requestRender();
352
289
  return;
@@ -369,35 +306,22 @@ export class JobListComponent implements Component {
369
306
  return `Clear ${pending.count} finished job${pending.count === 1 ? "" : "s"}?${note} y/N`;
370
307
  }
371
308
 
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
309
  private handleLogInput(data: string): void {
380
310
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.backspace) || data === "q") {
381
311
  this.mode = "list";
382
- this.logJobId = undefined;
312
+ this.pane.close();
383
313
  this.requestRender();
384
314
  return;
385
315
  }
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);
316
+ if (this.pane.handleKey(data)) this.requestRender();
393
317
  }
394
318
 
395
319
  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);
320
+ const cells = [paint(this.theme, "meta", `${counts.total} job${counts.total === 1 ? "" : "s"}`)];
321
+ if (counts.running > 0) cells.push(paint(this.theme, "meta", `${counts.running} running`));
322
+ if (counts.failed > 0) cells.push(paint(this.theme, "bad", `${counts.failed} failed`));
323
+ const separator = paint(this.theme, "meta", SEPARATORS.item);
324
+ return clip(` ${fitCells(cells, Math.max(1, width - 2), separator)}`, width);
401
325
  }
402
326
 
403
327
  render(width: number): string[] {
@@ -407,80 +331,77 @@ export class JobListComponent implements Component {
407
331
 
408
332
  private renderList(w: number): string[] {
409
333
  const jobs = this.ordered;
410
- this.clampSelection();
411
- const index = this.indexOfSelected(jobs);
334
+ this.cursor.clamp(jobs, jobId);
335
+ const index = this.cursor.indexOf(jobs, jobId);
412
336
  const focused = jobs[index];
413
337
  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));
338
+ this.cursor.visible = fitRows(
339
+ this.viewportRowsSource,
340
+ jobs.length,
341
+ LIST_CHROME_ROWS + detail.length,
342
+ SCREEN_DEFAULT_ROWS,
343
+ );
344
+ this.cursor.clamp(jobs, jobId);
345
+ this.cursor.ensureVisible(this.cursor.indexOf(jobs, jobId));
419
346
 
420
- const lines: string[] = [screenHeader(this.theme, w, "Jobs"), this.summary(jobCounts(jobs), w), ""];
347
+ const body: string[] = [];
348
+ let range: string | undefined;
421
349
  if (jobs.length === 0) {
422
- lines.push(truncateToWidth(` ${this.theme.fg("dim", "No background jobs.")}`, w));
350
+ body.push(...emptyState(this.theme, w, { label: "No background jobs." }));
423
351
  } 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 }));
352
+ const end = Math.min(jobs.length, this.cursor.scroll + this.cursor.visible);
353
+ for (let i = this.cursor.scroll; i < end; i++) body.push(jobRow(jobs[i], this.theme, w, i === index));
354
+ if (this.cursor.scroll > 0 || end < jobs.length) {
355
+ range = formatRange(this.theme, w, { start: this.cursor.scroll, end, total: jobs.length });
430
356
  }
431
357
  }
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;
358
+
359
+ const hint = this.confirm
360
+ ? clip(` ${paint(this.theme, "warn", this.confirmPrompt())}`, w)
361
+ : this.notice
362
+ ? clip(` ${paint(this.theme, "meta", this.notice)}`, w)
363
+ : screenHint(this.theme, w, [
364
+ "Esc close",
365
+ "↑/↓ or j/k select",
366
+ "Enter or l logs",
367
+ "d or K kill",
368
+ "x clear finished",
369
+ "PgUp/PgDn",
370
+ ]);
371
+
372
+ return dockListFrame({
373
+ header: titleRule(this.theme, w, "Jobs"),
374
+ summary: this.summary(jobCounts(jobs), w),
375
+ body,
376
+ range,
377
+ // The detail pane is separated from the list by a blank line.
378
+ detail: ["", ...detail.map((line) => clip(` ${paint(this.theme, "meta", line)}`, w))],
379
+ hint,
380
+ });
451
381
  }
452
382
 
453
383
  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));
384
+ const visible = fitRows(this.viewportRowsSource, this.pane.count, LOG_CHROME_ROWS, SCREEN_DEFAULT_ROWS);
385
+ this.pane.fit(visible);
386
+
387
+ const body: string[] = [];
388
+ let range: string | undefined;
389
+ if (this.pane.count === 0) {
390
+ body.push(...emptyState(this.theme, w, { label: "No output yet." }));
470
391
  } 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
- );
392
+ const { start, end } = this.pane.window();
393
+ for (let i = start; i < end; i++) body.push(clip(` ${this.pane.lines[i]}`, w));
394
+ if (start > 0 || end < this.pane.count) {
395
+ range = formatRange(this.theme, w, { start, end, total: this.pane.count, label: "line" });
479
396
  }
480
397
  }
481
- lines.push("");
482
- lines.push(screenHint(this.theme, w, ["Esc back", "↑/↓ scroll", "g/G ends", "PgUp/PgDn"]));
483
- lines.push("");
484
- return lines;
398
+
399
+ return dockListFrame({
400
+ header: titleRule(this.theme, w, this.pane.title("Job Logs")),
401
+ summary: clip(` ${paint(this.theme, "faint", this.pane.subtitleLine())}`, w),
402
+ body,
403
+ range,
404
+ hint: screenHint(this.theme, w, ["Esc back", "↑/↓ scroll", "g/G ends", "PgUp/PgDn"]),
405
+ });
485
406
  }
486
407
  }
@@ -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
  }
@@ -205,7 +205,7 @@ when consolidating.
205
205
  | `runtime.ts` | Session view of both stores, stamp-based refresh, trust gating, mutations. |
206
206
  | `format.ts` | Pure call/result/notice text and the bounded injection body. |
207
207
  | `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). |
208
+ | `tui.ts` | The `/memory` picker component (note list, inline editor, scope filter), drawn from the `lib/tui/` kit (`titleRule`, `dockListFrame`, `listRow`, `emptyState`). |
209
209
  | `tools.ts` | `registerTool("memory", …)`. |
210
210
  | `commands.ts` | `registerCommand("memory", …)`. |
211
211
 
@@ -11,7 +11,8 @@
11
11
  import type { JsonValue } from "@earendil-works/pi-ai";
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { sanitizeLine } from "../../lib/format.ts";
14
- import { reuseText, toolDetails } from "../../lib/tui.ts";
14
+ import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
15
+ import { paint } from "../../lib/tui/tokens.ts";
15
16
  import { messageOf } from "./errors.ts";
16
17
  import { formatCallText, formatResultText, scopeLabel } from "./format.ts";
17
18
  import type { MemoryRuntime } from "./runtime.ts";
@@ -142,7 +143,7 @@ export function registerTools(pi: ExtensionAPI, runtime: MemoryRuntime): void {
142
143
  args as { action?: unknown; text?: unknown; to?: unknown; scope?: unknown },
143
144
  context.argsComplete,
144
145
  );
145
- text.setText(theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + theme.fg("muted", body));
146
+ text.setText(theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "meta", body));
146
147
  return text;
147
148
  },
148
149
 
@@ -155,7 +156,7 @@ export function registerTools(pi: ExtensionAPI, runtime: MemoryRuntime): void {
155
156
  return text;
156
157
  }
157
158
  if (details.error) {
158
- text.setText(theme.fg("error", `Error: ${sanitizeLine(details.error)}`));
159
+ text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
159
160
  return text;
160
161
  }
161
162
  text.setText(formatResultText(details, expanded));