@gavin-hu/my-pi-agent 0.13.1 → 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 (237) hide show
  1. package/README.md +66 -41
  2. package/README.zh-CN.md +53 -36
  3. package/extensions/bytefly/README.md +321 -186
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/config.ts +4 -1
  7. package/extensions/bytefly/doctor/register.ts +24 -5
  8. package/extensions/bytefly/doctor/tools.ts +4 -0
  9. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  10. package/extensions/bytefly/footer/config.ts +10 -6
  11. package/extensions/bytefly/footer/price/config.ts +3 -2
  12. package/extensions/bytefly/footer/price/price.ts +5 -3
  13. package/extensions/bytefly/footer/price/rates.ts +63 -26
  14. package/extensions/bytefly/footer/render/status.ts +3 -3
  15. package/extensions/bytefly/header/component.ts +33 -10
  16. package/extensions/bytefly/header/config.ts +22 -0
  17. package/extensions/bytefly/header/register.ts +2 -1
  18. package/extensions/bytefly/index.ts +6 -6
  19. package/extensions/bytefly/notice/component.ts +8 -3
  20. package/extensions/bytefly/notify/config.ts +7 -12
  21. package/extensions/bytefly/notify/dispatch.ts +13 -5
  22. package/extensions/bytefly/panel/commands.ts +22 -2
  23. package/extensions/bytefly/panel/format.ts +17 -21
  24. package/extensions/bytefly/panel/settings-runtime.ts +86 -22
  25. package/extensions/bytefly/panel/settings.ts +279 -46
  26. package/extensions/bytefly/panel/themes.ts +32 -0
  27. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  28. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  29. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  30. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  31. package/extensions/bytefly/panel/tui/settings-view.ts +98 -13
  32. package/extensions/bytefly/panel/tui.ts +176 -166
  33. package/extensions/bytefly/picker/discovery.ts +1 -0
  34. package/extensions/bytefly/picker/types.ts +2 -0
  35. package/extensions/bytefly/separator/component.ts +17 -6
  36. package/extensions/bytefly/separator/config.ts +15 -0
  37. package/extensions/bytefly/separator/format.ts +60 -6
  38. package/extensions/bytefly/separator/register.ts +68 -9
  39. package/extensions/bytefly/separator/types.ts +8 -0
  40. package/extensions/bytefly/update/cache.ts +58 -19
  41. package/extensions/bytefly/update/check.ts +35 -0
  42. package/extensions/bytefly/update/format.ts +76 -35
  43. package/extensions/bytefly/update/register.ts +144 -55
  44. package/extensions/bytefly/update/state.ts +4 -2
  45. package/extensions/bytefly/update/suppress.ts +66 -0
  46. package/extensions/bytefly/update/types.ts +15 -0
  47. package/extensions/bytefly/working/config.ts +4 -5
  48. package/extensions/bytefly/working/format.ts +24 -7
  49. package/extensions/bytefly/working/register.ts +34 -3
  50. package/extensions/check/README.md +253 -0
  51. package/extensions/check/commands.ts +128 -0
  52. package/extensions/check/config.ts +39 -0
  53. package/extensions/check/detect.ts +269 -0
  54. package/extensions/check/failures.ts +230 -0
  55. package/extensions/check/format.ts +135 -0
  56. package/extensions/check/index.ts +26 -0
  57. package/extensions/check/render.ts +185 -0
  58. package/extensions/check/runner.ts +176 -0
  59. package/extensions/check/runtime.ts +244 -0
  60. package/extensions/check/source.ts +80 -0
  61. package/extensions/check/tools.ts +173 -0
  62. package/extensions/check/tui.ts +195 -0
  63. package/extensions/check/types.ts +99 -0
  64. package/extensions/document/README.md +1 -1
  65. package/extensions/document/extract/pdf.ts +8 -16
  66. package/extensions/document/formats.ts +2 -2
  67. package/extensions/file-browse/README.md +66 -32
  68. package/extensions/file-browse/client.ts +95 -32
  69. package/extensions/file-browse/config.ts +9 -9
  70. package/extensions/file-browse/git.ts +37 -47
  71. package/extensions/file-browse/html.ts +39 -58
  72. package/extensions/file-browse/markdown.ts +110 -92
  73. package/extensions/file-browse/raw-html.ts +478 -0
  74. package/extensions/file-browse/router.ts +32 -70
  75. package/extensions/file-browse/server.ts +18 -56
  76. package/extensions/file-browse/urls.ts +80 -0
  77. package/extensions/goal/README.md +103 -47
  78. package/extensions/goal/clarify-parse.ts +4 -9
  79. package/extensions/goal/clarify.ts +27 -4
  80. package/extensions/goal/commands.ts +24 -11
  81. package/extensions/goal/format.ts +41 -10
  82. package/extensions/goal/index.ts +39 -16
  83. package/extensions/goal/plan-route.ts +51 -0
  84. package/extensions/goal/schema.ts +6 -23
  85. package/extensions/goal/state.ts +39 -57
  86. package/extensions/goal/tools.ts +26 -20
  87. package/extensions/goal/types.ts +11 -1
  88. package/extensions/job/README.md +20 -6
  89. package/extensions/job/format.ts +18 -68
  90. package/extensions/job/index.ts +26 -41
  91. package/extensions/job/log-writer.ts +208 -0
  92. package/extensions/job/logs.ts +3 -2
  93. package/extensions/job/process.ts +6 -99
  94. package/extensions/job/runtime.ts +42 -181
  95. package/extensions/job/schema.ts +1 -4
  96. package/extensions/job/tools.ts +1 -15
  97. package/extensions/job/tui.ts +117 -307
  98. package/extensions/job/ui.ts +7 -33
  99. package/extensions/memory/README.md +3 -1
  100. package/extensions/memory/errors.ts +5 -6
  101. package/extensions/memory/tui.ts +14 -24
  102. package/extensions/plan/README.md +106 -18
  103. package/extensions/plan/commands.ts +9 -5
  104. package/extensions/plan/format.ts +87 -0
  105. package/extensions/plan/index.ts +31 -15
  106. package/extensions/plan/plans.ts +2 -3
  107. package/extensions/plan/policy.ts +0 -6
  108. package/extensions/plan/runtime.ts +4 -2
  109. package/extensions/plan/steps.ts +37 -8
  110. package/extensions/plan/todos.ts +44 -3
  111. package/extensions/plan/tools/enter.ts +94 -0
  112. package/extensions/plan/tools/exit.ts +296 -0
  113. package/extensions/plan/tools/nested.ts +21 -0
  114. package/extensions/plan/tools/steps.ts +136 -0
  115. package/extensions/plan/tools/todo-seed.ts +76 -0
  116. package/extensions/plan/tools/write.ts +127 -0
  117. package/extensions/plan/tools.ts +18 -703
  118. package/extensions/plan/web-client.ts +6 -22
  119. package/extensions/plan/web-page.ts +19 -30
  120. package/extensions/plan/web-router.ts +34 -71
  121. package/extensions/plan/web.ts +13 -40
  122. package/extensions/question/answers.ts +3 -8
  123. package/extensions/question/tui.ts +6 -6
  124. package/extensions/rewind/README.md +95 -22
  125. package/extensions/rewind/checklist.ts +185 -0
  126. package/extensions/rewind/commands.ts +8 -0
  127. package/extensions/rewind/config.ts +3 -11
  128. package/extensions/rewind/format.ts +220 -2
  129. package/extensions/rewind/git.ts +74 -7
  130. package/extensions/rewind/hunk-picker.ts +114 -0
  131. package/extensions/rewind/patch.ts +88 -0
  132. package/extensions/rewind/path-picker.ts +67 -100
  133. package/extensions/rewind/paths.ts +93 -19
  134. package/extensions/rewind/restore.ts +53 -10
  135. package/extensions/rewind/review-tui.ts +320 -0
  136. package/extensions/rewind/review.ts +397 -0
  137. package/extensions/rewind/rewind-tui.ts +4 -3
  138. package/extensions/rewind/runtime.ts +94 -17
  139. package/extensions/subagent/README.md +118 -8
  140. package/extensions/subagent/background-tui.ts +208 -0
  141. package/extensions/subagent/background.ts +470 -0
  142. package/extensions/subagent/commands.ts +80 -0
  143. package/extensions/subagent/config.ts +62 -0
  144. package/extensions/subagent/format.ts +24 -1
  145. package/extensions/subagent/index.ts +116 -1
  146. package/extensions/subagent/orchestrate.ts +16 -3
  147. package/extensions/subagent/render.ts +25 -0
  148. package/extensions/subagent/run.ts +113 -49
  149. package/extensions/subagent/schema.ts +12 -0
  150. package/extensions/subagent/transcripts.ts +202 -0
  151. package/extensions/subagent/types.ts +23 -0
  152. package/extensions/subagent/ui.ts +50 -0
  153. package/extensions/todo/README.md +18 -13
  154. package/extensions/todo/format.ts +3 -2
  155. package/extensions/todo/runtime.ts +3 -2
  156. package/extensions/todo/schema.ts +8 -84
  157. package/extensions/todo/state.ts +5 -46
  158. package/extensions/todo/tools.ts +2 -1
  159. package/extensions/todo/types.ts +6 -15
  160. package/extensions/watch/README.md +16 -6
  161. package/extensions/watch/child.ts +150 -0
  162. package/extensions/watch/format.ts +8 -48
  163. package/extensions/watch/index.ts +42 -58
  164. package/extensions/watch/runtime.ts +49 -128
  165. package/extensions/watch/schema.ts +1 -4
  166. package/extensions/watch/tools.ts +1 -11
  167. package/extensions/watch/tui.ts +103 -273
  168. package/extensions/watch/ui.ts +5 -32
  169. package/extensions/web-access/README.md +1 -1
  170. package/extensions/web-access/fetch/page.ts +1 -1
  171. package/extensions/web-access/fetch/tool.ts +6 -2
  172. package/extensions/web-access/search/search.ts +2 -5
  173. package/extensions/wechat/README.md +3 -1
  174. package/extensions/wechat/client.ts +23 -0
  175. package/extensions/wechat/commands.ts +57 -42
  176. package/extensions/wechat/deliver.ts +5 -9
  177. package/extensions/wechat/errors.ts +3 -6
  178. package/extensions/wechat/inbound.ts +2 -5
  179. package/extensions/wechat/index.ts +2 -8
  180. package/extensions/wechat/outbound.ts +2 -4
  181. package/extensions/wechat/peer-worker.ts +3 -8
  182. package/extensions/wechat/poll.ts +10 -9
  183. package/extensions/wechat/presence.ts +35 -62
  184. package/extensions/wechat/reply-pipeline.ts +3 -1
  185. package/extensions/wechat/status-screen.ts +1 -1
  186. package/extensions/wechat/timer.ts +57 -0
  187. package/extensions/worktree/config.ts +8 -20
  188. package/extensions/worktree/enter.ts +7 -12
  189. package/extensions/worktree/git.ts +38 -69
  190. package/extensions/worktree/index.ts +5 -4
  191. package/extensions/worktree/registry.ts +7 -7
  192. package/extensions/worktree/root-tools.ts +2 -2
  193. package/extensions/worktree/status.ts +3 -2
  194. package/lib/README.md +22 -8
  195. package/lib/config.ts +83 -12
  196. package/lib/errors.ts +12 -0
  197. package/lib/format.ts +56 -0
  198. package/lib/git/read.ts +8 -0
  199. package/lib/goal-state.ts +23 -8
  200. package/lib/http/headers.ts +54 -0
  201. package/lib/http/index.ts +13 -0
  202. package/lib/http/response.ts +46 -0
  203. package/lib/http/router.ts +52 -0
  204. package/lib/http/server.ts +91 -0
  205. package/lib/lines.ts +77 -0
  206. package/lib/pdf.ts +82 -0
  207. package/lib/plan-entry.ts +71 -0
  208. package/lib/plan-state.ts +34 -0
  209. package/lib/report.ts +99 -0
  210. package/lib/requirements.ts +25 -19
  211. package/lib/spawn.ts +170 -0
  212. package/lib/todo-state.ts +110 -23
  213. package/lib/tool-names.ts +7 -0
  214. package/lib/tool-result.ts +33 -0
  215. package/lib/tui/README.md +68 -3
  216. package/lib/tui/chrome.ts +28 -4
  217. package/lib/tui/columns.ts +6 -1
  218. package/lib/tui/confirm.ts +65 -0
  219. package/lib/tui/dock-screen.ts +410 -0
  220. package/lib/tui/glyphs.ts +2 -1
  221. package/lib/tui/indent.ts +1 -1
  222. package/lib/tui/index.ts +2 -0
  223. package/lib/tui/rows.ts +21 -0
  224. package/lib/tui/status-controller.ts +35 -1
  225. package/lib/tui/tool.ts +59 -1
  226. package/lib/web/components.ts +20 -1
  227. package/lib/web/markup.ts +32 -0
  228. package/lib/web/runtime.ts +50 -0
  229. package/package.json +23 -5
  230. package/skills/init/SKILL.md +165 -0
  231. package/skills/init/references/agents-md-quality.md +103 -0
  232. package/skills/init/references/interview-questions.md +81 -0
  233. package/skills/refactor/SKILL.md +141 -0
  234. package/themes/bytefly-dark.json +2 -3
  235. package/themes/bytefly-light.json +2 -3
  236. package/extensions/watch/process.ts +0 -63
  237. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -0,0 +1,410 @@
1
+ /**
2
+ * The two-mode docked list screen: a selectable list with a focus pane, and a
3
+ * scrollable detail pane over one row.
4
+ *
5
+ * Every live list in this package has the same body: `/jobs`, `/watch`, the
6
+ * agent board, and a check result all draw a title rule, a summary line, rows
7
+ * under an id-anchored cursor, an optional focus block, a range note, and a
8
+ * footer that shows either the key hints, a pending y/N confirmation, or one
9
+ * line of feedback. Only the vocabulary differs — the row, the detail block, the
10
+ * verbs, the words.
11
+ *
12
+ * So this class owns the frame, the navigation and scroll keys, the wheel, the
13
+ * confirm layer, and the pane lifecycle; a subclass supplies `rows`, the row
14
+ * line, the detail block, the summary cells, the hints, and what a verb does.
15
+ * The cursor is anchored to a row id, so a live re-sort cannot move the focus or
16
+ * swap the open detail out from under the user.
17
+ *
18
+ * Value-only: the cursor, pane, and pending state live on the instance.
19
+ */
20
+
21
+ import type { Theme } from "@earendil-works/pi-coding-agent";
22
+ import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
23
+ import { detailBlockRows, dockListFrame, screenHint, type ScreenHint, type ViewportRowsSource } from "./chrome.ts";
24
+ import { clip, fitCells } from "./columns.ts";
25
+ import { ConfirmState } from "./confirm.ts";
26
+ import { fitRows, formatRange, IdCursor, isCloseKey, navIntent, wheelDelta } from "./cursor.ts";
27
+ import { SEPARATORS } from "./glyphs.ts";
28
+ import { LinePane } from "./line-pane.ts";
29
+ import { emptyState } from "./rows.ts";
30
+ import { titleRule } from "./rules.ts";
31
+ import { paint } from "./tokens.ts";
32
+
33
+ /** Rows the body shows when the terminal height is unknown. */
34
+ const SCREEN_DEFAULT_ROWS = 14;
35
+
36
+ /** Fixed rows around the list body: header, summary, blank, blank, hint, blank. */
37
+ const LIST_CHROME_ROWS = 6;
38
+
39
+ /** Fixed rows around a detail body: header, subtitle, blank, blank, hint, blank. */
40
+ const DETAIL_CHROME_ROWS = 6;
41
+
42
+ /** What a detail pane shows: its title, lines, and the one-line subtitle. */
43
+ export interface DetailContents {
44
+ /** Pane title, e.g. `j1 build`. */
45
+ title: string;
46
+ /** The body lines, already sanitized by the caller. */
47
+ lines: string[];
48
+ /** More lines exist above the ones supplied. */
49
+ more?: boolean;
50
+ /** Subtitle under the title, e.g. the log file path. */
51
+ subtitle?: string;
52
+ /**
53
+ * Rows above the scrollable body that depend on the width: key/value facts,
54
+ * a legend, a divider. Read fresh every frame with the live width and already
55
+ * painted by the subclass, because the body's lines are built once at open and
56
+ * would keep the width they were built at. Counted against the pane's height.
57
+ */
58
+ facts?: (width: number) => string[];
59
+ }
60
+
61
+ /** What the pane can be refreshed from: the lines for one row id. */
62
+ export interface DetailSource {
63
+ lines: string[];
64
+ more?: boolean;
65
+ }
66
+
67
+ export interface DockListScreenOptions {
68
+ theme: Theme;
69
+ /** The header rule's label. */
70
+ title: string;
71
+ /** The detail pane's title when the opened row supplies none. */
72
+ detailTitle: string;
73
+ /** Empty-list line. */
74
+ emptyList: string;
75
+ /** Empty-detail line. */
76
+ emptyDetail: string;
77
+ /** Ask the host to repaint. */
78
+ requestRender(): void;
79
+ /** Close the screen. */
80
+ onClose(): void;
81
+ /** The terminal height source, for the visible-row count. */
82
+ viewportRowsSource?: ViewportRowsSource;
83
+ }
84
+
85
+ /**
86
+ * One docked list screen. Subclass it and implement the vocabulary hooks; the
87
+ * frame, the keys, the confirm layer, and the pane lifecycle are shared.
88
+ *
89
+ * `/jobs`, `/watch`, `/agents`, and `/check` are the subclasses. The optional
90
+ * members are for a screen that needs more than the basic shape, and default to
91
+ * the behavior a plain list wants: no pollable pane (`detailById`), a pane whose
92
+ * lines are fixed at open (`liveDetail`), and no verb inside the pane
93
+ * (`handleDetailVerb`).
94
+ */
95
+ export abstract class DockListScreen<T> implements Component {
96
+ /** Focus anchored to a row id; the list is re-sorted from live state each render. */
97
+ protected readonly cursor = new IdCursor();
98
+ /** The open detail pane. */
99
+ protected readonly pane = new LinePane();
100
+ private mode: "list" | "detail" = "list";
101
+ private readonly confirmState = new ConfirmState();
102
+ private feedback: string | undefined;
103
+ private detailFacts: ((width: number) => string[]) | undefined;
104
+
105
+ constructor(protected readonly options: DockListScreenOptions) {}
106
+
107
+ /** The rows to show, already ordered. Re-read on every render. */
108
+ protected abstract rows(): readonly T[];
109
+ /** A row's stable id, for the cursor and the pane. */
110
+ protected abstract idOf(row: T): string;
111
+ /** One list line for `row`. */
112
+ protected abstract rowLine(row: T, width: number, selected: boolean): string;
113
+ /** The focus block under the list: one entry per line, unpainted. */
114
+ protected abstract detailBlock(row: T): string[];
115
+ /** The summary cells, painted by the subclass and joined by the base. */
116
+ protected abstract summaryCells(rows: readonly T[]): string[];
117
+ /** Footer hints while listing. */
118
+ protected abstract listHints(rows: readonly T[]): ScreenHint[];
119
+ /** Footer hints while reading a detail pane. */
120
+ protected abstract detailHints(row: T | undefined): ScreenHint[];
121
+ /** What to open for `row`, fetched fresh. */
122
+ protected abstract detailFor(row: T): DetailContents;
123
+ /** The lines to refresh an already-open pane with; omit when nothing polls it. */
124
+ protected detailById?(id: string): DetailSource | undefined;
125
+ /** Whether the open pane must re-read its source on every frame. */
126
+ protected liveDetail(): boolean {
127
+ return false;
128
+ }
129
+ /** A verb inside the open pane; returns whether it consumed the key. */
130
+ protected handleDetailVerb?(key: string, row: T): boolean;
131
+ /** A list-mode verb other than navigation, open, and close. */
132
+ protected abstract handleVerb(key: string, focused: T | undefined): void;
133
+
134
+ /** Paint through the theme. */
135
+ protected get theme(): Theme {
136
+ return this.options.theme;
137
+ }
138
+
139
+ /** The row under the cursor, if any. */
140
+ protected get focused(): T | undefined {
141
+ const rows = this.rows();
142
+ return rows[this.cursor.indexOf(rows, (row) => this.idOf(row))];
143
+ }
144
+
145
+ /** The row whose pane is open, while it is still in the list. */
146
+ protected get detailRow(): T | undefined {
147
+ const id = this.pane.id;
148
+ if (!id) return undefined;
149
+ return this.rows().find((row) => this.idOf(row) === id);
150
+ }
151
+
152
+ /** Ask for a y/N confirmation before `accept` runs. */
153
+ protected confirm(prompt: string, accept: () => void): void {
154
+ this.confirmState.ask(prompt, accept);
155
+ this.options.requestRender();
156
+ }
157
+
158
+ /** Show one line of feedback in place of the hints. */
159
+ protected notify(text: string): void {
160
+ this.feedback = text;
161
+ this.options.requestRender();
162
+ }
163
+
164
+ /** Re-clamp the focus after a mutation removed the selected row. */
165
+ protected resort(): void {
166
+ this.cursor.clamp(this.rows(), (row) => this.idOf(row));
167
+ this.options.requestRender();
168
+ }
169
+
170
+ invalidate(): void {}
171
+
172
+ /** Replace the open detail pane's contents. */
173
+ setDetail(contents: DetailContents): void {
174
+ // Facts stay with the pane unless this update supplies its own, so a poll that
175
+ // only refreshes the body cannot drop the facts the row opened with.
176
+ if (contents.facts) this.detailFacts = contents.facts;
177
+ const changed =
178
+ contents.subtitle === undefined
179
+ ? this.pane.update({ title: contents.title, lines: contents.lines, more: contents.more })
180
+ : this.pane.update({
181
+ title: contents.title,
182
+ lines: contents.lines,
183
+ more: contents.more,
184
+ subtitle: contents.subtitle,
185
+ });
186
+ if (changed) this.options.requestRender();
187
+ }
188
+
189
+ /** The row id whose detail is open, or undefined in list mode. */
190
+ currentDetailId(): string | undefined {
191
+ return this.mode === "detail" && this.pane.id ? this.pane.id : undefined;
192
+ }
193
+
194
+ /** Re-read the open detail pane, so a poll cannot show stale lines. */
195
+ refreshDetail(id: string): void {
196
+ const source = this.detailById?.(id);
197
+ if (source && this.pane.update({ lines: source.lines, more: source.more })) this.options.requestRender();
198
+ }
199
+
200
+ handleInput(data: string): void {
201
+ if (this.confirmState.pending) {
202
+ this.handleConfirmKey(data);
203
+ return;
204
+ }
205
+ if (this.mode === "list") this.handleListKey(data);
206
+ else this.handleDetailKey(data);
207
+ }
208
+
209
+ /** Wheel scrolling moves the selection, or scrolls the open pane. */
210
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
211
+ const delta = wheelDelta(event);
212
+ if (delta === undefined) return undefined;
213
+ if (this.confirmState.pending) return { handled: true };
214
+ if (this.mode === "list") {
215
+ const rows = this.rows();
216
+ this.select(this.cursor.indexOf(rows, (row) => this.idOf(row)) + delta);
217
+ } else {
218
+ this.pane.setScroll(this.pane.scroll + delta);
219
+ this.options.requestRender();
220
+ }
221
+ return { handled: true };
222
+ }
223
+
224
+ private select(next: number): void {
225
+ const rows = this.rows();
226
+ if (rows.length === 0) return;
227
+ this.cursor.select(rows, (row) => this.idOf(row), next);
228
+ this.options.requestRender();
229
+ }
230
+
231
+ private openDetail(): void {
232
+ const row = this.focused;
233
+ if (!row) return;
234
+ const contents = this.detailFor(row);
235
+ this.mode = "detail";
236
+ this.detailFacts = contents.facts;
237
+ this.pane.open(this.idOf(row), contents);
238
+ this.options.requestRender();
239
+ }
240
+
241
+ private handleListKey(data: string): void {
242
+ if (isCloseKey(data) || data === "q") {
243
+ this.options.onClose();
244
+ return;
245
+ }
246
+ // Any other key dismisses a prior notice, so it does not linger.
247
+ if (this.feedback !== undefined) {
248
+ this.feedback = undefined;
249
+ this.options.requestRender();
250
+ }
251
+ const rows = this.rows();
252
+ const index = this.cursor.indexOf(rows, (row) => this.idOf(row));
253
+ switch (navIntent(data)) {
254
+ case "up":
255
+ this.select(index - 1);
256
+ return;
257
+ case "down":
258
+ this.select(index + 1);
259
+ return;
260
+ case "pageUp":
261
+ this.select(index - this.cursor.visible);
262
+ return;
263
+ case "pageDown":
264
+ this.select(index + this.cursor.visible);
265
+ return;
266
+ case "home":
267
+ this.select(0);
268
+ return;
269
+ case "end":
270
+ this.select(rows.length - 1);
271
+ return;
272
+ }
273
+ if (matchesKey(data, Key.enter) || data === "l") {
274
+ this.openDetail();
275
+ return;
276
+ }
277
+ this.handleVerb(data, this.focused);
278
+ }
279
+
280
+ private handleConfirmKey(data: string): void {
281
+ const outcome = this.confirmState.resolve(data);
282
+ if (!outcome) return;
283
+ if (outcome === "accept") this.cursor.clamp(this.rows(), (row) => this.idOf(row));
284
+ this.options.requestRender();
285
+ }
286
+
287
+ private handleDetailKey(data: string): void {
288
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.backspace) || data === "q") {
289
+ this.mode = "list";
290
+ this.pane.close();
291
+ this.detailFacts = undefined;
292
+ this.options.requestRender();
293
+ return;
294
+ }
295
+ const row = this.detailRow;
296
+ // The verb owns its repaint: it may change state the host already watches.
297
+ if (row !== undefined && this.handleDetailVerb?.(data, row) === true) return;
298
+ if (this.pane.handleKey(data)) this.options.requestRender();
299
+ }
300
+
301
+ /** The footer: a pending confirmation, one line of feedback, or the hints. */
302
+ private hint(width: number, rows: readonly T[]): string {
303
+ const pending = this.confirmState.pending;
304
+ if (pending) return clip(` ${paint(this.theme, "warn", pending.prompt)}`, width);
305
+ if (this.feedback) return clip(` ${paint(this.theme, "meta", this.feedback)}`, width);
306
+ return screenHint(this.theme, width, this.listHints(rows));
307
+ }
308
+
309
+ private summary(rows: readonly T[], width: number): string {
310
+ const separator = paint(this.theme, "meta", SEPARATORS.item);
311
+ return clip(` ${fitCells(this.summaryCells(rows), Math.max(1, width - 2), separator)}`, width);
312
+ }
313
+
314
+ render(width: number): string[] {
315
+ const w = Math.max(1, width);
316
+ return this.mode === "detail" ? this.renderDetail(w) : this.renderList(w);
317
+ }
318
+
319
+ private renderList(w: number): string[] {
320
+ const rows = this.rows();
321
+ const id = (row: T): string => this.idOf(row);
322
+ this.cursor.clamp(rows, id);
323
+ const focused = rows[this.cursor.indexOf(rows, id)];
324
+ const block = focused ? this.detailBlock(focused) : [];
325
+ this.cursor.visible = fitRows(
326
+ this.options.viewportRowsSource,
327
+ rows.length,
328
+ LIST_CHROME_ROWS + detailBlockRows(block),
329
+ SCREEN_DEFAULT_ROWS,
330
+ );
331
+ this.cursor.clamp(rows, id);
332
+ const index = this.cursor.indexOf(rows, id);
333
+ this.cursor.ensureVisible(index);
334
+
335
+ const body: string[] = [];
336
+ let range: string | undefined;
337
+ if (rows.length === 0) {
338
+ body.push(...emptyState(this.theme, w, { label: this.options.emptyList }));
339
+ } else {
340
+ const end = Math.min(rows.length, this.cursor.scroll + this.cursor.visible);
341
+ for (let i = this.cursor.scroll; i < end; i++) body.push(this.rowLine(rows[i], w, i === index));
342
+ if (this.cursor.scroll > 0 || end < rows.length) {
343
+ range = formatRange(this.theme, w, { start: this.cursor.scroll, end, total: rows.length });
344
+ }
345
+ }
346
+
347
+ return dockListFrame({
348
+ header: titleRule(this.theme, w, this.options.title),
349
+ summary: this.summary(rows, w),
350
+ body,
351
+ range,
352
+ detail: block.map((line) => clip(` ${paint(this.theme, "meta", line)}`, w)),
353
+ hint: this.hint(w, rows),
354
+ });
355
+ }
356
+
357
+ private renderDetail(w: number): string[] {
358
+ this.syncDetail();
359
+ // A live pane whose row is gone has already closed itself: draw the list.
360
+ if (this.mode !== "detail") return this.renderList(w);
361
+ const facts = this.detailFacts ? this.detailFacts(w) : [];
362
+ const visible = fitRows(
363
+ this.options.viewportRowsSource,
364
+ this.pane.count,
365
+ DETAIL_CHROME_ROWS + facts.length,
366
+ SCREEN_DEFAULT_ROWS,
367
+ );
368
+ this.pane.fit(visible);
369
+
370
+ const body: string[] = [...facts];
371
+ if (body.length > 0) body.push("");
372
+ let range: string | undefined;
373
+ if (this.pane.count === 0) {
374
+ body.push(...emptyState(this.theme, w, { label: this.options.emptyDetail }));
375
+ } else {
376
+ const { start, end } = this.pane.window();
377
+ for (let i = start; i < end; i++) body.push(clip(` ${this.pane.lines[i]}`, w));
378
+ if (start > 0 || end < this.pane.count) {
379
+ range = formatRange(this.theme, w, { start, end, total: this.pane.count, label: "line" });
380
+ }
381
+ }
382
+
383
+ return dockListFrame({
384
+ header: titleRule(this.theme, w, this.pane.title(this.options.detailTitle)),
385
+ summary: clip(` ${paint(this.theme, "faint", this.pane.subtitleLine())}`, w),
386
+ body,
387
+ range,
388
+ hint: screenHint(this.theme, w, this.detailHints(this.detailRow)),
389
+ });
390
+ }
391
+
392
+ /**
393
+ * Re-read a live pane in place. This runs inside a render, so it must not ask
394
+ * for another frame; a source that disappeared closes the pane instead of
395
+ * showing lines the list no longer has a row for.
396
+ */
397
+ private syncDetail(): void {
398
+ if (!this.liveDetail()) return;
399
+ const id = this.pane.id;
400
+ if (!id) return;
401
+ const source = this.detailById?.(id);
402
+ if (!source) {
403
+ this.mode = "list";
404
+ this.pane.close();
405
+ this.detailFacts = undefined;
406
+ return;
407
+ }
408
+ this.pane.update({ lines: source.lines, more: source.more });
409
+ }
410
+ }
package/lib/tui/glyphs.ts CHANGED
@@ -25,7 +25,7 @@ export const GLYPHS = {
25
25
  worktree: "⑂",
26
26
  rewind: "↺",
27
27
  snapshot: "⬥",
28
- plan: "⋮",
28
+ plan: "≡",
29
29
  jobsRunning: "▸",
30
30
  jobsFailure: "✗",
31
31
  stopped: "⊘",
@@ -72,6 +72,7 @@ export const STATUS_KEYS = {
72
72
  watchMatches: "watch-matches",
73
73
  browse: "browse",
74
74
  rewind: "rewind",
75
+ agents: "agents",
75
76
  wechat: "wechat",
76
77
  } as const;
77
78
 
package/lib/tui/indent.ts CHANGED
@@ -56,7 +56,7 @@ export interface IndentOptions {
56
56
  * line that is only spaces carrying a background (a `Box`'s own padding) counts
57
57
  * as blank rather than as content.
58
58
  */
59
- function isBlank(line: string): boolean {
59
+ export function isBlank(line: string): boolean {
60
60
  return stripTerminalSequences(line).trim().length === 0;
61
61
  }
62
62
 
package/lib/tui/index.ts CHANGED
@@ -19,7 +19,9 @@
19
19
 
20
20
  export * from "./chrome.ts";
21
21
  export * from "./columns.ts";
22
+ export * from "./confirm.ts";
22
23
  export * from "./cursor.ts";
24
+ export * from "./dock-screen.ts";
23
25
  export * from "./gauge.ts";
24
26
  export * from "./glyphs.ts";
25
27
  export * from "./line-pane.ts";
package/lib/tui/rows.ts CHANGED
@@ -75,6 +75,8 @@ export interface StatusRow {
75
75
  glyph: string;
76
76
  glyphRole: TuiRole;
77
77
  label: string;
78
+ /** Role for the label. Default `label`; a list accents its focused row. */
79
+ labelRole?: TuiRole;
78
80
  trailing: string;
79
81
  /** Role for the trailing meta. Default `faint`. */
80
82
  trailingRole?: TuiRole;
@@ -88,6 +90,7 @@ export function statusRow(theme: Theme, width: number, row: StatusRow): string {
88
90
  glyph: row.glyph,
89
91
  glyphRole: row.glyphRole,
90
92
  label: row.label,
93
+ labelRole: row.labelRole,
91
94
  trailing: row.trailing,
92
95
  trailingRole: row.trailingRole ?? "faint",
93
96
  });
@@ -141,3 +144,21 @@ export function emptyState(theme: Theme, width: number, state: EmptyState): stri
141
144
  if (state.hint) lines.push(clip(`${indent}${paint(theme, "faint", state.hint)}`, width));
142
145
  return lines;
143
146
  }
147
+
148
+ /** A row a live list can order: it has a start time. */
149
+ export interface LiveRow {
150
+ startedAt: number;
151
+ }
152
+
153
+ /**
154
+ * Order a live list the way every screen in this package does: the active rows
155
+ * first, then the most recently started.
156
+ *
157
+ * The two accessors keep each caller's vocabulary local — a job is active when
158
+ * its status is `running`, a watcher likewise — while the ordering rule lives
159
+ * once, so the screen and the transcript cannot drift apart.
160
+ */
161
+ export function byRunningFirst<T extends LiveRow>(isRunning: (row: T) => boolean): (a: T, b: T) => number {
162
+ const rank = (row: T): number => (isRunning(row) ? 0 : 1);
163
+ return (a, b) => rank(a) - rank(b) || b.startedAt - a.startedAt;
164
+ }
@@ -36,16 +36,32 @@ export interface StatusControllerOptions {
36
36
  chips(): readonly StatusChip[];
37
37
  }
38
38
 
39
+ /** How a transient notification reads. */
40
+ export type NotifySeverity = "info" | "warning" | "error";
41
+
39
42
  /** An attach/paint/clear surface for one extension's footer chips. */
40
43
  export interface StatusController {
41
44
  /** Remember the live context; later calls use it when none is passed. */
42
45
  attach(ctx: ExtensionContext): void;
43
- /** Forget the context, for example on shutdown or reload. */
46
+ /**
47
+ * Clear the chips and forget the context, for example on shutdown or reload.
48
+ *
49
+ * A detached controller cannot paint, so leaving a stale chip behind would
50
+ * misreport a feature that is gone.
51
+ */
44
52
  detach(): void;
45
53
  /** Republish using the attached context. */
46
54
  paint(): void;
47
55
  /** Publish the chips (and attach `ctx` when given). */
48
56
  setStatus(ctx?: ExtensionContext): void;
57
+ /**
58
+ * Show a transient notification through the attached context.
59
+ *
60
+ * The chips and the alert belong to the same feature, so they share the one
61
+ * remembered context: without this, each caller re-mirrors it in a wrapper.
62
+ * A non-interactive mode has no UI, so this no-ops instead of throwing.
63
+ */
64
+ notify(text: string, severity: NotifySeverity): void;
49
65
  }
50
66
 
51
67
  /** A context's theme, or `undefined` when the UI is unavailable. */
@@ -92,16 +108,34 @@ export function createStatusController(options: StatusControllerOptions): Status
92
108
  }
93
109
  };
94
110
 
111
+ const notify = (text: string, severity: NotifySeverity): void => {
112
+ const target = ctx;
113
+ if (!target) return;
114
+ try {
115
+ target.ui.notify(text, severity);
116
+ } catch {
117
+ // UI may be unavailable in non-interactive modes.
118
+ }
119
+ };
120
+
95
121
  return {
96
122
  attach: (next) => {
97
123
  ctx = next;
98
124
  },
99
125
  detach: () => {
126
+ const target = ctx;
100
127
  ctx = undefined;
128
+ if (!target) return;
129
+ try {
130
+ for (const key of options.keys) target.ui.setStatus(key, undefined);
131
+ } catch {
132
+ // UI is already gone during shutdown.
133
+ }
101
134
  },
102
135
  paint: () => {
103
136
  setStatus();
104
137
  },
105
138
  setStatus,
139
+ notify,
106
140
  };
107
141
  }
package/lib/tui/tool.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  import type { Theme } from "@earendil-works/pi-coding-agent";
20
20
  import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
21
21
  import { formatDuration, sanitizeLine, stripControlChars } from "../format.ts";
22
- import { clipMiddle } from "./columns.ts";
22
+ import { clip, clipMiddle } from "./columns.ts";
23
23
  import { expandHint, SEPARATORS } from "./glyphs.ts";
24
24
  import { paint } from "./tokens.ts";
25
25
 
@@ -134,6 +134,64 @@ export function moreHint(theme: Theme, hidden: number, noun = "lines"): string {
134
134
  return `${paint(theme, "faint", `… ${hidden} more ${noun}`)} ${expandHint(theme)}`;
135
135
  }
136
136
 
137
+ /** `… N more` and the expand affordance, for a list whose rows are items. */
138
+ export function overflowHint(theme: Theme, hidden: number): string {
139
+ return `${paint(theme, "faint", `… ${hidden} more`)} ${expandHint(theme)}`;
140
+ }
141
+
142
+ /** `… N earlier <label>` and the expand affordance, for a bounded tail. */
143
+ export function earlierHint(theme: Theme, hidden: number, label: string): string {
144
+ return `${paint(theme, "faint", `… ${hidden} earlier ${label}`)} ${expandHint(theme)}`;
145
+ }
146
+
147
+ /** The row renderer a {@link TailResult} list uses: the extension's row grammar at a width. */
148
+ export type TailRowLine<Row> = (row: Row, width: number, theme: Theme) => string;
149
+
150
+ /** What a {@link TailResult} draws: the caller's rows plus a hidden count, or a bounded tail. */
151
+ export type TailResultInput<Row> =
152
+ | { kind: "list"; rows: readonly Row[]; more: number; rowLine: TailRowLine<Row> }
153
+ | { kind: "lines"; lines: readonly string[]; earlier: number; earlierLabel: string };
154
+
155
+ /**
156
+ * The transcript result block a tool that lists rows or tails output shares.
157
+ *
158
+ * One leading blank line per the block grammar, then either the caller's rows
159
+ * (already ordered and capped) with a "more" note, or the themed tail with an
160
+ * "earlier" note. The caller owns the row renderer, so the extension's own row
161
+ * grammar — a peer-session tag, a status glyph — stays with its vocabulary.
162
+ */
163
+ export class TailResult<Row> implements Component {
164
+ private input: TailResultInput<Row>;
165
+ private theme: Theme;
166
+
167
+ constructor(input: TailResultInput<Row>, theme: Theme) {
168
+ this.input = input;
169
+ this.theme = theme;
170
+ }
171
+
172
+ /** Update in place so the transcript can reuse this component across renders. */
173
+ setInput(input: TailResultInput<Row>, theme: Theme): void {
174
+ this.input = input;
175
+ this.theme = theme;
176
+ }
177
+
178
+ invalidate(): void {}
179
+
180
+ render(width: number): string[] {
181
+ const w = Math.max(1, width);
182
+ const lines: string[] = [""];
183
+ const input = this.input;
184
+ if (input.kind === "list") {
185
+ for (const row of input.rows) lines.push(input.rowLine(row, w, this.theme));
186
+ if (input.more > 0) lines.push(clip(` ${overflowHint(this.theme, input.more)}`, w));
187
+ return lines;
188
+ }
189
+ if (input.earlier > 0) lines.push(clip(earlierHint(this.theme, input.earlier, input.earlierLabel), w));
190
+ for (const line of input.lines) lines.push(this.theme.fg("toolOutput", clip(line, w)));
191
+ return lines;
192
+ }
193
+ }
194
+
137
195
  /** The render context fields the elapsed timer needs. */
138
196
  export interface ToolRenderClock {
139
197
  /** Pi's per-call render state, opaque here: this module only ever casts it. */
@@ -59,6 +59,12 @@ export const WEB_COMPONENTS_CSS = `.bf-brand{display:inline-flex;align-items:cen
59
59
  .bf-dialog__option{padding:var(--space-1) var(--space-4);font:var(--fs-sm) var(--font-mono);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
60
60
  .bf-dialog__option[aria-selected="true"]{background:var(--accent-soft);color:var(--accent)}
61
61
  .bf-dialog mark{background:transparent;color:var(--warn);font-weight:var(--fw-semibold)}
62
+ .bf-dialog__head{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border)}
63
+ .bf-dialog__head h2{margin:0;font-size:var(--fs-lg)}
64
+ .bf-keys{display:grid;grid-template-columns:auto 1fr;gap:8px var(--space-4);margin:0;padding:var(--space-4)}
65
+ .bf-keys dt{white-space:nowrap}
66
+ .bf-keys dd{margin:0;color:var(--muted);font-size:var(--fs-sm)}
67
+ .bf-kbd{display:inline-block;min-width:20px;padding:1px 6px;border:1px solid var(--border);border-bottom-width:2px;border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font:var(--fs-xs) var(--font-mono);text-align:center}
62
68
  .bf-tree{font-family:var(--font-mono);font-size:var(--fs-sm);overflow-x:auto}
63
69
  .bf-tree ul{list-style:none;margin:0;padding-left:14px}
64
70
  .bf-tree>ul{padding-left:0}
@@ -114,8 +120,13 @@ th[aria-sort="descending"] .bf-sort::after{content:" \\25BC";font-size:9px;color
114
120
  .bf-md pre code{background:none;border:0;padding:0}
115
121
  .bf-md blockquote{margin-left:0;padding:0 1em;border-left:3px solid var(--border);color:var(--muted)}
116
122
  .bf-md li.task{list-style:none;margin-left:-1.4em}
117
- .bf-md .md-check{color:var(--success)}
123
+ /* The boxes are drawn rather than left native: a disabled native checkbox is UA
124
+ gray in both themes, so the done state could not carry the success colour. */
125
+ .bf-md .md-check{appearance:none;-webkit-appearance:none;width:13px;height:13px;margin:0 5px 0 0;border:1px solid var(--border-strong);border-radius:3px;background:var(--canvas);vertical-align:-2px}
126
+ .bf-md .md-check:checked{border-color:var(--success);background:var(--success) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' fill='none' stroke='%23fff' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/11px 11px no-repeat}
127
+ .bf-md .md-check:disabled{opacity:1}
118
128
  .bf-md table{margin:0;border-collapse:collapse;table-layout:auto;width:auto;font-size:var(--fs-sm)}
129
+ .bf-md .md-table-frame{position:relative}
119
130
  .bf-md .md-table-wrap{overflow-x:auto;margin:0 0 1em}
120
131
  .bf-md th,.bf-md td{border:1px solid var(--border);padding:6px 10px;text-align:left;vertical-align:top;overflow-wrap:break-word}
121
132
  .bf-md th{white-space:nowrap}
@@ -126,6 +137,14 @@ th[aria-sort="descending"] .bf-sort::after{content:" \\25BC";font-size:9px;color
126
137
  .bf-md .md-right{text-align:right}
127
138
  .bf-md img{max-width:100%;height:auto;border-radius:var(--radius-sm)}
128
139
  .bf-md hr{border:0;border-top:1px solid var(--border);margin:1.4em 0}
140
+ .bf-md details{margin:0 0 1em}
141
+ .bf-md summary{cursor:pointer;font-weight:var(--fw-medium)}
142
+ .bf-md figure{margin:0 0 1em}
143
+ .bf-md figcaption{color:var(--muted);font-size:var(--fs-sm)}
144
+ .bf-md kbd{font-family:var(--font-mono);font-size:.85em;background:var(--canvas);border:1px solid var(--border);border-bottom-width:2px;border-radius:var(--radius-sm);padding:.1em .35em}
145
+ .bf-md mark{background:var(--accent-soft);color:inherit;padding:0 .1em}
146
+ .bf-md abbr[title]{text-decoration:underline dotted;cursor:help}
147
+ .bf-md sub,.bf-md sup{font-size:.75em;line-height:0}
129
148
  .bf-comment{padding:var(--space-3) var(--space-4);background:var(--surface);border:1px solid var(--border);border-left:3px solid var(--warn);border-radius:var(--radius-md)}
130
149
  .bf-comment__quote{margin-bottom:var(--space-1);color:var(--muted);font-size:var(--fs-xs);overflow-wrap:anywhere}
131
150
  .bf-comment__body{white-space:pre-wrap;overflow-wrap:anywhere}