@gavin-hu/my-pi-agent 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +35 -8
  2. package/README.zh-CN.md +33 -8
  3. package/assets/bytefly.schema.json +17 -1
  4. package/assets/chrome.png +0 -0
  5. package/assets/file-browse.png +0 -0
  6. package/assets/job.png +0 -0
  7. package/assets/plan-review.png +0 -0
  8. package/assets/question.png +0 -0
  9. package/assets/rewind.png +0 -0
  10. package/assets/todo.png +0 -0
  11. package/assets/tool-renderings.png +0 -0
  12. package/assets/tools/ask_user_question.png +0 -0
  13. package/assets/tools/bash.png +0 -0
  14. package/assets/tools/edit.png +0 -0
  15. package/assets/tools/enter_plan_mode.png +0 -0
  16. package/assets/tools/enter_worktree.png +0 -0
  17. package/assets/tools/exit_plan_mode.png +0 -0
  18. package/assets/tools/exit_worktree.png +0 -0
  19. package/assets/tools/find.png +0 -0
  20. package/assets/tools/goal.png +0 -0
  21. package/assets/tools/grep.png +0 -0
  22. package/assets/tools/job.png +0 -0
  23. package/assets/tools/list_worktrees.png +0 -0
  24. package/assets/tools/ls.png +0 -0
  25. package/assets/tools/memory.png +0 -0
  26. package/assets/tools/notice.png +0 -0
  27. package/assets/tools/open_path.png +0 -0
  28. package/assets/tools/prune_worktrees.png +0 -0
  29. package/assets/tools/read.png +0 -0
  30. package/assets/tools/read_doc.png +0 -0
  31. package/assets/tools/run_doctor.png +0 -0
  32. package/assets/tools/send_wechat.png +0 -0
  33. package/assets/tools/set_plan_steps.png +0 -0
  34. package/assets/tools/subagent.png +0 -0
  35. package/assets/tools/todo.png +0 -0
  36. package/assets/tools/turn-rule.png +0 -0
  37. package/assets/tools/user-message.png +0 -0
  38. package/assets/tools/watch.png +0 -0
  39. package/assets/tools/web_fetch.png +0 -0
  40. package/assets/tools/web_search.png +0 -0
  41. package/assets/tools/write.png +0 -0
  42. package/assets/tools/write_plan.png +0 -0
  43. package/extensions/bytefly/README.md +306 -68
  44. package/extensions/bytefly/block/component.ts +282 -0
  45. package/extensions/bytefly/block/config.ts +24 -0
  46. package/extensions/bytefly/block/fallback.ts +120 -0
  47. package/extensions/bytefly/block/register.ts +164 -0
  48. package/extensions/bytefly/chrome.ts +97 -0
  49. package/extensions/bytefly/doctor/format.ts +36 -10
  50. package/extensions/bytefly/doctor/tools.ts +10 -6
  51. package/extensions/bytefly/footer/config.ts +1 -1
  52. package/extensions/bytefly/footer/render/format.ts +5 -18
  53. package/extensions/bytefly/footer/render/layout.ts +2 -1
  54. package/extensions/bytefly/footer/render/segments.ts +51 -78
  55. package/extensions/bytefly/header/component.ts +49 -21
  56. package/extensions/bytefly/header/config.ts +28 -21
  57. package/extensions/bytefly/header/register.ts +6 -1
  58. package/extensions/bytefly/index.ts +32 -12
  59. package/extensions/bytefly/message/config.ts +15 -0
  60. package/extensions/bytefly/message/format.ts +42 -0
  61. package/extensions/bytefly/message/register.ts +15 -0
  62. package/extensions/bytefly/notice/component.ts +107 -0
  63. package/extensions/bytefly/notice/config.ts +41 -0
  64. package/extensions/bytefly/notice/register.ts +27 -0
  65. package/extensions/bytefly/pad.ts +42 -0
  66. package/extensions/bytefly/panel/commands.ts +69 -35
  67. package/extensions/bytefly/panel/format.ts +8 -8
  68. package/extensions/bytefly/panel/tui.ts +45 -70
  69. package/extensions/bytefly/separator/component.ts +2 -1
  70. package/extensions/bytefly/separator/config.ts +16 -8
  71. package/extensions/bytefly/separator/format.ts +18 -38
  72. package/extensions/bytefly/separator/register.ts +3 -2
  73. package/extensions/bytefly/separator/types.ts +0 -10
  74. package/extensions/bytefly/working/config.ts +20 -0
  75. package/extensions/bytefly/working/format.ts +25 -0
  76. package/extensions/bytefly/working/register.ts +88 -0
  77. package/extensions/document/README.md +12 -5
  78. package/extensions/document/paging.ts +10 -10
  79. package/extensions/document/render.ts +17 -16
  80. package/extensions/document/tool.ts +1 -1
  81. package/extensions/file-browse/README.md +54 -10
  82. package/extensions/file-browse/client.ts +110 -2
  83. package/extensions/file-browse/config.ts +12 -0
  84. package/extensions/file-browse/diff.ts +187 -0
  85. package/extensions/file-browse/git.ts +180 -8
  86. package/extensions/file-browse/html.ts +401 -25
  87. package/extensions/file-browse/index.ts +15 -4
  88. package/extensions/file-browse/markdown.ts +33 -0
  89. package/extensions/file-browse/router.ts +94 -2
  90. package/extensions/file-browse/server.ts +4 -1
  91. package/extensions/file-open/README.md +6 -0
  92. package/extensions/goal/README.md +16 -10
  93. package/extensions/goal/format.ts +13 -25
  94. package/extensions/goal/tools.ts +11 -22
  95. package/extensions/goal/tui.ts +12 -40
  96. package/extensions/job/README.md +22 -13
  97. package/extensions/job/format.ts +36 -13
  98. package/extensions/job/index.ts +26 -21
  99. package/extensions/job/tools.ts +15 -12
  100. package/extensions/job/tui.ts +148 -222
  101. package/extensions/job/ui.ts +24 -49
  102. package/extensions/memory/README.md +7 -1
  103. package/extensions/memory/format.ts +5 -3
  104. package/extensions/memory/tools.ts +4 -3
  105. package/extensions/memory/tui.ts +32 -36
  106. package/extensions/plan/README.md +86 -36
  107. package/extensions/plan/blocks.ts +44 -2
  108. package/extensions/plan/feedback.ts +20 -5
  109. package/extensions/plan/index.ts +1 -1
  110. package/extensions/plan/runtime.ts +3 -2
  111. package/extensions/plan/tools.ts +43 -27
  112. package/extensions/plan/tui.ts +21 -27
  113. package/extensions/plan/web-client.ts +386 -40
  114. package/extensions/plan/web-page.ts +138 -33
  115. package/extensions/plan/web-router.ts +46 -13
  116. package/extensions/plan/web.ts +63 -12
  117. package/extensions/question/README.md +14 -2
  118. package/extensions/question/answers.ts +25 -18
  119. package/extensions/question/tools.ts +22 -16
  120. package/extensions/question/tui.ts +132 -76
  121. package/extensions/rewind/README.md +3 -1
  122. package/extensions/rewind/format.ts +35 -2
  123. package/extensions/rewind/index.ts +3 -1
  124. package/extensions/rewind/rewind-tui.ts +66 -62
  125. package/extensions/rewind/snapshot-run.ts +1 -1
  126. package/extensions/rewind/status.ts +3 -2
  127. package/extensions/subagent/README.md +6 -0
  128. package/extensions/subagent/index.ts +1 -1
  129. package/extensions/subagent/render.ts +108 -66
  130. package/extensions/todo/README.md +20 -13
  131. package/extensions/todo/format.ts +47 -34
  132. package/extensions/todo/tools.ts +6 -4
  133. package/extensions/todo/tui.ts +46 -26
  134. package/extensions/watch/README.md +16 -8
  135. package/extensions/watch/format.ts +33 -13
  136. package/extensions/watch/index.ts +29 -24
  137. package/extensions/watch/tools.ts +16 -9
  138. package/extensions/watch/tui.ts +142 -196
  139. package/extensions/watch/ui.ts +24 -46
  140. package/extensions/web-access/README.md +16 -7
  141. package/extensions/web-access/fetch/tool.ts +12 -11
  142. package/extensions/web-access/search/tool.ts +16 -13
  143. package/extensions/wechat/README.md +8 -2
  144. package/extensions/wechat/index.ts +4 -3
  145. package/extensions/wechat/login-screen.ts +8 -5
  146. package/extensions/wechat/status-screen.ts +41 -22
  147. package/extensions/wechat/tools.ts +6 -5
  148. package/extensions/worktree/README.md +6 -0
  149. package/extensions/worktree/enter.ts +1 -1
  150. package/extensions/worktree/index.ts +1 -1
  151. package/extensions/worktree/runtime.ts +1 -1
  152. package/lib/README.md +11 -7
  153. package/lib/format.ts +17 -0
  154. package/lib/notice.ts +87 -0
  155. package/lib/requirements.ts +2 -2
  156. package/lib/{tui.ts → tui/chrome.ts} +17 -34
  157. package/lib/tui/columns.ts +96 -0
  158. package/lib/{list-cursor.ts → tui/cursor.ts} +58 -4
  159. package/lib/tui/gauge.ts +78 -0
  160. package/lib/{ui.ts → tui/glyphs.ts} +10 -11
  161. package/lib/tui/indent.ts +166 -0
  162. package/lib/tui/index.ts +27 -0
  163. package/lib/tui/line-pane.ts +173 -0
  164. package/lib/tui/rows.ts +143 -0
  165. package/lib/tui/rules.ts +94 -0
  166. package/lib/tui/status-controller.ts +107 -0
  167. package/lib/tui/tokens.ts +72 -0
  168. package/lib/web/base.ts +2 -1
  169. package/lib/web/components.ts +3 -1
  170. package/lib/web/layout.ts +5 -1
  171. package/package.json +7 -3
  172. package/themes/bytefly-dark.json +7 -8
  173. package/themes/bytefly-light.json +10 -11
  174. package/assets/bytefly-chrome.png +0 -0
@@ -31,6 +31,22 @@ import { encodePath } from "./paths.ts";
31
31
  const HAS_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
32
32
  const SAFE_LINK_SCHEME = /^(?:https?|mailto):/i;
33
33
  const HTTP_SCHEME = /^https?:/i;
34
+ /** A GFM extended autolink: `http(s)://` or `www.` followed by a domain. */
35
+ const AUTOLINK = /^(?:https?:\/\/|www\.)[^\s<>]*/i;
36
+
37
+ /** GFM starts an autolink at the line start or after whitespace or one of `*_~(`. */
38
+ function autolinkBoundary(text: string, index: number): boolean {
39
+ return index === 0 || /[\s*_~(]/.test(text[index - 1]);
40
+ }
41
+
42
+ /** Drop the trailing punctuation GFM excludes, and an unclosed `)`. */
43
+ function trimAutolink(url: string): string {
44
+ let end = url.replace(/[.,;:!?*_~]+$/, "");
45
+ while (end.endsWith(")") && (end.match(/\(/g)?.length ?? 0) < (end.match(/\)/g)?.length ?? 0)) {
46
+ end = end.slice(0, -1).replace(/[.,;:!?*_~]+$/, "");
47
+ }
48
+ return end;
49
+ }
34
50
  /** A link reference definition: `[id]: dest "title"`. */
35
51
  const DEFINITION = /^ {0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+["'(]([^"')]*)["')])?\s*$/;
36
52
 
@@ -800,6 +816,23 @@ function renderInline(text: string, ctx: Context): string {
800
816
  }
801
817
  }
802
818
 
819
+ // GFM extended autolink: a bare `https://` or `www.` URL in running text.
820
+ if ((char === "h" || char === "H" || char === "w" || char === "W") && autolinkBoundary(text, i)) {
821
+ const match = AUTOLINK.exec(text.slice(i));
822
+ if (match) {
823
+ const label = trimAutolink(match[0]);
824
+ const dots = label.indexOf(".");
825
+ // Require a domain, so a bare scheme is not a link.
826
+ if (dots > 0 && /\.[^./]/.test(label.slice(dots))) {
827
+ const href = /^www\./i.test(label) ? `http://${label}` : label;
828
+ const external = HTTP_SCHEME.test(href) ? ` target="_blank" rel="noopener noreferrer"` : "";
829
+ out += `<a href="${escapeHtml(href)}"${external}>${escapeHtml(label)}</a>`;
830
+ i += label.length;
831
+ continue;
832
+ }
833
+ }
834
+ }
835
+
803
836
  if ((char === "*" || char === "_") && text[i + 1] === char) {
804
837
  const close = text.indexOf(char + char, i + 2);
805
838
  if (close > i + 2) {
@@ -11,7 +11,8 @@ import { stat } from "node:fs/promises";
11
11
  import { THEME_JS } from "../../lib/web/index.ts";
12
12
  import { CLIENT_JS } from "./client.ts";
13
13
  import type { ServeConfig } from "./config.ts";
14
- import type { GitStatus, GitStatusProvider } from "./git.ts";
14
+ import { changeDirs, isDiffMode, type DiffMode } from "./diff.ts";
15
+ import type { DiffList, GitDiffProvider, GitStatus, GitStatusProvider } from "./git.ts";
15
16
  import {
16
17
  buildFileIndex,
17
18
  buildTree,
@@ -25,7 +26,8 @@ import {
25
26
  } from "./files.ts";
26
27
  import { FOLDER_ICON, iconHref, languageOf } from "./icons.ts";
27
28
  import { fuzzySearch } from "./fuzzy.ts";
28
- import { renderDirectoryPage, renderErrorPage, renderFilePage } from "./html.ts";
29
+ import { renderDirectoryPage, renderDiffPage, renderErrorPage, renderFileDiffPage, renderFilePage } from "./html.ts";
30
+ import type { ChangeSummary } from "./html.ts";
29
31
  import { encodePath, HttpError, relativePath, resolveDecodedPath, resolveRequestPath } from "./paths.ts";
30
32
 
31
33
  export interface ServeContext {
@@ -33,6 +35,8 @@ export interface ServeContext {
33
35
  config: ServeConfig;
34
36
  /** Read-only git context for the page header; omitted when unavailable. */
35
37
  git?: GitStatusProvider;
38
+ /** Read-only diff provider; omitted when the diff view is unavailable. */
39
+ diff?: GitDiffProvider;
36
40
  /** Cached quick-open file index; built on demand when omitted. */
37
41
  index?: FileIndexProvider;
38
42
  }
@@ -97,6 +101,33 @@ async function gitFor(ctx: ServeContext): Promise<GitStatus | undefined> {
97
101
  }
98
102
  }
99
103
 
104
+ /** The change list for a mode, or `undefined` outside a repository. */
105
+ async function diffFor(ctx: ServeContext, mode: DiffMode) {
106
+ if (!ctx.config.gitDiff || !ctx.diff) return undefined;
107
+ try {
108
+ return await ctx.diff.list(mode);
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ }
113
+
114
+ /** The marker/totals index derived from a change list. */
115
+ function summaryOf(list: DiffList): ChangeSummary {
116
+ return {
117
+ count: list.files.length,
118
+ insertions: list.insertions,
119
+ deletions: list.deletions,
120
+ files: new Set(list.files.map((file) => file.rel)),
121
+ dirs: changeDirs(list.files),
122
+ };
123
+ }
124
+
125
+ /** The `head` change summary, for the header chip, tree markers, and file action. */
126
+ async function changed(ctx: ServeContext): Promise<ChangeSummary | undefined> {
127
+ const list = await diffFor(ctx, "head");
128
+ return list ? summaryOf(list) : undefined;
129
+ }
130
+
100
131
  async function errorResponse(
101
132
  ctx: ServeContext,
102
133
  status: number,
@@ -137,6 +168,7 @@ async function browse(ctx: ServeContext, rawRel: string): Promise<ServeResponse>
137
168
  const listing = await listDirectory(ctx.root, rel, ctx.config.maxDirEntries);
138
169
  const tree = await treeFor(ctx, rel);
139
170
  const git = await gitFor(ctx);
171
+ const changes = await changed(ctx);
140
172
  return htmlResponse(
141
173
  200,
142
174
  renderDirectoryPage({
@@ -147,6 +179,7 @@ async function browse(ctx: ServeContext, rawRel: string): Promise<ServeResponse>
147
179
  thumbnails: ctx.config.thumbnails,
148
180
  maxThumbBytes: ctx.config.maxThumbBytes,
149
181
  git,
182
+ summary: changes,
150
183
  }),
151
184
  );
152
185
  }
@@ -161,6 +194,7 @@ async function view(ctx: ServeContext, rawRel: string, raw: boolean): Promise<Se
161
194
  const file = await readFileView(ctx.root, rel, ctx.config.maxFileBytes, ctx.config.maxTextLines, ctx.config.markdown);
162
195
  const tree = await treeFor(ctx, rel);
163
196
  const git = await gitFor(ctx);
197
+ const changes = await changed(ctx);
164
198
  return htmlResponse(
165
199
  200,
166
200
  renderFilePage({
@@ -171,6 +205,59 @@ async function view(ctx: ServeContext, rawRel: string, raw: boolean): Promise<Se
171
205
  maxFileBytes: ctx.config.maxFileBytes,
172
206
  git,
173
207
  markdownRaw: raw,
208
+ changed: changes?.files.has(rel),
209
+ summary: changes,
210
+ }),
211
+ );
212
+ }
213
+
214
+ /** The change overview page for a mode. A non-repository is an empty state, not an error. */
215
+ async function diffOverview(ctx: ServeContext, mode: DiffMode): Promise<ServeResponse> {
216
+ if (!ctx.config.gitDiff || !ctx.diff) throw new HttpError(404, "The diff view is not available.");
217
+ const list = await diffFor(ctx, mode);
218
+ const tree = await treeFor(ctx, "");
219
+ const git = await gitFor(ctx);
220
+ return htmlResponse(
221
+ 200,
222
+ renderDiffPage({
223
+ rootLabel: ctx.root,
224
+ mode,
225
+ files: list?.files ?? [],
226
+ stat: list?.stat ?? "",
227
+ tree,
228
+ git,
229
+ summary: list ? summaryOf(list) : undefined,
230
+ }),
231
+ );
232
+ }
233
+
234
+ /** A single file's unified diff for a mode. */
235
+ async function diffFile(ctx: ServeContext, rawRel: string, mode: DiffMode): Promise<ServeResponse> {
236
+ if (!ctx.config.gitDiff || !ctx.diff) throw new HttpError(404, "The diff view is not available.");
237
+ const abs = await resolveRequestPath(ctx.root, rawRel);
238
+ const rel = relativePath(ctx.root, abs);
239
+ const info = await stat(abs).catch(() => undefined);
240
+ if (info?.isDirectory()) throw new HttpError(400, "That path is a folder.");
241
+ let diff: Awaited<ReturnType<GitDiffProvider["file"]>>;
242
+ try {
243
+ diff = await ctx.diff.file(rel, mode);
244
+ } catch {
245
+ diff = undefined;
246
+ }
247
+ if (!diff) throw new HttpError(404, "No diff is available for that file.");
248
+ const tree = await treeFor(ctx, rel);
249
+ const git = await gitFor(ctx);
250
+ const changes = await changed(ctx);
251
+ return htmlResponse(
252
+ 200,
253
+ renderFileDiffPage({
254
+ rootLabel: ctx.root,
255
+ rel,
256
+ mode,
257
+ diff,
258
+ tree,
259
+ git,
260
+ summary: changes,
174
261
  }),
175
262
  );
176
263
  }
@@ -276,6 +363,11 @@ export async function handleRequest(
276
363
  if (pathname === "/favicon.ico") return { status: 204, headers: { ...HTML_SECURITY }, body: "" };
277
364
  if (pathname === "/api/tree") return await treeApi(ctx, search.get("path") ?? "");
278
365
  if (pathname === "/api/files") return await filesApi(ctx, search.get("q") ?? "");
366
+ const rawMode = search.get("mode");
367
+ if (rawMode !== null && !isDiffMode(rawMode)) return errorResponse(ctx, 400, "Unknown diff mode.");
368
+ const mode: DiffMode = isDiffMode(rawMode) ? rawMode : "head";
369
+ if (pathname === "/diff" || pathname === "/diff/") return await diffOverview(ctx, mode);
370
+ if (pathname.startsWith("/diff/")) return await diffFile(ctx, pathname.slice("/diff/".length), mode);
279
371
  if (pathname === "/" || pathname === "/browse" || pathname === "/browse/") return await browse(ctx, "");
280
372
  if (pathname.startsWith("/browse/")) return await browse(ctx, pathname.slice("/browse/".length));
281
373
  if (pathname.startsWith("/view/"))
@@ -11,7 +11,7 @@ import { createReadStream, realpathSync } from "node:fs";
11
11
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
12
12
  import type { ServeConfig } from "./config.ts";
13
13
  import { createFileIndex } from "./files.ts";
14
- import type { GitStatusProvider } from "./git.ts";
14
+ import type { GitDiffProvider, GitStatusProvider } from "./git.ts";
15
15
  import { handleRequest, type ServeContext, type ServeResponse } from "./router.ts";
16
16
 
17
17
  export interface FileServer {
@@ -30,6 +30,8 @@ export interface FileServerOptions {
30
30
  config: ServeConfig;
31
31
  /** Read-only git context for the page header; omitted when unavailable. */
32
32
  git?: GitStatusProvider;
33
+ /** Read-only diff provider for the change and diff views; omitted when unavailable. */
34
+ diff?: GitDiffProvider;
33
35
  /** Override the request handler (tests). */
34
36
  handler?: typeof handleRequest;
35
37
  }
@@ -68,6 +70,7 @@ export async function createFileServer(options: FileServerOptions): Promise<File
68
70
  root,
69
71
  config: options.config,
70
72
  git: options.git,
73
+ diff: options.diff,
71
74
  index: createFileIndex(root, options.config.maxIndexEntries),
72
75
  };
73
76
  const handler = options.handler ?? handleRequest;
@@ -5,6 +5,12 @@ handler, or reveals it in the file manager. `/open` does it interactively and
5
5
  the `open_path` tool does it from a script, both resolving the path against the
6
6
  effective working directory.
7
7
 
8
+ ## Tool rendering
9
+
10
+ How the `open_path` call and its result look in the transcript — the call line as a headline and the body indented under it:
11
+
12
+ <img src="../../assets/tools/open_path.png" alt="The open_path tool's call and result, drawn by the generic fallback" width="100%">
13
+
8
14
  ## Quickstart
9
15
 
10
16
  ```bash
@@ -9,6 +9,12 @@ pi -e . # load the whole @gavin-hu/my-pi-agent packa
9
9
  pi install ./ # install the package
10
10
  ```
11
11
 
12
+ ## Tool rendering
13
+
14
+ How the `goal` call and its result look in the transcript — the call line as a headline and the body indented under it:
15
+
16
+ <img src="../../assets/tools/goal.png" alt="The goal tool's result: the call line and an indented body with the Goal header and the objective body" width="100%">
17
+
12
18
  ## What it does
13
19
 
14
20
  - Registers one model-callable tool, `goal`, that records the session
@@ -110,14 +116,13 @@ The tool works in every mode. The persistent widget requires interactive
110
116
  `/goal <prompt>` runs the loop: each model call uses a cancellable `tui` spinner
111
117
  in interactive mode and runs directly in RPC; the questions and the confirm step
112
118
  are host dialogs, forwarded by RPC. In print and JSON mode the loop is skipped
113
- and the typed text is sent. The widget is always a single line; the transcript result
114
- keeps the full glyph rail, separated from the call line by a blank line:
119
+ and the typed text is sent. The widget is always a single line; the transcript
120
+ result is the status header alone, directly under the call line that names the
121
+ objective:
115
122
 
116
123
  ```
117
124
  goal → set: Refactor the parser to support streaming input
118
-
119
- Goal · active
120
- ⊚ Refactor the parser to support streaming input and ship it with tests
125
+ Goal · active
121
126
  ```
122
127
 
123
128
  The renderer reuses the slot's component (`context.lastComponent`) rather than
@@ -205,9 +210,10 @@ depends on config.
205
210
  hide all rails and restore them on close.
206
211
  - **One-line widget, achieved hidden by default.** The rail follows the
207
212
  `todo` grammar (`Goal · active · <objective>`): accent label leads, status
208
- follows, no prefixed glyph, clipped to the terminal. The transcript keeps the
209
- multi-line `⊚` / `✓` rail. `achieved: "show"` restores the dimmed line; config
210
- only affects presentation.
213
+ follows, no prefixed glyph, clipped to the terminal. The transcript result is
214
+ the status header alone (`Goal · active`), because the call line above it
215
+ already carries the objective; it does not restate it. `achieved: "show"`
216
+ restores the dimmed widget line; config only affects presentation.
211
217
  - **Sanitize at the boundary.** `normalizeGoal` and `parseClarification` both route
212
218
  model text through `lib/format.ts`'s `sanitizeLine`, so an embedded newline
213
219
  cannot corrupt the widget and raw escapes cannot restyle the terminal.
@@ -223,9 +229,9 @@ depends on config.
223
229
  | `clarify-parse.ts` | Parse the `{ understood, goal, questions }` reply (and the plain-text fallback), normalize questions, and expose the limits (pure). |
224
230
  | `clarify.ts` | The two model calls, their cancellable loaders, the in-command questionnaire, and the confirm/edit dialog (host). |
225
231
  | `state.ts` | Branch reconstruction (pure). |
226
- | `format.ts` | Model-facing and terminal text plus the transcript `⊚` / `✓` symbols and the rail vocabulary (pure). |
232
+ | `format.ts` | Model-facing and terminal text plus the header and rail vocabulary (pure). |
227
233
  | `config.ts` | Widget config load/validation (the `goal` section of `bytefly.json`). |
228
- | `tui.ts` | The persistent goal widget and the transcript result rail. |
234
+ | `tui.ts` | The persistent goal widget and the transcript status header. |
229
235
  | `runtime.ts` | Session-scoped state and widget synchronization. |
230
236
  | `tools.ts` | `goal` tool registration and rendering. |
231
237
  | `commands.ts` | `/goal`. |
@@ -1,33 +1,21 @@
1
1
  /**
2
2
  * Model-facing and transcript text for the goal (pure).
3
3
  *
4
- * The transcript result is drawn as a glyph rail shared with `todo`: a header
5
- * line (`Goal · active`) and an indented body row led by `⊚` (active) or `✓`
6
- * (achieved), with wrapped continuation rows aligned under the text. The
7
- * persistent widget, by contrast, is a plain label-first line
8
- * (`Goal · active · <objective>`) matching the `todo` and `jobs` widgets. The
9
- * symbols and labels live here; the rail indent constants are shared with
10
- * `todo` in `lib/ui.ts`.
4
+ * The transcript result is the status header (`Goal · active`) under the call
5
+ * line, which already names the objective; the persistent widget is a plain
6
+ * label-first line (`Goal · active · <objective>`) matching the `todo` and
7
+ * `jobs` widgets. The status labels live here.
11
8
  */
12
9
 
13
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
14
11
  import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
15
- import { GLYPHS } from "../../lib/ui.ts";
12
+ import { SEPARATORS } from "../../lib/tui/glyphs.ts";
13
+ import { paint } from "../../lib/tui/tokens.ts";
16
14
  import type { Goal, GoalStatus } from "./types.ts";
17
15
 
18
- /** Active-goal glyph. */
19
- export const ACTIVE_SYMBOL = GLYPHS.target;
20
- /** Achieved-goal glyph. */
21
- export const ACHIEVED_SYMBOL = GLYPHS.check;
22
-
23
16
  /** Longest objective preview shown in the transcript call line. */
24
17
  const CALL_PREVIEW_WIDTH = 48;
25
18
 
26
- /** Themed status glyph, shared by the widget and the transcript renderer. */
27
- export function goalGlyph(goal: Goal, theme: Theme): string {
28
- return goal.status === "achieved" ? theme.fg("success", ACHIEVED_SYMBOL) : theme.fg("accent", ACTIVE_SYMBOL);
29
- }
30
-
31
19
  /** Human label for a status, e.g. `active`. */
32
20
  export function goalStatusLabel(status: GoalStatus): string {
33
21
  return status === "achieved" ? "achieved" : "active";
@@ -38,10 +26,10 @@ export function goalStatusLabel(status: GoalStatus): string {
38
26
  * the status word is dim while active, success-colored once achieved.
39
27
  */
40
28
  export function goalHeader(goal: Goal, theme: Theme): string {
41
- const label = theme.fg("accent", "Goal");
42
- const separator = theme.fg("dim", " · ");
29
+ const label = paint(theme, "accent", "Goal");
30
+ const separator = paint(theme, "faint", SEPARATORS.item);
43
31
  const word = goalStatusLabel(goal.status);
44
- const status = goal.status === "achieved" ? theme.fg("success", word) : theme.fg("dim", word);
32
+ const status = goal.status === "achieved" ? paint(theme, "ok", word) : paint(theme, "faint", word);
45
33
  return `${label}${separator}${status}`;
46
34
  }
47
35
 
@@ -52,15 +40,15 @@ export function goalHeader(goal: Goal, theme: Theme): string {
52
40
  * Callers clip it to the available width.
53
41
  */
54
42
  export function goalLine(goal: Goal, theme: Theme): string {
55
- const separator = theme.fg("dim", " · ");
43
+ const separator = paint(theme, "faint", SEPARATORS.item);
56
44
  const word = goalStatusLabel(goal.status);
57
- const status = goal.status === "achieved" ? theme.fg("success", word) : theme.fg("dim", word);
58
- return `${theme.fg("accent", "Goal")}${separator}${status}${separator}${goalObjective(goal, theme)}`;
45
+ const status = goal.status === "achieved" ? paint(theme, "ok", word) : paint(theme, "faint", word);
46
+ return `${paint(theme, "accent", "Goal")}${separator}${status}${separator}${goalObjective(goal, theme)}`;
59
47
  }
60
48
 
61
49
  /** Themed objective text: dim once achieved, normal while active. */
62
50
  export function goalObjective(goal: Goal, theme: Theme): string {
63
- return goal.status === "achieved" ? theme.fg("dim", goal.objective) : theme.fg("text", goal.objective);
51
+ return goal.status === "achieved" ? paint(theme, "faint", goal.objective) : paint(theme, "label", goal.objective);
64
52
  }
65
53
 
66
54
  /** Model-facing result text. */
@@ -8,12 +8,13 @@
8
8
  */
9
9
 
10
10
  import type { JsonValue } from "@earendil-works/pi-ai";
11
- import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { sanitizeLine } from "../../lib/format.ts";
13
13
  import { GOAL_TOOL } from "../../lib/tool-names.ts";
14
- import { reuseText, toolDetails } from "../../lib/tui.ts";
15
- import { GLYPHS } from "../../lib/ui.ts";
16
- import { formatCallText, formatGoalText, goalObjective, previewObjective } from "./format.ts";
14
+ import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
15
+ import { paint } from "../../lib/tui/tokens.ts";
16
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
17
+ import { formatCallText, formatGoalText } from "./format.ts";
17
18
  import type { GoalRuntime } from "./runtime.ts";
18
19
  import { GoalParams, GoalResult, normalizeGoal, type GoalArgs } from "./schema.ts";
19
20
  import { GoalResult as GoalResultView } from "./tui.ts";
@@ -21,9 +22,6 @@ import type { GoalAction, GoalDetails } from "./types.ts";
21
22
 
22
23
  export const TOOL_NAME = GOAL_TOOL;
23
24
 
24
- /** Longest objective shown in an unexpanded transcript result. */
25
- const RESULT_PREVIEW_WIDTH = 72;
26
-
27
25
  /** Best-effort action of a rejected call, for the error result's details. */
28
26
  function attemptedAction(args: Partial<GoalArgs>): GoalAction {
29
27
  if (args.status === "achieved") return "achieve";
@@ -31,14 +29,6 @@ function attemptedAction(args: Partial<GoalArgs>): GoalAction {
31
29
  return "set";
32
30
  }
33
31
 
34
- /** Themed body for the transcript result: the collapsed preview or the full objective. */
35
- function resultBody(goal: NonNullable<GoalDetails["goal"]>, expanded: boolean, theme: Theme): string {
36
- const text = expanded ? goal.objective : previewObjective(goal.objective, RESULT_PREVIEW_WIDTH);
37
- // Route both states through `goalObjective`, so an achieved goal is dimmed
38
- // whether it is collapsed or expanded.
39
- return goalObjective({ objective: text, status: goal.status }, theme);
40
- }
41
-
42
32
  /**
43
33
  * JSON-safe mirror of the goal for `structuredContent`.
44
34
  *
@@ -102,12 +92,12 @@ export function registerTools(pi: ExtensionAPI, runtime: GoalRuntime): void {
102
92
  const text = reuseText(context.lastComponent);
103
93
  text.setText(
104
94
  theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) +
105
- theme.fg("muted", formatCallText(args.objective, context.argsComplete, args.status)),
95
+ paint(theme, "meta", formatCallText(args.objective, context.argsComplete, args.status)),
106
96
  );
107
97
  return text;
108
98
  },
109
99
 
110
- renderResult(result, { expanded }, theme, context) {
100
+ renderResult(result, _options, theme, context) {
111
101
  const text = reuseText(context.lastComponent);
112
102
  const details = toolDetails<GoalDetails>(result.details);
113
103
  if (!details) {
@@ -116,20 +106,19 @@ export function registerTools(pi: ExtensionAPI, runtime: GoalRuntime): void {
116
106
  return text;
117
107
  }
118
108
  if (details.error) {
119
- text.setText(theme.fg("error", `Error: ${sanitizeLine(details.error)}`));
109
+ text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
120
110
  return text;
121
111
  }
122
112
  if (!details.goal) {
123
113
  // A neutral marker, deliberately not the achieved check.
124
- text.setText(theme.fg("dim", `${GLYPHS.stopped} `) + theme.fg("muted", "Cleared the goal"));
114
+ text.setText(paint(theme, "faint", `${GLYPHS.stopped} `) + paint(theme, "meta", "Cleared the goal"));
125
115
  return text;
126
116
  }
127
- const body = resultBody(details.goal, expanded, theme);
128
117
  const view =
129
118
  context.lastComponent instanceof GoalResultView
130
119
  ? context.lastComponent
131
- : new GoalResultView(details.goal, body, theme);
132
- view.update(details.goal, body, theme);
120
+ : new GoalResultView(details.goal, theme);
121
+ view.update(details.goal, theme);
133
122
  return view;
134
123
  },
135
124
  });
@@ -1,20 +1,16 @@
1
1
  /**
2
2
  * Terminal rendering for the goal.
3
3
  *
4
- * Two surfaces share one vocabulary. `goalWidgetLines` renders the persistent
5
- * above-editor widget as a single label-first rail line (`Goal · active ·
6
- * <objective>`, dimmed once achieved), matching the `todo` and `jobs` widgets.
7
- * `goalRailLines` renders the transcript result as a header (`Goal · active`)
8
- * plus the objective wrapped and led by the status glyph, with continuation rows
9
- * aligned under the text; it matches the todo transcript result's indent + glyph
10
- * grammar so the two read as a pair.
11
- * `GoalResult` wraps `goalRailLines` uncapped for the transcript.
4
+ * Two surfaces share one vocabulary. The persistent above-editor widget is a
5
+ * single label-first rail line (`Goal · active · <objective>`, dimmed once
6
+ * achieved), matching the `todo` and `jobs` widgets. The transcript result is
7
+ * the status header alone (`Goal · active`): the call line already names the
8
+ * objective, so the result only states the new status.
12
9
  */
13
10
 
14
11
  import type { Theme } from "@earendil-works/pi-coding-agent";
15
- import { truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
16
- import { BODY_INDENT, GLYPH_GAP } from "../../lib/ui.ts";
17
- import { goalGlyph, goalHeader, goalLine } from "./format.ts";
12
+ import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
13
+ import { goalHeader, goalLine } from "./format.ts";
18
14
  import type { AchievedStyle, Goal } from "./types.ts";
19
15
 
20
16
  /** Widget key used with `ctx.ui.setWidget()`. */
@@ -25,27 +21,6 @@ export interface GoalWidgetOptions {
25
21
  achieved?: AchievedStyle;
26
22
  }
27
23
 
28
- /**
29
- * Rows for the transcript goal rail: the themed header plus `body` wrapped and
30
- * led by the status glyph. Continuation rows are padded to the text column.
31
- */
32
- export function goalRailLines(goal: Goal, body: string, theme: Theme, width: number): string[] {
33
- const w = Math.max(1, width);
34
- const glyph = goalGlyph(goal, theme);
35
- const glyphWidth = visibleWidth(glyph);
36
- const textColumn = BODY_INDENT + glyphWidth + GLYPH_GAP;
37
- const inner = Math.max(1, w - textColumn);
38
-
39
- const wrapped = wrapTextWithAnsi(body, inner);
40
- const pad = " ".repeat(BODY_INDENT);
41
- const continuation = " ".repeat(textColumn);
42
- const lines = [goalHeader(goal, theme)];
43
- wrapped.forEach((line, index) => {
44
- lines.push(index === 0 ? `${pad}${glyph}${" ".repeat(GLYPH_GAP)}${line}` : `${continuation}${line}`);
45
- });
46
- return lines.map((line) => truncateToWidth(line, w, "…"));
47
- }
48
-
49
24
  /** Rows for the goal widget: a single rail line, or nothing when hidden. */
50
25
  export function goalWidgetLines(goal: Goal, theme: Theme, width: number, options: GoalWidgetOptions = {}): string[] {
51
26
  const w = Math.max(1, width);
@@ -68,30 +43,27 @@ export class GoalWidget implements Component {
68
43
  }
69
44
  }
70
45
 
71
- /** Transcript result block: the rail, uncapped, with the themed body. */
46
+ /** Transcript result block: the status header, separated from the call line. */
72
47
  export class GoalResult implements Component {
73
48
  private goal: Goal;
74
- private body: string;
75
49
  private theme: Theme;
76
50
 
77
- constructor(goal: Goal, body: string, theme: Theme) {
51
+ constructor(goal: Goal, theme: Theme) {
78
52
  this.goal = goal;
79
- this.body = body;
80
53
  this.theme = theme;
81
54
  }
82
55
 
83
56
  /** Update in place so the transcript can reuse this component across renders. */
84
- update(goal: Goal, body: string, theme: Theme): void {
57
+ update(goal: Goal, theme: Theme): void {
85
58
  this.goal = goal;
86
- this.body = body;
87
59
  this.theme = theme;
88
60
  }
89
61
 
90
62
  invalidate(): void {}
91
63
 
92
64
  render(width: number): string[] {
93
- // The leading blank line separates the rail from the call header, matching
65
+ // The leading blank line separates the header from the call line, matching
94
66
  // the built-ins' header/output gap.
95
- return ["", ...goalRailLines(this.goal, this.body, this.theme, Math.max(1, width))];
67
+ return ["", truncateToWidth(goalHeader(this.goal, this.theme), Math.max(1, width), "…")];
96
68
  }
97
69
  }
@@ -5,12 +5,20 @@ the background and manage them without blocking the turn. A job starts
5
5
  immediately and keeps running across tool calls; poll it, tail its output, wait
6
6
  for it, or kill it.
7
7
 
8
+ <img src="../../assets/job.png" alt="The /jobs screen: a summary line, the job rows with status glyphs and elapsed times, and the focused job's command, cwd, and last output line" width="100%">
9
+
8
10
  ```
9
11
  pi --extension ./extensions/job # load just this extension
10
12
  pi -e . # load the whole @gavin-hu/my-pi-agent package
11
13
  pi install ./ # install the package
12
14
  ```
13
15
 
16
+ ## Tool rendering
17
+
18
+ How the `job` call and its result look in the transcript — the call line as a headline and the body indented under it:
19
+
20
+ <img src="../../assets/tools/job.png" alt="The job tool's result: the call line and an indented body with a finished and a failed job row, each with its status glyph and elapsed time" width="100%">
21
+
14
22
  ## What it does
15
23
 
16
24
  - Registers one model-callable tool, `job`, with actions `start`, `list`,
@@ -81,7 +89,7 @@ JobDetails {
81
89
  ### Transcript
82
90
 
83
91
  The call line names the action and its target. The result is themed and reuses
84
- the slot's component: `list` draws the same row rail as `/jobs`, `logs` draws a
92
+ the slot's component: `list` draws the same rows as `/jobs`, `logs` draws a
85
93
  blank line and the sanitized tail, and a single-job action draws one outcome
86
94
  line. A `kill` that sent a signal appends it (`· SIGTERM sent`) instead of
87
95
  showing the job's pre-kill running state, a `start` badges `detached` and
@@ -89,7 +97,6 @@ showing the job's pre-kill running state, a `start` badges `detached` and
89
97
 
90
98
  ```
91
99
  job list
92
-
93
100
  ▸ j1 tests 3.2s
94
101
  ✗ j2 build 0.4s
95
102
  ```
@@ -199,8 +206,8 @@ rewritten.
199
206
  | Execution mode | `sequential` — actions share the in-memory job table. |
200
207
  | Annotations | `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true` — plan mode blocks it. |
201
208
  | `outputSchema` / `structuredContent` | None. Results return `JobDetails` in tool-result `details` only. |
202
- | State storage | One durable registry file per session, `~/.pi/agent/jobs/<projectKey>/registry-<sessionHash>.json` (keyed by the session cwd, so entering a worktree does not move it), merged from every session file on load; a dead session's records are adopted and a legacy shared `registry.json` is migrated once. Ids are reserved with an atomic `O_EXCL` `<id>.lock` before `start`, and a POSIX job's exit code is written to a per-job `.status` file. Tool results carry `JobDetails`; unreported completions arrive as hidden `job-context` messages at `before_agent_start`. |
203
- | Lifecycle | `session_start` loads and reconciles the registry and arms `onFinish` and `onComplete`; `session_shutdown` kills session-owned jobs and clears both status chips. `session_tree` repaints; `agent_start`/`agent_settled` track busy; `context` prunes stale `job-context` messages. |
209
+ | State storage | One durable registry file per session, `~/.pi/agent/jobs/<projectKey>/registry-<sessionHash>.json` (keyed by the session cwd, so entering a worktree does not move it), merged from every session file on load; a dead session's records are adopted and a legacy shared `registry.json` is migrated once. Ids are reserved with an atomic `O_EXCL` `<id>.lock` before `start`, and a POSIX job's exit code is written to a per-job `.status` file. Tool results carry `JobDetails`; unreported completions arrive as a shared transcript notice (`my-pi-agent/notice`) at `before_agent_start`. |
210
+ | Lifecycle | `session_start` loads and reconciles the registry and arms `onFinish` and `onComplete`; `session_shutdown` kills session-owned jobs and clears both status chips. `session_tree` repaints; `agent_start`/`agent_settled` track busy; `context` prunes stale notices of its own source. |
204
211
  | Event bus | Each job that reaches a terminal state is published on `my-pi-agent/job-finished` (`lib/events.ts`) with its id, label, status, exit code, and signal, from the one `settle` path every transition goes through. bytefly's notify capability subscribes there instead of importing this extension. |
205
212
 
206
213
  ## Design notes
@@ -248,11 +255,11 @@ rewritten.
248
255
  settles it (recovering an exit code from the status file, else `unknown`)
249
256
  when the pid disappears.
250
257
  - **Notify by default, wake opt-in.** A failed job raises one `ctx.ui.notify`
251
- (a clean exit is left to the list and chips). Finished jobs are also injected
252
- as a hidden `job-context` message at the next `before_agent_start`. A
253
- `wake: true` job (or `wakeOnFinish`) additionally triggers one turn when the
254
- agent is idle; auto-wake is not the default because it easily loops and burns
255
- tokens.
258
+ (a clean exit is left to the list and chips). Finished jobs are also posted
259
+ as a shared transcript notice — model-facing text plus the notice block — at the
260
+ next `before_agent_start`. A `wake: true` job (or `wakeOnFinish`) additionally
261
+ triggers one turn when the agent is idle; auto-wake is not the default because
262
+ it easily loops and burns tokens.
256
263
  - **Width-1 glyphs and cached latest line.** The running chip is `▸ N` and the
257
264
  failure chip `✗ N`, each a two-token badge under its own status key, so the
258
265
  status bar compacts each independently (`▸N` / `✗N`) and an unreported
@@ -264,8 +271,10 @@ rewritten.
264
271
  spawn/liveness/kill/clock functions, so the whole suite runs without
265
272
  launching a process.
266
273
  - **The transcript reuses the `/jobs` grammar.** `job list` renders the same
267
- `jobRow` rail as the screen (running first, capped, `… N more`), `job logs`
268
- draws a themed tail with a `… N earlier lines` note, and single-job actions
274
+ `jobRow` rows as the screen (running first, capped, `… N more`), minus the
275
+ selection-marker column (`listRow`'s `marker: false`), which only the
276
+ interactive screen needs; `job logs` draws a themed tail with a `… N earlier
277
+ lines` note, and single-job actions
269
278
  draw one `formatJobOutcomeLine` (plus a signal note on `kill` and
270
279
  `detached`/`wake` badges on `start`). A capped collapsed result ends with the
271
280
  `app.tools.expand` hint, and an expanded list shows every job. The renderers
@@ -289,10 +298,10 @@ rewritten.
289
298
  | `session.ts` | Per-session heartbeat markers and owner-liveness rules. |
290
299
  | `store.ts` | Durable store: registry dir, this session's atomic merge-write, deletions. |
291
300
  | `logs.ts` | Bounded, sanitized log-tail reading. |
292
- | `ui.ts` | Footer status chips. |
301
+ | `ui.ts` | Footer status chips, over the shared `lib/tui/status-controller.ts`. |
293
302
  | `waiters.ts` | `wait` resolver bookkeeping. |
294
303
  | `runtime.ts` | Composition: job table, handles, process events, repaint clock. |
295
- | `tui.ts` | The `/jobs` screen and the transcript `JobResult` rail. |
304
+ | `tui.ts` | The `/jobs` screen and the transcript `JobResult` rows, drawn from the `lib/tui/` kit (`titleRule`, `listRow`, `emptyState`, `IdCursor`, `LinePane`). |
296
305
  | `tools.ts` | The `job` tool and its transcript rendering. |
297
306
  | `commands.ts` | The `/jobs` command. |
298
307
  | `types.ts` | Shared types. |