@gavin-hu/my-pi-agent 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +9 -8
  2. package/README.zh-CN.md +8 -8
  3. package/assets/file-browse.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +49 -36
  6. package/extensions/bytefly/doctor/format.ts +36 -10
  7. package/extensions/bytefly/doctor/tools.ts +6 -5
  8. package/extensions/bytefly/footer/config.ts +1 -1
  9. package/extensions/bytefly/footer/render/format.ts +5 -18
  10. package/extensions/bytefly/footer/render/layout.ts +2 -1
  11. package/extensions/bytefly/footer/render/segments.ts +51 -78
  12. package/extensions/bytefly/header/component.ts +33 -16
  13. package/extensions/bytefly/header/config.ts +28 -18
  14. package/extensions/bytefly/index.ts +1 -1
  15. package/extensions/bytefly/panel/commands.ts +69 -35
  16. package/extensions/bytefly/panel/format.ts +8 -8
  17. package/extensions/bytefly/panel/tui.ts +45 -70
  18. package/extensions/bytefly/separator/config.ts +16 -8
  19. package/extensions/bytefly/separator/format.ts +13 -40
  20. package/extensions/bytefly/separator/types.ts +0 -10
  21. package/extensions/document/README.md +1 -1
  22. package/extensions/document/render.ts +11 -10
  23. package/extensions/document/tool.ts +1 -1
  24. package/extensions/file-browse/README.md +62 -14
  25. package/extensions/file-browse/client.ts +103 -2
  26. package/extensions/file-browse/config.ts +12 -0
  27. package/extensions/file-browse/diff.ts +187 -0
  28. package/extensions/file-browse/git.ts +176 -1
  29. package/extensions/file-browse/html.ts +412 -23
  30. package/extensions/file-browse/index.ts +15 -4
  31. package/extensions/file-browse/markdown.ts +98 -5
  32. package/extensions/file-browse/router.ts +94 -2
  33. package/extensions/file-browse/server.ts +4 -1
  34. package/extensions/goal/format.ts +11 -10
  35. package/extensions/goal/tools.ts +6 -5
  36. package/extensions/goal/tui.ts +1 -1
  37. package/extensions/job/README.md +2 -2
  38. package/extensions/job/format.ts +36 -13
  39. package/extensions/job/index.ts +1 -1
  40. package/extensions/job/tools.ts +11 -10
  41. package/extensions/job/tui.ts +141 -220
  42. package/extensions/job/ui.ts +24 -49
  43. package/extensions/memory/README.md +1 -1
  44. package/extensions/memory/tools.ts +4 -3
  45. package/extensions/memory/tui.ts +32 -36
  46. package/extensions/plan/README.md +64 -34
  47. package/extensions/plan/blocks.ts +44 -2
  48. package/extensions/plan/feedback.ts +20 -5
  49. package/extensions/plan/index.ts +1 -1
  50. package/extensions/plan/runtime.ts +3 -2
  51. package/extensions/plan/tools.ts +23 -22
  52. package/extensions/plan/tui.ts +21 -27
  53. package/extensions/plan/web-client.ts +335 -30
  54. package/extensions/plan/web-page.ts +110 -30
  55. package/extensions/plan/web-router.ts +6 -4
  56. package/extensions/plan/web.ts +41 -10
  57. package/extensions/question/README.md +1 -1
  58. package/extensions/question/tools.ts +12 -11
  59. package/extensions/question/tui.ts +132 -76
  60. package/extensions/rewind/README.md +1 -1
  61. package/extensions/rewind/format.ts +29 -1
  62. package/extensions/rewind/rewind-tui.ts +66 -62
  63. package/extensions/rewind/status.ts +3 -2
  64. package/extensions/subagent/render.ts +62 -47
  65. package/extensions/todo/README.md +2 -2
  66. package/extensions/todo/format.ts +31 -10
  67. package/extensions/todo/tools.ts +5 -4
  68. package/extensions/todo/tui.ts +45 -26
  69. package/extensions/watch/README.md +2 -2
  70. package/extensions/watch/format.ts +33 -13
  71. package/extensions/watch/index.ts +1 -1
  72. package/extensions/watch/tools.ts +12 -7
  73. package/extensions/watch/tui.ts +135 -194
  74. package/extensions/watch/ui.ts +24 -46
  75. package/extensions/web-access/fetch/tool.ts +12 -11
  76. package/extensions/web-access/search/tool.ts +16 -13
  77. package/extensions/wechat/README.md +2 -2
  78. package/extensions/wechat/index.ts +4 -3
  79. package/extensions/wechat/login-screen.ts +8 -5
  80. package/extensions/wechat/status-screen.ts +41 -22
  81. package/extensions/wechat/tools.ts +6 -5
  82. package/extensions/worktree/enter.ts +1 -1
  83. package/extensions/worktree/index.ts +1 -1
  84. package/extensions/worktree/runtime.ts +1 -1
  85. package/lib/README.md +10 -7
  86. package/lib/requirements.ts +2 -2
  87. package/lib/{tui.ts → tui/chrome.ts} +9 -30
  88. package/lib/tui/columns.ts +96 -0
  89. package/lib/{list-cursor.ts → tui/cursor.ts} +56 -3
  90. package/lib/tui/gauge.ts +78 -0
  91. package/lib/tui/index.ts +25 -0
  92. package/lib/tui/line-pane.ts +173 -0
  93. package/lib/tui/rows.ts +109 -0
  94. package/lib/tui/rules.ts +88 -0
  95. package/lib/tui/status-controller.ts +107 -0
  96. package/lib/tui/tokens.ts +58 -0
  97. package/package.json +2 -2
  98. /package/lib/{ui.ts → tui/glyphs.ts} +0 -0
@@ -14,6 +14,10 @@
14
14
  * External images are rendered as links (the page CSP is `img-src 'self' data:`)
15
15
  * and non-http(s) schemes such as `javascript:` are dropped.
16
16
  *
17
+ * Headings get a stable anchor id and are demoted one level so the embedding
18
+ * page keeps the only `h1`; `renderMarkdownDocument` returns them for an
19
+ * outline.
20
+ *
17
21
  * Kept free of the page renderer: it imports only `lib/escape.ts`,
18
22
  * `lib/highlight.ts`, and `paths.ts`, so `html.ts` can compose it without a
19
23
  * module cycle.
@@ -27,6 +31,22 @@ import { encodePath } from "./paths.ts";
27
31
  const HAS_SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
28
32
  const SAFE_LINK_SCHEME = /^(?:https?|mailto):/i;
29
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
+ }
30
50
  /** A link reference definition: `[id]: dest "title"`. */
31
51
  const DEFINITION = /^ {0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+["'(]([^"')]*)["')])?\s*$/;
32
52
 
@@ -35,16 +55,69 @@ interface LinkDefinition {
35
55
  title?: string;
36
56
  }
37
57
 
38
- /** Per-document render state: the resolution base and the reference definitions. */
58
+ /** A heading the renderer emitted, in document order. */
59
+ export interface MarkdownHeading {
60
+ /** The rendered tag level, already demoted and capped at `h6`. */
61
+ level: number;
62
+ /** The anchor id, unique within the document. */
63
+ id: string;
64
+ /** The heading's raw inline Markdown. */
65
+ text: string;
66
+ }
67
+
68
+ /**
69
+ * Per-document render state: the resolution base, the reference definitions,
70
+ * and the headings emitted so far.
71
+ */
39
72
  interface Context {
40
73
  baseDir: string;
41
74
  defs: Map<string, LinkDefinition>;
75
+ headings: MarkdownHeading[];
76
+ slug(text: string): string;
77
+ }
78
+
79
+ /**
80
+ * Render a Markdown source string to a safe HTML fragment, plus the headings it
81
+ * emitted so a caller can build an outline. Headings carry an anchor id and are
82
+ * demoted one level, so the embedding page keeps the only `h1`.
83
+ */
84
+ export function renderMarkdownDocument(source: string, baseDir = ""): { html: string; headings: MarkdownHeading[] } {
85
+ const { defs, body } = collectDefinitions(source.split(/\r\n|\r|\n/));
86
+ const context = createContext(baseDir, defs);
87
+ return { html: renderBlocks(body, context), headings: context.headings };
42
88
  }
43
89
 
44
90
  /** Render a Markdown source string to a safe HTML fragment. */
45
91
  export function renderMarkdown(source: string, baseDir = ""): string {
46
- const { defs, body } = collectDefinitions(source.split(/\r\n|\r|\n/));
47
- return renderBlocks(body, { baseDir, defs });
92
+ return renderMarkdownDocument(source, baseDir).html;
93
+ }
94
+
95
+ /** Turn heading text into a stable anchor id, dropping Markdown punctuation. */
96
+ function headingSlug(text: string): string {
97
+ const plain = text
98
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
99
+ .replace(/[`*_~]/g, "")
100
+ .toLowerCase()
101
+ .replace(/[^\p{L}\p{N}\s-]/gu, "")
102
+ .replace(/[\s-]+/g, "-")
103
+ .replace(/^-+|-+$/g, "");
104
+ return plain || "section";
105
+ }
106
+
107
+ /** Build the per-document render state, suffixing repeated heading slugs. */
108
+ function createContext(baseDir: string, defs: Map<string, LinkDefinition>): Context {
109
+ const seen = new Map<string, number>();
110
+ return {
111
+ baseDir,
112
+ defs,
113
+ headings: [],
114
+ slug(text: string): string {
115
+ const base = headingSlug(text);
116
+ const count = seen.get(base) ?? 0;
117
+ seen.set(base, count + 1);
118
+ return count === 0 ? base : `${base}-${count}`;
119
+ },
120
+ };
48
121
  }
49
122
 
50
123
  /** Fold a reference label the way CommonMark does: trim, collapse, case-fold. */
@@ -135,7 +208,10 @@ function renderBlocks(lines: string[], ctx: Context): string {
135
208
 
136
209
  const heading = matchHeading(line);
137
210
  if (heading) {
138
- out.push(`<h${heading.level}>${renderInline(heading.text, ctx)}</h${heading.level}>`);
211
+ const level = Math.min(heading.level + 1, 6);
212
+ const id = ctx.slug(heading.text);
213
+ ctx.headings.push({ level, id, text: heading.text });
214
+ out.push(`<h${level} id="${id}">${renderInline(heading.text, ctx)}</h${level}>`);
139
215
  i++;
140
216
  continue;
141
217
  }
@@ -403,7 +479,7 @@ function renderListItem(contentLines: string[], ctx: Context): string {
403
479
  const tight = !source.some(isBlank);
404
480
  let body = renderBlocks(source, ctx);
405
481
  if (tight) body = body.replace(/<\/?p>/g, "");
406
- const mark = task ? `<span class="md-check" aria-hidden="true">${done ? "☑" : "☐"}</span> ` : "";
482
+ const mark = task ? `<input class="md-check" type="checkbox" disabled${done ? " checked" : ""}> ` : "";
407
483
  return `<li${task ? (done ? ` class="task done"` : ` class="task"`) : ""}>${mark}${body}</li>`;
408
484
  }
409
485
 
@@ -740,6 +816,23 @@ function renderInline(text: string, ctx: Context): string {
740
816
  }
741
817
  }
742
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
+
743
836
  if ((char === "*" || char === "_") && text[i + 1] === char) {
744
837
  const close = text.indexOf(char + char, i + 2);
745
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;
@@ -7,12 +7,13 @@
7
7
  * persistent widget, by contrast, is a plain label-first line
8
8
  * (`Goal · active · <objective>`) matching the `todo` and `jobs` widgets. The
9
9
  * symbols and labels live here; the rail indent constants are shared with
10
- * `todo` in `lib/ui.ts`.
10
+ * `todo` in `lib/tui/glyphs.ts`.
11
11
  */
12
12
 
13
13
  import type { Theme } from "@earendil-works/pi-coding-agent";
14
14
  import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
15
- import { GLYPHS } from "../../lib/ui.ts";
15
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
16
+ import { paint } from "../../lib/tui/tokens.ts";
16
17
  import type { Goal, GoalStatus } from "./types.ts";
17
18
 
18
19
  /** Active-goal glyph. */
@@ -25,7 +26,7 @@ const CALL_PREVIEW_WIDTH = 48;
25
26
 
26
27
  /** Themed status glyph, shared by the widget and the transcript renderer. */
27
28
  export function goalGlyph(goal: Goal, theme: Theme): string {
28
- return goal.status === "achieved" ? theme.fg("success", ACHIEVED_SYMBOL) : theme.fg("accent", ACTIVE_SYMBOL);
29
+ return goal.status === "achieved" ? paint(theme, "ok", ACHIEVED_SYMBOL) : paint(theme, "accent", ACTIVE_SYMBOL);
29
30
  }
30
31
 
31
32
  /** Human label for a status, e.g. `active`. */
@@ -38,10 +39,10 @@ export function goalStatusLabel(status: GoalStatus): string {
38
39
  * the status word is dim while active, success-colored once achieved.
39
40
  */
40
41
  export function goalHeader(goal: Goal, theme: Theme): string {
41
- const label = theme.fg("accent", "Goal");
42
- const separator = theme.fg("dim", " · ");
42
+ const label = paint(theme, "accent", "Goal");
43
+ const separator = paint(theme, "faint", " · ");
43
44
  const word = goalStatusLabel(goal.status);
44
- const status = goal.status === "achieved" ? theme.fg("success", word) : theme.fg("dim", word);
45
+ const status = goal.status === "achieved" ? paint(theme, "ok", word) : paint(theme, "faint", word);
45
46
  return `${label}${separator}${status}`;
46
47
  }
47
48
 
@@ -52,15 +53,15 @@ export function goalHeader(goal: Goal, theme: Theme): string {
52
53
  * Callers clip it to the available width.
53
54
  */
54
55
  export function goalLine(goal: Goal, theme: Theme): string {
55
- const separator = theme.fg("dim", " · ");
56
+ const separator = paint(theme, "faint", " · ");
56
57
  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)}`;
58
+ const status = goal.status === "achieved" ? paint(theme, "ok", word) : paint(theme, "faint", word);
59
+ return `${paint(theme, "accent", "Goal")}${separator}${status}${separator}${goalObjective(goal, theme)}`;
59
60
  }
60
61
 
61
62
  /** Themed objective text: dim once achieved, normal while active. */
62
63
  export function goalObjective(goal: Goal, theme: Theme): string {
63
- return goal.status === "achieved" ? theme.fg("dim", goal.objective) : theme.fg("text", goal.objective);
64
+ return goal.status === "achieved" ? paint(theme, "faint", goal.objective) : paint(theme, "label", goal.objective);
64
65
  }
65
66
 
66
67
  /** Model-facing result text. */
@@ -11,8 +11,9 @@ import type { JsonValue } from "@earendil-works/pi-ai";
11
11
  import type { ExtensionAPI, Theme } 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";
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";
16
17
  import { formatCallText, formatGoalText, goalObjective, previewObjective } from "./format.ts";
17
18
  import type { GoalRuntime } from "./runtime.ts";
18
19
  import { GoalParams, GoalResult, normalizeGoal, type GoalArgs } from "./schema.ts";
@@ -102,7 +103,7 @@ export function registerTools(pi: ExtensionAPI, runtime: GoalRuntime): void {
102
103
  const text = reuseText(context.lastComponent);
103
104
  text.setText(
104
105
  theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) +
105
- theme.fg("muted", formatCallText(args.objective, context.argsComplete, args.status)),
106
+ paint(theme, "meta", formatCallText(args.objective, context.argsComplete, args.status)),
106
107
  );
107
108
  return text;
108
109
  },
@@ -116,12 +117,12 @@ export function registerTools(pi: ExtensionAPI, runtime: GoalRuntime): void {
116
117
  return text;
117
118
  }
118
119
  if (details.error) {
119
- text.setText(theme.fg("error", `Error: ${sanitizeLine(details.error)}`));
120
+ text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
120
121
  return text;
121
122
  }
122
123
  if (!details.goal) {
123
124
  // A neutral marker, deliberately not the achieved check.
124
- text.setText(theme.fg("dim", `${GLYPHS.stopped} `) + theme.fg("muted", "Cleared the goal"));
125
+ text.setText(paint(theme, "faint", `${GLYPHS.stopped} `) + paint(theme, "meta", "Cleared the goal"));
125
126
  return text;
126
127
  }
127
128
  const body = resultBody(details.goal, expanded, theme);
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { Theme } from "@earendil-works/pi-coding-agent";
15
15
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
16
- import { BODY_INDENT, GLYPH_GAP } from "../../lib/ui.ts";
16
+ import { BODY_INDENT, GLYPH_GAP } from "../../lib/tui/glyphs.ts";
17
17
  import { goalGlyph, goalHeader, goalLine } from "./format.ts";
18
18
  import type { AchievedStyle, Goal } from "./types.ts";
19
19
 
@@ -289,10 +289,10 @@ rewritten.
289
289
  | `session.ts` | Per-session heartbeat markers and owner-liveness rules. |
290
290
  | `store.ts` | Durable store: registry dir, this session's atomic merge-write, deletions. |
291
291
  | `logs.ts` | Bounded, sanitized log-tail reading. |
292
- | `ui.ts` | Footer status chips. |
292
+ | `ui.ts` | Footer status chips, over the shared `lib/tui/status-controller.ts`. |
293
293
  | `waiters.ts` | `wait` resolver bookkeeping. |
294
294
  | `runtime.ts` | Composition: job table, handles, process events, repaint clock. |
295
- | `tui.ts` | The `/jobs` screen and the transcript `JobResult` rail. |
295
+ | `tui.ts` | The `/jobs` screen and the transcript `JobResult` rail, drawn from the `lib/tui/` kit (`titleRule`, `listRow`, `emptyState`, `IdCursor`, `LinePane`). |
296
296
  | `tools.ts` | The `job` tool and its transcript rendering. |
297
297
  | `commands.ts` | The `/jobs` command. |
298
298
  | `types.ts` | Shared types. |
@@ -14,7 +14,8 @@
14
14
 
15
15
  import type { Theme } from "@earendil-works/pi-coding-agent";
16
16
  import { truncateToWidth } from "@earendil-works/pi-tui";
17
- import { GLYPHS } from "../../lib/ui.ts";
17
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
18
+ import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
18
19
  import type { JobRecord, JobStatus } from "./types.ts";
19
20
 
20
21
  /** Longest sanitized log line kept for display; longer lines are clipped. */
@@ -76,22 +77,43 @@ export function elapsedMs(job: JobRecord, now: number): number {
76
77
  return (job.finishedAt ?? now) - job.startedAt;
77
78
  }
78
79
 
79
- /** Width-1 themed glyph for a status. */
80
- export function statusGlyph(status: JobStatus, theme: Theme): string {
80
+ /** The design role a job status is drawn in. */
81
+ export function statusRole(status: JobStatus): TuiRole {
81
82
  switch (status) {
82
83
  case "exited":
83
- return theme.fg("success", GLYPHS.check);
84
+ return "ok";
84
85
  case "failed":
85
- return theme.fg("error", GLYPHS.jobsFailure);
86
+ return "bad";
86
87
  case "killed":
87
- return theme.fg("muted", GLYPHS.killed);
88
+ return "meta";
88
89
  case "unknown":
89
- return theme.fg("dim", GLYPHS.unknown);
90
+ return "faint";
90
91
  default:
91
- return theme.fg("accent", GLYPHS.jobsRunning);
92
+ return "accent";
92
93
  }
93
94
  }
94
95
 
96
+ /** Width-1 glyph for a status, from the shared vocabulary. */
97
+ export function statusGlyphChar(status: JobStatus): string {
98
+ switch (status) {
99
+ case "exited":
100
+ return GLYPHS.check;
101
+ case "failed":
102
+ return GLYPHS.jobsFailure;
103
+ case "killed":
104
+ return GLYPHS.killed;
105
+ case "unknown":
106
+ return GLYPHS.unknown;
107
+ default:
108
+ return GLYPHS.jobsRunning;
109
+ }
110
+ }
111
+
112
+ /** Width-1 themed glyph for a status. */
113
+ export function statusGlyph(status: JobStatus, theme: Theme): string {
114
+ return paint(theme, statusRole(status), statusGlyphChar(status));
115
+ }
116
+
95
117
  /** Short status word for text output. */
96
118
  function statusWord(job: JobRecord): string {
97
119
  if (job.status === "failed" && job.exitCode !== null) return `failed (exit ${job.exitCode})`;
@@ -229,18 +251,19 @@ export function jobOutcome(job: JobRecord): string {
229
251
 
230
252
  /** One-line outcome for a single job, themed for the transcript result. */
231
253
  export function formatJobOutcomeLine(job: JobRecord, theme: Theme, now = Date.now()): string {
232
- const sep = theme.fg("dim", " · ");
233
- const outcome = job.status === "failed" ? theme.fg("error", jobOutcome(job)) : theme.fg("dim", jobOutcome(job));
254
+ const sep = paint(theme, "faint", " · ");
255
+ const outcome =
256
+ job.status === "failed" ? paint(theme, "bad", jobOutcome(job)) : paint(theme, "faint", jobOutcome(job));
234
257
  return (
235
258
  statusGlyph(job.status, theme) +
236
259
  " " +
237
- theme.fg("accent", job.id) +
260
+ paint(theme, "accent", job.id) +
238
261
  " " +
239
- theme.fg("muted", shortLabel(job)) +
262
+ paint(theme, "meta", shortLabel(job)) +
240
263
  sep +
241
264
  outcome +
242
265
  sep +
243
- theme.fg("dim", formatDuration(elapsedMs(job, now)))
266
+ paint(theme, "faint", formatDuration(elapsedMs(job, now)))
244
267
  );
245
268
  }
246
269
 
@@ -13,7 +13,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
13
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
14
  import { isExtensionEnabled } from "../../lib/env.ts";
15
15
  import { JOB_FINISHED_CHANNEL, type JobFinishedEvent } from "../../lib/events.ts";
16
- import { STATUS_KEYS } from "../../lib/ui.ts";
16
+ import { STATUS_KEYS } from "../../lib/tui/glyphs.ts";
17
17
  import { registerCommands } from "./commands.ts";
18
18
  import { formatCompletion } from "./format.ts";
19
19
  import { createJobsRuntime, type JobsRuntime } from "./runtime.ts";
@@ -9,7 +9,8 @@
9
9
 
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import { sanitizeLine } from "../../lib/format.ts";
12
- import { reuseText, toolDetails } from "../../lib/tui.ts";
12
+ import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
13
+ import { paint } from "../../lib/tui/tokens.ts";
13
14
  import {
14
15
  compareJobs,
15
16
  formatCallText,
@@ -225,20 +226,20 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
225
226
  return text;
226
227
  }
227
228
  if (details.error) {
228
- text.setText(theme.fg("error", `Error: ${sanitizeLine(details.error)}`));
229
+ text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
229
230
  return text;
230
231
  }
231
232
 
232
233
  if (options.isPartial) {
233
234
  const id = details.job?.id;
234
- text.setText(theme.fg("warning", id ? `Waiting on ${id}…` : "Waiting…"));
235
+ text.setText(paint(theme, "warn", id ? `Waiting on ${id}…` : "Waiting…"));
235
236
  return text;
236
237
  }
237
238
 
238
239
  if (details.action === "logs" && details.logs !== undefined) {
239
240
  const all = details.logs === "" ? [] : details.logs.split("\n");
240
241
  if (all.length === 0) {
241
- text.setText(theme.fg("muted", "No output yet."));
242
+ text.setText(paint(theme, "meta", "No output yet."));
242
243
  return text;
243
244
  }
244
245
  const shown = options.expanded ? all : all.slice(Math.max(0, all.length - RESULT_ROWS));
@@ -250,7 +251,7 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
250
251
 
251
252
  if (details.action === "list" && details.jobs) {
252
253
  if (details.jobs.length === 0) {
253
- text.setText(theme.fg("muted", "No jobs."));
254
+ text.setText(paint(theme, "meta", "No jobs."));
254
255
  return text;
255
256
  }
256
257
  const ordered = [...details.jobs].sort(compareJobs);
@@ -264,7 +265,7 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
264
265
  if (details.action === "clear") {
265
266
  const count = details.cleared ?? 0;
266
267
  text.setText(
267
- theme.fg("muted", count === 0 ? "Nothing to clear." : `Cleared ${count} job${count === 1 ? "" : "s"}.`),
268
+ paint(theme, "meta", count === 0 ? "Nothing to clear." : `Cleared ${count} job${count === 1 ? "" : "s"}.`),
268
269
  );
269
270
  return text;
270
271
  }
@@ -272,16 +273,16 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
272
273
  if (details.job) {
273
274
  let line = formatJobOutcomeLine(details.job, theme);
274
275
  if (details.action === "wait" && details.timedOut) {
275
- line += theme.fg("warning", " · still running after the timeout");
276
+ line += paint(theme, "warn", " · still running after the timeout");
276
277
  } else if (details.action === "wait" && details.cancelled) {
277
- line += theme.fg("warning", " · wait cancelled; job still running");
278
+ line += paint(theme, "warn", " · wait cancelled; job still running");
278
279
  } else if (details.action === "kill" && details.signalled) {
279
- line += theme.fg("warning", ` · ${details.signal ?? "SIGTERM"} sent`);
280
+ line += paint(theme, "warn", ` · ${details.signal ?? "SIGTERM"} sent`);
280
281
  } else if (details.action === "start") {
281
282
  const badges: string[] = [];
282
283
  if (details.job.detached) badges.push("detached");
283
284
  if (details.job.wake) badges.push("wake");
284
- if (badges.length > 0) line += theme.fg("dim", ` · ${badges.join(" · ")}`);
285
+ if (badges.length > 0) line += paint(theme, "faint", ` · ${badges.join(" · ")}`);
285
286
  }
286
287
  text.setText(line);
287
288
  return text;