@gavin-hu/my-pi-agent 0.14.0 → 0.15.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 (222) hide show
  1. package/README.md +55 -33
  2. package/README.zh-CN.md +44 -27
  3. package/extensions/bytefly/README.md +193 -140
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/register.ts +24 -5
  7. package/extensions/bytefly/doctor/tools.ts +4 -0
  8. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  9. package/extensions/bytefly/footer/config.ts +10 -6
  10. package/extensions/bytefly/footer/price/price.ts +5 -3
  11. package/extensions/bytefly/footer/price/rates.ts +63 -26
  12. package/extensions/bytefly/footer/render/status.ts +3 -3
  13. package/extensions/bytefly/header/component.ts +3 -2
  14. package/extensions/bytefly/index.ts +3 -3
  15. package/extensions/bytefly/notice/component.ts +8 -3
  16. package/extensions/bytefly/notify/config.ts +6 -11
  17. package/extensions/bytefly/notify/dispatch.ts +13 -5
  18. package/extensions/bytefly/panel/commands.ts +21 -1
  19. package/extensions/bytefly/panel/format.ts +5 -2
  20. package/extensions/bytefly/panel/settings-runtime.ts +26 -6
  21. package/extensions/bytefly/panel/settings.ts +41 -4
  22. package/extensions/bytefly/panel/themes.ts +32 -0
  23. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  24. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  25. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  26. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  27. package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
  28. package/extensions/bytefly/panel/tui.ts +94 -152
  29. package/extensions/bytefly/picker/discovery.ts +1 -0
  30. package/extensions/bytefly/picker/types.ts +2 -0
  31. package/extensions/bytefly/separator/component.ts +14 -4
  32. package/extensions/bytefly/update/cache.ts +58 -19
  33. package/extensions/bytefly/update/check.ts +35 -0
  34. package/extensions/bytefly/update/format.ts +76 -35
  35. package/extensions/bytefly/update/register.ts +144 -55
  36. package/extensions/bytefly/update/state.ts +4 -2
  37. package/extensions/bytefly/update/suppress.ts +66 -0
  38. package/extensions/bytefly/update/types.ts +15 -0
  39. package/extensions/check/README.md +253 -0
  40. package/extensions/check/commands.ts +128 -0
  41. package/extensions/check/config.ts +39 -0
  42. package/extensions/check/detect.ts +269 -0
  43. package/extensions/check/failures.ts +230 -0
  44. package/extensions/check/format.ts +135 -0
  45. package/extensions/check/index.ts +26 -0
  46. package/extensions/check/render.ts +185 -0
  47. package/extensions/check/runner.ts +176 -0
  48. package/extensions/check/runtime.ts +244 -0
  49. package/extensions/check/source.ts +80 -0
  50. package/extensions/check/tools.ts +173 -0
  51. package/extensions/check/tui.ts +195 -0
  52. package/extensions/check/types.ts +99 -0
  53. package/extensions/document/README.md +1 -1
  54. package/extensions/document/extract/pdf.ts +8 -16
  55. package/extensions/document/formats.ts +2 -2
  56. package/extensions/file-browse/README.md +66 -32
  57. package/extensions/file-browse/client.ts +95 -32
  58. package/extensions/file-browse/config.ts +9 -9
  59. package/extensions/file-browse/git.ts +37 -47
  60. package/extensions/file-browse/html.ts +39 -58
  61. package/extensions/file-browse/markdown.ts +110 -92
  62. package/extensions/file-browse/raw-html.ts +478 -0
  63. package/extensions/file-browse/router.ts +32 -70
  64. package/extensions/file-browse/server.ts +18 -56
  65. package/extensions/file-browse/urls.ts +80 -0
  66. package/extensions/goal/README.md +103 -47
  67. package/extensions/goal/clarify-parse.ts +4 -9
  68. package/extensions/goal/clarify.ts +27 -4
  69. package/extensions/goal/commands.ts +24 -11
  70. package/extensions/goal/format.ts +41 -10
  71. package/extensions/goal/index.ts +39 -16
  72. package/extensions/goal/plan-route.ts +51 -0
  73. package/extensions/goal/schema.ts +6 -23
  74. package/extensions/goal/state.ts +39 -57
  75. package/extensions/goal/tools.ts +26 -20
  76. package/extensions/goal/types.ts +11 -1
  77. package/extensions/job/README.md +20 -6
  78. package/extensions/job/format.ts +18 -68
  79. package/extensions/job/index.ts +26 -41
  80. package/extensions/job/log-writer.ts +208 -0
  81. package/extensions/job/logs.ts +3 -2
  82. package/extensions/job/process.ts +6 -99
  83. package/extensions/job/runtime.ts +42 -181
  84. package/extensions/job/schema.ts +1 -4
  85. package/extensions/job/tools.ts +1 -15
  86. package/extensions/job/tui.ts +117 -307
  87. package/extensions/job/ui.ts +7 -33
  88. package/extensions/memory/README.md +1 -1
  89. package/extensions/memory/errors.ts +5 -6
  90. package/extensions/memory/tui.ts +14 -24
  91. package/extensions/plan/README.md +94 -18
  92. package/extensions/plan/commands.ts +9 -5
  93. package/extensions/plan/format.ts +87 -0
  94. package/extensions/plan/index.ts +31 -15
  95. package/extensions/plan/plans.ts +2 -3
  96. package/extensions/plan/policy.ts +0 -6
  97. package/extensions/plan/runtime.ts +4 -2
  98. package/extensions/plan/steps.ts +37 -8
  99. package/extensions/plan/todos.ts +44 -3
  100. package/extensions/plan/tools/enter.ts +94 -0
  101. package/extensions/plan/tools/exit.ts +296 -0
  102. package/extensions/plan/tools/nested.ts +21 -0
  103. package/extensions/plan/tools/steps.ts +136 -0
  104. package/extensions/plan/tools/todo-seed.ts +76 -0
  105. package/extensions/plan/tools/write.ts +127 -0
  106. package/extensions/plan/tools.ts +18 -703
  107. package/extensions/plan/web-client.ts +6 -22
  108. package/extensions/plan/web-page.ts +19 -30
  109. package/extensions/plan/web-router.ts +34 -71
  110. package/extensions/plan/web.ts +13 -40
  111. package/extensions/question/answers.ts +3 -8
  112. package/extensions/question/tui.ts +6 -6
  113. package/extensions/rewind/README.md +95 -22
  114. package/extensions/rewind/checklist.ts +185 -0
  115. package/extensions/rewind/commands.ts +8 -0
  116. package/extensions/rewind/config.ts +3 -11
  117. package/extensions/rewind/format.ts +220 -2
  118. package/extensions/rewind/git.ts +74 -7
  119. package/extensions/rewind/hunk-picker.ts +114 -0
  120. package/extensions/rewind/patch.ts +88 -0
  121. package/extensions/rewind/path-picker.ts +67 -100
  122. package/extensions/rewind/paths.ts +93 -19
  123. package/extensions/rewind/restore.ts +53 -10
  124. package/extensions/rewind/review-tui.ts +320 -0
  125. package/extensions/rewind/review.ts +397 -0
  126. package/extensions/rewind/rewind-tui.ts +4 -3
  127. package/extensions/rewind/runtime.ts +94 -17
  128. package/extensions/subagent/README.md +61 -6
  129. package/extensions/subagent/background-tui.ts +208 -0
  130. package/extensions/subagent/background.ts +470 -0
  131. package/extensions/subagent/commands.ts +80 -0
  132. package/extensions/subagent/config.ts +4 -0
  133. package/extensions/subagent/format.ts +24 -1
  134. package/extensions/subagent/index.ts +91 -2
  135. package/extensions/subagent/render.ts +13 -0
  136. package/extensions/subagent/run.ts +17 -16
  137. package/extensions/subagent/schema.ts +12 -0
  138. package/extensions/subagent/types.ts +11 -0
  139. package/extensions/subagent/ui.ts +50 -0
  140. package/extensions/todo/README.md +18 -13
  141. package/extensions/todo/format.ts +3 -2
  142. package/extensions/todo/runtime.ts +3 -2
  143. package/extensions/todo/schema.ts +8 -84
  144. package/extensions/todo/state.ts +5 -46
  145. package/extensions/todo/tools.ts +2 -1
  146. package/extensions/todo/types.ts +6 -15
  147. package/extensions/watch/README.md +14 -6
  148. package/extensions/watch/child.ts +150 -0
  149. package/extensions/watch/format.ts +8 -48
  150. package/extensions/watch/index.ts +42 -58
  151. package/extensions/watch/runtime.ts +49 -128
  152. package/extensions/watch/schema.ts +1 -4
  153. package/extensions/watch/tools.ts +1 -11
  154. package/extensions/watch/tui.ts +103 -273
  155. package/extensions/watch/ui.ts +5 -32
  156. package/extensions/web-access/README.md +1 -1
  157. package/extensions/web-access/fetch/page.ts +1 -1
  158. package/extensions/web-access/fetch/tool.ts +6 -2
  159. package/extensions/web-access/search/search.ts +2 -5
  160. package/extensions/wechat/README.md +1 -1
  161. package/extensions/wechat/client.ts +23 -0
  162. package/extensions/wechat/commands.ts +57 -42
  163. package/extensions/wechat/deliver.ts +5 -9
  164. package/extensions/wechat/errors.ts +3 -6
  165. package/extensions/wechat/inbound.ts +2 -5
  166. package/extensions/wechat/index.ts +2 -8
  167. package/extensions/wechat/outbound.ts +2 -4
  168. package/extensions/wechat/peer-worker.ts +3 -8
  169. package/extensions/wechat/poll.ts +10 -9
  170. package/extensions/wechat/presence.ts +35 -62
  171. package/extensions/wechat/reply-pipeline.ts +3 -1
  172. package/extensions/wechat/status-screen.ts +1 -1
  173. package/extensions/wechat/timer.ts +57 -0
  174. package/extensions/worktree/config.ts +8 -20
  175. package/extensions/worktree/enter.ts +7 -12
  176. package/extensions/worktree/git.ts +38 -69
  177. package/extensions/worktree/index.ts +5 -4
  178. package/extensions/worktree/registry.ts +7 -7
  179. package/extensions/worktree/root-tools.ts +2 -2
  180. package/extensions/worktree/status.ts +3 -2
  181. package/lib/README.md +22 -8
  182. package/lib/config.ts +50 -3
  183. package/lib/errors.ts +12 -0
  184. package/lib/format.ts +50 -0
  185. package/lib/git/read.ts +8 -0
  186. package/lib/goal-state.ts +23 -8
  187. package/lib/http/headers.ts +54 -0
  188. package/lib/http/index.ts +13 -0
  189. package/lib/http/response.ts +46 -0
  190. package/lib/http/router.ts +52 -0
  191. package/lib/http/server.ts +91 -0
  192. package/lib/lines.ts +77 -0
  193. package/lib/pdf.ts +82 -0
  194. package/lib/plan-entry.ts +71 -0
  195. package/lib/plan-state.ts +34 -0
  196. package/lib/report.ts +99 -0
  197. package/lib/requirements.ts +25 -19
  198. package/lib/spawn.ts +170 -0
  199. package/lib/todo-state.ts +110 -23
  200. package/lib/tool-names.ts +7 -0
  201. package/lib/tool-result.ts +33 -0
  202. package/lib/tui/README.md +68 -3
  203. package/lib/tui/chrome.ts +28 -4
  204. package/lib/tui/columns.ts +6 -1
  205. package/lib/tui/confirm.ts +65 -0
  206. package/lib/tui/dock-screen.ts +410 -0
  207. package/lib/tui/glyphs.ts +2 -1
  208. package/lib/tui/indent.ts +1 -1
  209. package/lib/tui/index.ts +2 -0
  210. package/lib/tui/rows.ts +21 -0
  211. package/lib/tui/status-controller.ts +35 -1
  212. package/lib/tui/tool.ts +59 -1
  213. package/lib/web/components.ts +20 -1
  214. package/lib/web/markup.ts +32 -0
  215. package/lib/web/runtime.ts +50 -0
  216. package/package.json +22 -5
  217. package/skills/init/SKILL.md +165 -0
  218. package/skills/init/references/agents-md-quality.md +103 -0
  219. package/skills/init/references/interview-questions.md +81 -0
  220. package/skills/refactor/SKILL.md +141 -0
  221. package/extensions/watch/process.ts +0 -63
  222. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -6,31 +6,28 @@
6
6
  * computed from `Date.now()` at render, so the command can refresh the screen
7
7
  * without rebuilding the component.
8
8
  *
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).
9
+ * The frame, the navigation keys, the y/N confirmation, and the log pane come
10
+ * from the shared `DockListScreen`, so this module owns only the job vocabulary:
11
+ * the row, the focus block, the counts, the hints, and what the verbs do.
12
12
  *
13
13
  * Log text arrives already sanitized by the runtime, so no untrusted escape
14
14
  * sequence reaches the terminal through here.
15
15
  */
16
16
 
17
17
  import type { Theme } from "@earendil-works/pi-coding-agent";
18
- import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
19
- import { formatDuration } from "../../lib/format.ts";
20
- import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
21
- import { clip, fitCells } from "../../lib/tui/columns.ts";
22
- import { fitRows, formatRange, IdCursor, isCloseKey, navIntent, wheelDelta } from "../../lib/tui/cursor.ts";
23
- import { expandHint, SEPARATORS } from "../../lib/tui/glyphs.ts";
24
- import { LinePane } from "../../lib/tui/line-pane.ts";
25
- import { emptyState, statusRow, type StatusRow } from "../../lib/tui/rows.ts";
26
- import { titleRule } from "../../lib/tui/rules.ts";
18
+ import type { Component } from "@earendil-works/pi-tui";
19
+ import { formatDuration, sanitizeUntrustedLine } from "../../lib/format.ts";
20
+ import type { ScreenHint, ViewportRowsSource } from "../../lib/tui/chrome.ts";
21
+ import { DockListScreen, type DetailContents } from "../../lib/tui/dock-screen.ts";
22
+ import { SEPARATORS } from "../../lib/tui/glyphs.ts";
23
+ import { statusRow, type StatusRow } from "../../lib/tui/rows.ts";
24
+ import { TailResult, type TailResultInput } from "../../lib/tui/tool.ts";
27
25
  import { paint } from "../../lib/tui/tokens.ts";
28
26
  import {
29
27
  compareJobs,
30
28
  elapsedMs,
31
29
  formatJobDetail,
32
30
  jobCounts,
33
- sanitizeLogLine,
34
31
  shortLabel,
35
32
  statusGlyphChar,
36
33
  statusRole,
@@ -38,18 +35,6 @@ import {
38
35
  } from "./format.ts";
39
36
  import type { JobRecord } from "./types.ts";
40
37
 
41
- /** Rows the `/jobs` screen body shows when the terminal height is unknown. */
42
- const SCREEN_DEFAULT_ROWS = 14;
43
-
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
38
  /** One job row: selection marker, status glyph, id, label, and a right-aligned
54
39
  * elapsed value, tagged `peer` when another session owns it. `statusRow` owns
55
40
  * the clipping and the alignment. */
@@ -65,6 +50,7 @@ function jobRow(
65
50
  glyph: statusGlyphChar(job.status),
66
51
  glyphRole: statusRole(job.status),
67
52
  label: `${job.id} ${shortLabel(job)}`,
53
+ labelRole: options.selected ? "accent" : "label",
68
54
  // The tag rides the right-aligned cell, which survives a narrow terminal.
69
55
  trailing: options.foreign ? `${elapsed}${SEPARATORS.item}peer` : elapsed,
70
56
  });
@@ -75,51 +61,44 @@ export type JobResultInput =
75
61
  | { kind: "list"; jobs: JobRecord[]; more: number; sessionId?: string }
76
62
  | { kind: "logs"; lines: string[]; earlier: number };
77
63
 
64
+ /** Map the job vocabulary onto the shared transcript tail block. */
65
+ function jobTail(input: JobResultInput): TailResultInput<JobRecord> {
66
+ if (input.kind === "logs") {
67
+ return { kind: "lines", lines: input.lines, earlier: input.earlier, earlierLabel: "lines" };
68
+ }
69
+ return {
70
+ kind: "list",
71
+ rows: input.jobs,
72
+ more: input.more,
73
+ rowLine: (job, width, theme) => {
74
+ const foreign = input.sessionId !== undefined && job.sessionId !== input.sessionId;
75
+ return jobRow(job, theme, width, { marker: false, foreign });
76
+ },
77
+ };
78
+ }
79
+
78
80
  /**
79
- * Transcript result block for the `job` tool: a blank line, then either the
80
- * `/jobs`-style row rail (already ordered and capped by the caller) or the
81
- * themed log tail. Reused across renders via `setInput`.
81
+ * Transcript result block for the `job` tool: the shared tail block over the
82
+ * `/jobs`-style row rail or the themed log tail. Reused across renders.
82
83
  */
83
84
  export class JobResult implements Component {
84
- private input: JobResultInput;
85
- private theme: Theme;
85
+ private readonly view: TailResult<JobRecord>;
86
86
 
87
87
  constructor(input: JobResultInput, theme: Theme) {
88
- this.input = input;
89
- this.theme = theme;
88
+ this.view = new TailResult(jobTail(input), theme);
90
89
  }
91
90
 
92
91
  /** Update in place so the transcript can reuse this component across renders. */
93
92
  setInput(input: JobResultInput, theme: Theme): void {
94
- this.input = input;
95
- this.theme = theme;
93
+ this.view.setInput(jobTail(input), theme);
96
94
  }
97
95
 
98
- invalidate(): void {}
96
+ invalidate(): void {
97
+ this.view.invalidate();
98
+ }
99
99
 
100
100
  render(width: number): string[] {
101
- const w = Math.max(1, width);
102
- // A leading blank line separates the result from the call header.
103
- const lines: string[] = [""];
104
- if (this.input.kind === "list") {
105
- for (const job of this.input.jobs) {
106
- const foreign = this.input.sessionId !== undefined && job.sessionId !== this.input.sessionId;
107
- lines.push(jobRow(job, this.theme, w, { marker: false, foreign }));
108
- }
109
- if (this.input.more > 0) {
110
- const note = `${paint(this.theme, "faint", `… ${this.input.more} more`)} ${expandHint(this.theme)}`;
111
- lines.push(clip(` ${note}`, w));
112
- }
113
- } else {
114
- if (this.input.earlier > 0) {
115
- const note = `${paint(this.theme, "faint", `… ${this.input.earlier} earlier lines`)} ${expandHint(this.theme)}`;
116
- lines.push(clip(note, w));
117
- }
118
- for (const line of this.input.lines) {
119
- lines.push(this.theme.fg("toolOutput", clip(line, w)));
120
- }
121
- }
122
- return lines;
101
+ return this.view.render(width);
123
102
  }
124
103
  }
125
104
 
@@ -132,55 +111,44 @@ export interface JobListCallbacks {
132
111
  clear(): void;
133
112
  }
134
113
 
135
- /** A destructive action waiting for y/N confirmation. */
136
- type PendingConfirm =
137
- | { kind: "kill"; id: string; label: string }
138
- | { kind: "clear"; count: number; unreported: number };
139
-
140
114
  /** Dismissible `/jobs` screen: a selectable list with a focus pane and log view. */
141
- export class JobListComponent implements Component {
142
- private mode: "list" | "logs" = "list";
143
- /** Focus anchored to a job id; the list is re-sorted from live state each render. */
144
- private readonly cursor = new IdCursor();
145
- /** The open log tail. */
146
- private readonly pane = new LinePane();
147
- /** Pending destructive action awaiting y/N. */
148
- private confirm: PendingConfirm | undefined;
149
- /** Transient feedback shown in place of the hint, e.g. a no-op action. */
150
- private notice: string | undefined;
151
-
115
+ export class JobListComponent extends DockListScreen<JobRecord> {
152
116
  constructor(
153
117
  private readonly jobs: () => JobRecord[],
154
- private readonly theme: Theme,
118
+ theme: Theme,
155
119
  private readonly callbacks: JobListCallbacks,
156
- private readonly onClose: () => void,
157
- private readonly requestRender: () => void,
158
- private readonly viewportRowsSource?: ViewportRowsSource,
159
- ) {}
160
-
161
- invalidate(): void {}
120
+ onClose: () => void,
121
+ requestRender: () => void,
122
+ viewportRowsSource?: ViewportRowsSource,
123
+ ) {
124
+ super({
125
+ theme,
126
+ title: "Jobs",
127
+ detailTitle: "Job Logs",
128
+ emptyList: "No background jobs.",
129
+ emptyDetail: "No output yet.",
130
+ requestRender,
131
+ onClose,
132
+ viewportRowsSource,
133
+ });
134
+ }
162
135
 
163
136
  /** Replace the log pane contents (called by the command's poll loop). */
164
137
  setLogs(title: string, lines: string[], meta: { more?: boolean; logPath?: string } = {}): void {
165
138
  // The poll fires every 500ms; `LinePane.update` skips the repaint when
166
139
  // nothing changed so an idle log pane does not redraw (and fight the
167
140
  // user's scroll) constantly.
168
- if (this.pane.update({ title, lines, more: meta.more, subtitle: meta.logPath })) this.requestRender();
141
+ this.setDetail({ title, lines, more: meta.more, subtitle: meta.logPath });
169
142
  }
170
143
 
171
144
  /** The job whose log pane is open, or undefined in list mode. */
172
145
  currentLogId(): string | undefined {
173
- return this.mode === "logs" && this.pane.id ? this.pane.id : undefined;
146
+ return this.currentDetailId();
174
147
  }
175
148
 
176
149
  /** Re-read the open log pane from the runtime. */
177
150
  refreshLogs(id: string): void {
178
- const result = this.callbacks.logs(id);
179
- if (result && this.pane.update({ lines: result.lines, more: result.more })) this.requestRender();
180
- }
181
-
182
- private get ordered(): JobRecord[] {
183
- return [...this.jobs()].sort(compareJobs);
151
+ this.refreshDetail(id);
184
152
  }
185
153
 
186
154
  /** Whether another session owns this job, so the screen leaves it alone. */
@@ -188,246 +156,88 @@ export class JobListComponent implements Component {
188
156
  return job.sessionId !== this.callbacks.sessionId();
189
157
  }
190
158
 
191
- /** Focus job `next` (clamped) and keep it visible. */
192
- private select(next: number): void {
193
- const jobs = this.ordered;
194
- if (jobs.length === 0) return;
195
- this.cursor.select(jobs, jobId, next);
196
- this.requestRender();
197
- }
198
-
199
- private openLogs(): void {
200
- const job = this.ordered[this.cursor.indexOf(this.ordered, jobId)];
201
- if (!job) return;
202
- this.mode = "logs";
203
- const result = this.callbacks.logs(job.id);
204
- this.pane.open(job.id, {
205
- title: `${job.id} ${shortLabel(job)}`,
206
- lines: result?.lines ?? [],
207
- more: result?.more ?? false,
208
- subtitle: job.logPath ? sanitizeLogLine(job.logPath) : undefined,
209
- });
210
- this.requestRender();
211
- }
212
-
213
- handleInput(data: string): void {
214
- if (this.confirm) {
215
- this.handleConfirmInput(data);
216
- return;
217
- }
218
- if (this.mode === "list") this.handleListInput(data);
219
- else this.handleLogInput(data);
220
- }
221
-
222
- /** Wheel scrolling moves the selection, or scrolls the log pane in log mode. */
223
- handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
224
- const delta = wheelDelta(event);
225
- if (delta === undefined) return undefined;
226
- if (this.confirm) return { handled: true };
227
- if (this.mode === "list") {
228
- this.select(this.cursor.indexOf(this.ordered, jobId) + delta);
229
- } else {
230
- this.pane.setScroll(this.pane.scroll + delta);
231
- this.requestRender();
232
- }
233
- return { handled: true };
234
- }
235
-
236
- private handleListInput(data: string): void {
237
- if (isCloseKey(data) || data === "q") {
238
- this.onClose();
239
- return;
240
- }
241
- // Any other key dismisses a prior notice, so it does not linger.
242
- if (this.notice !== undefined) {
243
- this.notice = undefined;
244
- this.requestRender();
245
- }
246
- const ordered = this.ordered;
247
- const index = this.cursor.indexOf(ordered, jobId);
248
- switch (navIntent(data)) {
249
- case "up":
250
- this.select(index - 1);
251
- return;
252
- case "down":
253
- this.select(index + 1);
254
- return;
255
- case "pageUp":
256
- this.select(index - this.cursor.visible);
257
- return;
258
- case "pageDown":
259
- this.select(index + this.cursor.visible);
260
- return;
261
- case "home":
262
- this.select(0);
263
- return;
264
- case "end":
265
- this.select(ordered.length - 1);
266
- return;
267
- }
268
- if (matchesKey(data, Key.enter) || data === "l") this.openLogs();
269
- else if (data === "d" || data === "K") {
270
- const job = ordered[index];
271
- if (!job) return;
272
- if (this.isForeign(job)) {
273
- // Another session's job is shown here, not managed from here.
274
- this.notice = `${job.id} is another session's job; not killed.`;
275
- } else if (job.status === "running") {
276
- this.confirm = { kind: "kill", id: job.id, label: shortLabel(job) };
277
- } else {
278
- // A finished job has nothing to kill; say so instead of a silent no-op.
279
- this.notice = `${job.id} already ${job.status}; nothing to kill.`;
280
- }
281
- this.requestRender();
282
- } else if (data === "x") {
283
- // Only this session's finished jobs are ours to clear, so the prompt and the
284
- // result agree.
285
- const finished = ordered.filter((job) => !this.isForeign(job) && job.status !== "running");
286
- if (finished.length > 0) {
287
- this.confirm = {
288
- kind: "clear",
289
- count: finished.length,
290
- unreported: finished.filter((job) => !job.seen).length,
291
- };
292
- } else {
293
- this.notice = "No finished jobs to clear.";
294
- }
295
- this.requestRender();
296
- }
159
+ protected rows(): readonly JobRecord[] {
160
+ return [...this.jobs()].sort(compareJobs);
297
161
  }
298
162
 
299
- /** Resolve a pending y/N confirmation. */
300
- private handleConfirmInput(data: string): void {
301
- const pending = this.confirm;
302
- if (!pending) return;
303
- if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
304
- this.confirm = undefined;
305
- if (pending.kind === "kill") this.callbacks.kill(pending.id);
306
- else {
307
- this.callbacks.clear();
308
- this.cursor.clamp(this.ordered, jobId);
309
- }
310
- this.requestRender();
311
- return;
312
- }
313
- if (data === "n" || data === "N" || isCloseKey(data) || data === "q") {
314
- this.confirm = undefined;
315
- this.requestRender();
316
- }
163
+ protected idOf(job: JobRecord): string {
164
+ return job.id;
317
165
  }
318
166
 
319
- /** Warning line shown in place of the hint while a confirm is pending. */
320
- private confirmPrompt(): string {
321
- const pending = this.confirm;
322
- if (!pending) return "";
323
- if (pending.kind === "kill") return `Kill ${pending.id} (${pending.label})? y/N`;
324
- const note =
325
- pending.unreported > 0
326
- ? ` ${pending.unreported} unreported result${pending.unreported === 1 ? "" : "s"} will be discarded.`
327
- : "";
328
- return `Clear ${pending.count} finished job${pending.count === 1 ? "" : "s"}?${note} y/N`;
167
+ protected rowLine(job: JobRecord, width: number, selected: boolean): string {
168
+ return jobRow(job, this.theme, width, { selected, foreign: this.isForeign(job) });
329
169
  }
330
170
 
331
- private handleLogInput(data: string): void {
332
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.backspace) || data === "q") {
333
- this.mode = "list";
334
- this.pane.close();
335
- this.requestRender();
336
- return;
337
- }
338
- if (this.pane.handleKey(data)) this.requestRender();
171
+ protected detailBlock(job: JobRecord): string[] {
172
+ return formatJobDetail(job, Date.now(), this.callbacks.sessionId());
339
173
  }
340
174
 
341
- private summary(counts: JobCounts, width: number, foreign: number): string {
175
+ protected summaryCells(rows: readonly JobRecord[]): string[] {
176
+ const counts: JobCounts = jobCounts([...rows]);
342
177
  const cells = [paint(this.theme, "meta", `${counts.total} job${counts.total === 1 ? "" : "s"}`)];
343
178
  if (counts.running > 0) cells.push(paint(this.theme, "meta", `${counts.running} running`));
344
179
  if (counts.failed > 0) cells.push(paint(this.theme, "bad", `${counts.failed} failed`));
180
+ const foreign = rows.filter((job) => this.isForeign(job)).length;
345
181
  if (foreign > 0) cells.push(paint(this.theme, "faint", `${foreign} peer`));
346
- const separator = paint(this.theme, "meta", SEPARATORS.item);
347
- return clip(` ${fitCells(cells, Math.max(1, width - 2), separator)}`, width);
182
+ return cells;
348
183
  }
349
184
 
350
- render(width: number): string[] {
351
- const w = Math.max(1, width);
352
- return this.mode === "logs" ? this.renderLogs(w) : this.renderList(w);
185
+ protected listHints(): ScreenHint[] {
186
+ return [
187
+ "Esc close",
188
+ "↑/↓ or j/k select",
189
+ { text: "Enter or l logs", color: "accent" },
190
+ { text: "d or K kill", color: "warning" },
191
+ { text: "x clear finished", color: "warning" },
192
+ "PgUp/PgDn",
193
+ ];
353
194
  }
354
195
 
355
- private renderList(w: number): string[] {
356
- const jobs = this.ordered;
357
- this.cursor.clamp(jobs, jobId);
358
- const index = this.cursor.indexOf(jobs, jobId);
359
- const focused = jobs[index];
360
- const detail = focused ? formatJobDetail(focused, Date.now(), this.callbacks.sessionId()) : [];
361
- this.cursor.visible = fitRows(
362
- this.viewportRowsSource,
363
- jobs.length,
364
- LIST_CHROME_ROWS + detail.length,
365
- SCREEN_DEFAULT_ROWS,
366
- );
367
- this.cursor.clamp(jobs, jobId);
368
- this.cursor.ensureVisible(this.cursor.indexOf(jobs, jobId));
369
-
370
- const body: string[] = [];
371
- let range: string | undefined;
372
- if (jobs.length === 0) {
373
- body.push(...emptyState(this.theme, w, { label: "No background jobs." }));
374
- } else {
375
- const end = Math.min(jobs.length, this.cursor.scroll + this.cursor.visible);
376
- for (let i = this.cursor.scroll; i < end; i++) {
377
- const row = jobs[i];
378
- body.push(jobRow(row, this.theme, w, { selected: i === index, foreign: this.isForeign(row) }));
379
- }
380
- if (this.cursor.scroll > 0 || end < jobs.length) {
381
- range = formatRange(this.theme, w, { start: this.cursor.scroll, end, total: jobs.length });
382
- }
383
- }
384
-
385
- const hint = this.confirm
386
- ? clip(` ${paint(this.theme, "warn", this.confirmPrompt())}`, w)
387
- : this.notice
388
- ? clip(` ${paint(this.theme, "meta", this.notice)}`, w)
389
- : screenHint(this.theme, w, [
390
- "Esc close",
391
- "↑/↓ or j/k select",
392
- "Enter or l logs",
393
- "d or K kill",
394
- "x clear finished",
395
- "PgUp/PgDn",
396
- ]);
196
+ protected detailHints(): ScreenHint[] {
197
+ return ["Esc back", "↑/↓ scroll", "g/G ends", "PgUp/PgDn"];
198
+ }
397
199
 
398
- return dockListFrame({
399
- header: titleRule(this.theme, w, "Jobs"),
400
- summary: this.summary(jobCounts(jobs), w, jobs.filter((job) => this.isForeign(job)).length),
401
- body,
402
- range,
403
- // The detail pane is separated from the list by a blank line.
404
- detail: ["", ...detail.map((line) => clip(` ${paint(this.theme, "meta", line)}`, w))],
405
- hint,
406
- });
200
+ protected detailFor(job: JobRecord): DetailContents {
201
+ const result = this.callbacks.logs(job.id);
202
+ return {
203
+ title: `${job.id} ${shortLabel(job)}`,
204
+ lines: result?.lines ?? [],
205
+ more: result?.more ?? false,
206
+ subtitle: job.logPath ? sanitizeUntrustedLine(job.logPath) : undefined,
207
+ };
407
208
  }
408
209
 
409
- private renderLogs(w: number): string[] {
410
- const visible = fitRows(this.viewportRowsSource, this.pane.count, LOG_CHROME_ROWS, SCREEN_DEFAULT_ROWS);
411
- this.pane.fit(visible);
210
+ protected detailById(id: string): { lines: string[]; more?: boolean } | undefined {
211
+ return this.callbacks.logs(id);
212
+ }
412
213
 
413
- const body: string[] = [];
414
- let range: string | undefined;
415
- if (this.pane.count === 0) {
416
- body.push(...emptyState(this.theme, w, { label: "No output yet." }));
417
- } else {
418
- const { start, end } = this.pane.window();
419
- for (let i = start; i < end; i++) body.push(clip(` ${this.pane.lines[i]}`, w));
420
- if (start > 0 || end < this.pane.count) {
421
- range = formatRange(this.theme, w, { start, end, total: this.pane.count, label: "line" });
214
+ protected handleVerb(key: string, job: JobRecord | undefined): void {
215
+ if (key === "d" || key === "K") {
216
+ if (!job) return;
217
+ if (this.isForeign(job)) {
218
+ // Another session's job is shown here, not managed from here.
219
+ this.notify(`${job.id} is another session's job; not killed.`);
220
+ } else if (job.status === "running") {
221
+ this.confirm(`Kill ${job.id} (${shortLabel(job)})? y/N`, () => this.callbacks.kill(job.id));
222
+ } else {
223
+ // A finished job has nothing to kill; say so instead of a silent no-op.
224
+ this.notify(`${job.id} already ${job.status}; nothing to kill.`);
422
225
  }
226
+ return;
423
227
  }
424
-
425
- return dockListFrame({
426
- header: titleRule(this.theme, w, this.pane.title("Job Logs")),
427
- summary: clip(` ${paint(this.theme, "faint", this.pane.subtitleLine())}`, w),
428
- body,
429
- range,
430
- hint: screenHint(this.theme, w, ["Esc back", "↑/↓ scroll", "g/G ends", "PgUp/PgDn"]),
431
- });
228
+ if (key !== "x") return;
229
+ // Only this session's finished jobs are ours to clear, so the prompt and the
230
+ // result agree.
231
+ const finished = this.rows().filter((row) => !this.isForeign(row) && row.status !== "running");
232
+ if (finished.length === 0) {
233
+ this.notify("No finished jobs to clear.");
234
+ return;
235
+ }
236
+ const unreported = finished.filter((row) => !row.seen).length;
237
+ const note =
238
+ unreported > 0 ? ` ${unreported} unreported result${unreported === 1 ? "" : "s"} will be discarded.` : "";
239
+ this.confirm(`Clear ${finished.length} finished job${finished.length === 1 ? "" : "s"}?${note} y/N`, () =>
240
+ this.callbacks.clear(),
241
+ );
432
242
  }
433
243
  }
@@ -1,13 +1,12 @@
1
1
  /**
2
- * Presentation for the jobs extension: the footer status chips.
2
+ * Presentation for the jobs extension: the footer status chips and the failure
3
+ * alert.
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". 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.
5
+ * The chips and the context they paint through come from the shared
6
+ * `lib/tui/status-controller.ts`, so only the vocabulary — the keys, glyphs,
7
+ * counts, and message text — is job-specific.
8
8
  */
9
9
 
10
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
10
  import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
12
11
  import { createStatusController, type StatusChip, type StatusController } from "../../lib/tui/status-controller.ts";
13
12
  import type { JobsConfig } from "./config.ts";
@@ -26,8 +25,6 @@ export interface UiOptions {
26
25
 
27
26
  export function createUiController(options: UiOptions): UiController {
28
27
  const { getJobs, getConfig } = options;
29
- // The notification also needs the context, so it is mirrored here.
30
- let ctx: ExtensionContext | undefined;
31
28
 
32
29
  const controller = createStatusController({
33
30
  keys: [STATUS_KEYS.jobs, STATUS_KEYS.jobsFailure],
@@ -43,32 +40,9 @@ export function createUiController(options: UiOptions): UiController {
43
40
  });
44
41
 
45
42
  const notifyFailure = (job: JobRecord): void => {
46
- const target = ctx;
47
- if (!target) return;
48
43
  const exit = job.exitCode !== null ? ` (exit ${job.exitCode})` : "";
49
- try {
50
- target.ui.notify(`Job ${job.id} failed: ${shortLabel(job)}${exit}`, "error");
51
- } catch {
52
- // UI may be unavailable in non-interactive modes.
53
- }
44
+ controller.notify(`Job ${job.id} failed: ${shortLabel(job)}${exit}`, "error");
54
45
  };
55
46
 
56
- return {
57
- attach: (next) => {
58
- ctx = next;
59
- controller.attach(next);
60
- },
61
- detach: () => {
62
- ctx = undefined;
63
- controller.detach();
64
- },
65
- paint: () => {
66
- controller.paint();
67
- },
68
- setStatus: (next) => {
69
- if (next) ctx = next;
70
- controller.setStatus(next);
71
- },
72
- notifyFailure,
73
- };
47
+ return { ...controller, notifyFailure };
74
48
  }
@@ -254,7 +254,7 @@ when consolidating, or merge the pair in the picker afterward.
254
254
  | `doctor.ts` | The health report (pure): counts, file bytes, the injection plan's omissions, and near-duplicate pairs. |
255
255
  | `runtime.ts` | Session view of both stores, stamp-based refresh, trust gating, mutations. |
256
256
  | `format.ts` | Pure call/result/notice text and the bounded injection body. |
257
- | `errors.ts` | `messageOf`, the shared error-text extraction for the tool and command. |
257
+ | `errors.ts` | Re-exports `messageOf`, the shared error-text extraction (defined in `lib/errors.ts`) for the tool and command. |
258
258
  | `tui.ts` | The `/memory` picker component (note list, inline editor, merge mark, scope move, scope filter), drawn from the `lib/tui/` kit (`titleRule`, `dockListFrame`, `listRow`, `emptyState`). |
259
259
  | `tools.ts` | `registerTool("memory", …)`. |
260
260
  | `commands.ts` | `registerCommand("memory", …)`. |
@@ -1,9 +1,8 @@
1
1
  /**
2
- * Error-text extraction shared by the memory tool and the `/memory` command, so
3
- * both report an unknown thrown value the same way.
2
+ * Error-text extraction shared by the memory tool and the `/memory` command.
3
+ *
4
+ * The single definition lives in `lib/errors.ts`; this module keeps the
5
+ * extension-local import path used by the tool, command, and picker.
4
6
  */
5
7
 
6
- /** A human-readable message for an unknown thrown value. */
7
- export function messageOf(error: unknown): string {
8
- return error instanceof Error ? error.message : String(error);
9
- }
8
+ export { messageOf } from "../../lib/errors.ts";