@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
@@ -11,8 +11,9 @@
11
11
 
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { formatTokens } from "../../../lib/format.ts";
14
- import { reuseText } from "../../../lib/tui.ts";
15
- import { SEPARATORS } from "../../../lib/ui.ts";
14
+ import { reuseText } from "../../../lib/tui/chrome.ts";
15
+ import { paint } from "../../../lib/tui/tokens.ts";
16
+ import { SEPARATORS } from "../../../lib/tui/glyphs.ts";
16
17
  import { charLength } from "../text.ts";
17
18
  import { oneLine } from "../transcript.ts";
18
19
  import { createPageCache, type PageCache } from "./cache.ts";
@@ -204,9 +205,9 @@ export function registerFetchTool(pi: ExtensionAPI, deps: FetchToolDeps = {}): v
204
205
  renderCall(args, theme, context) {
205
206
  const { url, find, startIndex } = args as WebFetchArgs;
206
207
  const text = reuseText(context?.lastComponent);
207
- let line = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + theme.fg("accent", oneLine(url ?? ""));
208
- if (find?.length) line += theme.fg("dim", ` find ${find.map((f) => `"${oneLine(f)}"`).join(", ")}`);
209
- else if (startIndex) line += theme.fg("dim", ` (from ${startIndex})`);
208
+ let line = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "accent", oneLine(url ?? ""));
209
+ if (find?.length) line += paint(theme, "faint", ` find ${find.map((f) => `"${oneLine(f)}"`).join(", ")}`);
210
+ else if (startIndex) line += paint(theme, "faint", ` (from ${startIndex})`);
210
211
  text.setText(line);
211
212
  return text;
212
213
  },
@@ -216,21 +217,21 @@ export function registerFetchTool(pi: ExtensionAPI, deps: FetchToolDeps = {}): v
216
217
  const page = result.details as FetchResponse | undefined;
217
218
  if (page?.error) {
218
219
  const message = oneLine(page.error);
219
- text.setText(theme.fg("error", message.startsWith("Error:") ? message : `Error: ${message}`));
220
+ text.setText(paint(theme, "bad", message.startsWith("Error:") ? message : `Error: ${message}`));
220
221
  return text;
221
222
  }
222
223
  if (!page || result.isError) {
223
224
  const first = result.content[0];
224
225
  const message = oneLine(first?.type === "text" ? first.text : "Fetch failed");
225
- text.setText(theme.fg("error", message.startsWith("Error:") ? message : `Error: ${message}`));
226
+ text.setText(paint(theme, "bad", message.startsWith("Error:") ? message : `Error: ${message}`));
226
227
  return text;
227
228
  }
228
229
 
229
230
  const label = oneLine(page.title || page.finalUrl);
230
- let line = theme.fg("muted", label);
231
- if (page.matches.length > 0) line += theme.fg("dim", `${SEPARATORS.item}${page.matches.length} match(es)`);
232
- else line += theme.fg("dim", `${SEPARATORS.item}${formatTokens(page.totalChars)} chars`);
233
- if (page.cached) line += theme.fg("dim", " (cached)");
231
+ let line = paint(theme, "meta", label);
232
+ if (page.matches.length > 0) line += paint(theme, "faint", `${SEPARATORS.item}${page.matches.length} match(es)`);
233
+ else line += paint(theme, "faint", `${SEPARATORS.item}${formatTokens(page.totalChars)} chars`);
234
+ if (page.cached) line += paint(theme, "faint", " (cached)");
234
235
  text.setText(line);
235
236
  return text;
236
237
  },
@@ -10,8 +10,9 @@
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import { loadSearchConfig } from "../config.ts";
12
12
  import { oneLine } from "../transcript.ts";
13
- import { reuseText } from "../../../lib/tui.ts";
14
- import { expandHint, SEPARATORS } from "../../../lib/ui.ts";
13
+ import { reuseText } from "../../../lib/tui/chrome.ts";
14
+ import { paint } from "../../../lib/tui/tokens.ts";
15
+ import { expandHint, SEPARATORS } from "../../../lib/tui/glyphs.ts";
15
16
  import { formatResults } from "./format.ts";
16
17
  import { createThrottle, runSearch, type SearchDeps } from "./search.ts";
17
18
  import { resolveRequest, WebSearchOutput, WebSearchParams, type SearchResponse, type WebSearchArgs } from "./schema.ts";
@@ -68,8 +69,8 @@ export function registerSearchTool(pi: ExtensionAPI, deps: SearchDeps = {}): voi
68
69
  renderCall(args, theme, context) {
69
70
  const { query, maxResults } = args as WebSearchArgs;
70
71
  const text = reuseText(context?.lastComponent);
71
- let line = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + theme.fg("accent", oneLine(query ?? ""));
72
- if (maxResults) line += theme.fg("dim", ` (${maxResults} results)`);
72
+ let line = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "accent", oneLine(query ?? ""));
73
+ if (maxResults) line += paint(theme, "faint", ` (${maxResults} results)`);
73
74
  text.setText(line);
74
75
  return text;
75
76
  },
@@ -80,11 +81,11 @@ export function registerSearchTool(pi: ExtensionAPI, deps: SearchDeps = {}): voi
80
81
  if (!details || result.isError) {
81
82
  const first = result.content[0];
82
83
  const message = first?.type === "text" ? first.text : "Search failed";
83
- text.setText(theme.fg("error", `Error: ${oneLine(message)}`));
84
+ text.setText(paint(theme, "bad", `Error: ${oneLine(message)}`));
84
85
  return text;
85
86
  }
86
87
  if (details.provider === "none") {
87
- text.setText(theme.fg("dim", `No results for "${oneLine(details.query)}".`));
88
+ text.setText(paint(theme, "faint", `No results for "${oneLine(details.query)}".`));
88
89
  return text;
89
90
  }
90
91
 
@@ -92,19 +93,21 @@ export function registerSearchTool(pi: ExtensionAPI, deps: SearchDeps = {}): voi
92
93
  // A blank line separates the call header from the result body.
93
94
  const lines: string[] = [
94
95
  "",
95
- theme.fg("dim", `via ${details.provider}${SEPARATORS.item}${count} result${count === 1 ? "" : "s"}`),
96
+ paint(theme, "faint", `via ${details.provider}${SEPARATORS.item}${count} result${count === 1 ? "" : "s"}`),
96
97
  ];
97
- if (details.answer) lines.push(`${theme.fg("dim", "answer")} ${theme.fg("muted", oneLine(details.answer, 160))}`);
98
+ if (details.answer)
99
+ lines.push(`${paint(theme, "faint", "answer")} ${paint(theme, "meta", oneLine(details.answer, 160))}`);
98
100
 
99
101
  const shown = options.expanded ? details.results : details.results.slice(0, 5);
100
- const indent = theme.fg("dim", " ");
102
+ const indent = paint(theme, "faint", " ");
101
103
  for (const [index, item] of shown.entries()) {
102
- lines.push(`${theme.fg("accent", `${index + 1}.`)} ${theme.fg("muted", oneLine(item.title, 100))}`);
103
- lines.push(`${indent}${theme.fg("dim", oneLine(item.url, 100))}`);
104
- if (options.expanded && item.snippet) lines.push(`${indent}${theme.fg("muted", oneLine(item.snippet, 160))}`);
104
+ lines.push(`${paint(theme, "accent", `${index + 1}.`)} ${paint(theme, "meta", oneLine(item.title, 100))}`);
105
+ lines.push(`${indent}${paint(theme, "faint", oneLine(item.url, 100))}`);
106
+ if (options.expanded && item.snippet)
107
+ lines.push(`${indent}${paint(theme, "meta", oneLine(item.snippet, 160))}`);
105
108
  }
106
109
  const extra = count - shown.length;
107
- if (extra > 0) lines.push(`${theme.fg("dim", `+${extra} more`)} ${expandHint(theme)}`);
110
+ if (extra > 0) lines.push(`${paint(theme, "faint", `+${extra} more`)} ${expandHint(theme)}`);
108
111
  text.setText(lines.join("\n"));
109
112
  return text;
110
113
  },
@@ -252,8 +252,8 @@ status, which is what the old start/stop guidance used to do.
252
252
  | `outbound.ts` | Upload and send a local file: hash, encrypt, `getUploadUrl`, upload, `sendFileMessage`. |
253
253
  | `login.ts` | QR login state machine. |
254
254
  | `qrcode.ts` | QR matrix → Unicode half-block terminal lines. |
255
- | `login-screen.ts` | The `/wechat login` screen (`ctx.ui.custom`). |
256
- | `status-screen.ts` | The `/wechat` control screen (`ctx.ui.custom`) and the pure one-line/body renderers. |
255
+ | `login-screen.ts` | The `/wechat login` screen (`ctx.ui.custom`), drawn from the `lib/tui/` kit (`titleRule`, `clip`, `isCloseKey`). |
256
+ | `status-screen.ts` | The `/wechat` control screen (`ctx.ui.custom`) and the pure one-line/body renderers, drawn from the `lib/tui/` kit (`titleRule`, `keyValueRow`, `paint`). |
257
257
  | `config.ts` | The `wechat` section of `bytefly.json` loading and normalization. |
258
258
  | `credentials.ts` | `credentials.json` (`0600`, multi-account). |
259
259
  | `state.ts` | `state.json` (cursor, owner, per-peer tokens). |
@@ -14,7 +14,8 @@
14
14
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
15
15
  import { join } from "node:path";
16
16
  import { isExtensionEnabled } from "../../lib/env.ts";
17
- import { GLYPHS, STATUS_KEYS } from "../../lib/ui.ts";
17
+ import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
18
+ import { paint } from "../../lib/tui/tokens.ts";
18
19
  import { createBridge, type Bridge, type BridgeState } from "./bridge.ts";
19
20
  import { createFetchRunner, WechatClient } from "./client.ts";
20
21
  import { registerCommands, type CommandDeps } from "./commands.ts";
@@ -34,9 +35,9 @@ import { registerTools } from "./tools.ts";
34
35
  export function wechatChip(theme: Theme, state: BridgeState): string | undefined {
35
36
  switch (state) {
36
37
  case "running":
37
- return theme.fg("success", `${GLYPHS.wechat} wechat`);
38
+ return paint(theme, "ok", `${GLYPHS.wechat} wechat`);
38
39
  case "expired":
39
- return theme.fg("error", `${GLYPHS.wechat} wechat expired`);
40
+ return paint(theme, "bad", `${GLYPHS.wechat} wechat expired`);
40
41
  case "stopped":
41
42
  return undefined;
42
43
  }
@@ -7,8 +7,11 @@
7
7
  */
8
8
 
9
9
  import type { Theme } from "@earendil-works/pi-coding-agent";
10
- import { Key, matchesKey, type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
- import { screenHeader, screenHint } from "../../lib/tui.ts";
10
+ import { type Component, visibleWidth } from "@earendil-works/pi-tui";
11
+ import { screenHint } from "../../lib/tui/chrome.ts";
12
+ import { clip } from "../../lib/tui/columns.ts";
13
+ import { isCloseKey } from "../../lib/tui/cursor.ts";
14
+ import { titleRule } from "../../lib/tui/rules.ts";
12
15
 
13
16
  export interface LoginViewState {
14
17
  /** Raw `get_qrcode_status` value, or `wait` before the first poll. */
@@ -51,7 +54,7 @@ export function loginBody(state: LoginViewState, width: number): string[] {
51
54
  lines.push(state.url.slice(index, index + target));
52
55
  }
53
56
  }
54
- return lines.map((line) => truncateToWidth(line, target));
57
+ return lines.map((line) => clip(line, target));
55
58
  }
56
59
 
57
60
  export interface LoginScreenOptions {
@@ -69,7 +72,7 @@ export class LoginScreenComponent implements Component {
69
72
  constructor(private readonly options: LoginScreenOptions) {}
70
73
 
71
74
  handleInput(data: string): void {
72
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
75
+ if (isCloseKey(data)) {
73
76
  this.options.onCancel();
74
77
  }
75
78
  }
@@ -81,7 +84,7 @@ export class LoginScreenComponent implements Component {
81
84
  const target = Math.max(1, width);
82
85
  const theme = this.options.theme;
83
86
  return [
84
- screenHeader(theme, target, "wechat · login"),
87
+ titleRule(theme, target, "wechat · login"),
85
88
  "",
86
89
  ...loginBody(this.options.state(), target),
87
90
  "",
@@ -10,8 +10,13 @@
10
10
  */
11
11
 
12
12
  import type { Theme } from "@earendil-works/pi-coding-agent";
13
- import { Key, matchesKey, truncateToWidth, type Component } from "@earendil-works/pi-tui";
14
- import { screenHeader, screenHint, type ScreenHint } from "../../lib/tui.ts";
13
+ import type { Component } from "@earendil-works/pi-tui";
14
+ import { screenHint, type ScreenHint } from "../../lib/tui/chrome.ts";
15
+ import { clip } from "../../lib/tui/columns.ts";
16
+ import { isCloseKey } from "../../lib/tui/cursor.ts";
17
+ import { keyValueRow } from "../../lib/tui/rows.ts";
18
+ import { titleRule } from "../../lib/tui/rules.ts";
19
+ import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
15
20
  import type { BridgeStatus } from "./bridge.ts";
16
21
  import { peerLabel } from "./format.ts";
17
22
  import type { BotCredentials } from "./types.ts";
@@ -72,18 +77,32 @@ export function statusLine(view: StatusView): string {
72
77
  /** Label column width in the screen body. */
73
78
  const LABEL_WIDTH = 11;
74
79
 
75
- function row(theme: Theme, label: string, value: string): string {
76
- return ` ${theme.fg("muted", label.padEnd(LABEL_WIDTH))}${value}`;
80
+ /** One body row: a `meta` label column and a value in `valueRole`. */
81
+ function row(theme: Theme, width: number, label: string, value: string, valueRole: TuiRole = "label"): string {
82
+ return keyValueRow(theme, width, { label, value, labelWidth: LABEL_WIDTH, labelRole: "meta", valueRole });
77
83
  }
78
84
 
79
- function connectionText(theme: Theme, connection: StatusConnection): string {
85
+ /** The connection word, uncolored; {@link row} paints it. */
86
+ function connectionText(connection: StatusConnection): string {
80
87
  switch (connection) {
81
88
  case "open":
82
- return theme.fg("success", "open");
89
+ return "open";
83
90
  case "expired":
84
- return theme.fg("error", "expired");
91
+ return "expired";
85
92
  case "closed":
86
- return theme.fg("muted", "closed");
93
+ return "closed";
94
+ }
95
+ }
96
+
97
+ /** The design role the connection word is drawn in. */
98
+ function connectionRole(connection: StatusConnection): TuiRole {
99
+ switch (connection) {
100
+ case "open":
101
+ return "ok";
102
+ case "expired":
103
+ return "bad";
104
+ case "closed":
105
+ return "meta";
87
106
  }
88
107
  }
89
108
 
@@ -91,28 +110,28 @@ function connectionText(theme: Theme, connection: StatusConnection): string {
91
110
  export function statusBody(view: StatusView, theme: Theme, width: number): string[] {
92
111
  const target = Math.max(1, width);
93
112
  const lines = [
94
- row(theme, "Account", view.account ?? theme.fg("muted", "not logged in")),
95
- row(theme, "Connection", connectionText(theme, view.connection)),
113
+ row(theme, target, "Account", view.account ?? "not logged in", view.account ? "label" : "meta"),
114
+ row(theme, target, "Connection", connectionText(view.connection), connectionRole(view.connection)),
96
115
  ];
97
116
  if (view.bridge) {
98
- if (view.owner && view.owner !== view.account) lines.push(row(theme, "Owner", view.owner));
99
- lines.push(row(theme, "Peers", String(view.peers)));
100
- lines.push(row(theme, "Queued", String(view.queued)));
101
- if (view.refused > 0) lines.push(row(theme, "Refused", String(view.refused)));
102
- if (view.mediaDropped > 0) lines.push(row(theme, "Media", `${view.mediaDropped} dropped`));
103
- if (view.promptPending) lines.push(row(theme, "Prompt", theme.fg("accent", "waiting for a reply")));
117
+ if (view.owner && view.owner !== view.account) lines.push(row(theme, target, "Owner", view.owner));
118
+ lines.push(row(theme, target, "Peers", String(view.peers), "meta"));
119
+ lines.push(row(theme, target, "Queued", String(view.queued), "meta"));
120
+ if (view.refused > 0) lines.push(row(theme, target, "Refused", String(view.refused), "meta"));
121
+ if (view.mediaDropped > 0) lines.push(row(theme, target, "Media", `${view.mediaDropped} dropped`, "meta"));
122
+ if (view.promptPending) lines.push(row(theme, target, "Prompt", "waiting for a reply", "accent"));
104
123
  }
105
124
  if (view.connection === "expired") {
106
125
  lines.push("");
107
- lines.push(` ${theme.fg("error", "Session expired — press l to log in again.")}`);
126
+ lines.push(clip(` ${paint(theme, "bad", "Session expired — press l to log in again.")}`, target));
108
127
  } else if (view.connection === "closed" && view.account) {
109
128
  lines.push("");
110
- lines.push(` ${theme.fg("muted", "Not connected — press l to log in again.")}`);
129
+ lines.push(clip(` ${paint(theme, "meta", "Not connected — press l to log in again.")}`, target));
111
130
  } else if (view.connection === "closed") {
112
131
  lines.push("");
113
- lines.push(` ${theme.fg("muted", "Run /wechat login to connect an account.")}`);
132
+ lines.push(clip(` ${paint(theme, "meta", "Run /wechat login to connect an account.")}`, target));
114
133
  }
115
- return lines.map((line) => truncateToWidth(line, target));
134
+ return lines;
116
135
  }
117
136
 
118
137
  export interface StatusScreenOptions {
@@ -140,7 +159,7 @@ export class StatusScreenComponent implements Component {
140
159
  }
141
160
 
142
161
  handleInput(data: string): void {
143
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
162
+ if (isCloseKey(data)) {
144
163
  this.options.onClose("close");
145
164
  return;
146
165
  }
@@ -166,7 +185,7 @@ export class StatusScreenComponent implements Component {
166
185
  const target = Math.max(1, width);
167
186
  const theme = this.options.theme;
168
187
  return [
169
- screenHeader(theme, target, "wechat · control"),
188
+ titleRule(theme, target, "wechat · control"),
170
189
  "",
171
190
  ...statusBody(this.options.view(), theme, target),
172
191
  "",
@@ -11,7 +11,8 @@ import type { JsonValue } from "@earendil-works/pi-ai";
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { Type, type Static } from "typebox";
13
13
  import { sanitizeLine } from "../../lib/format.ts";
14
- import { reuseText, toolDetails } from "../../lib/tui.ts";
14
+ import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
15
+ import { paint } from "../../lib/tui/tokens.ts";
15
16
  import type { Bridge } from "./bridge.ts";
16
17
 
17
18
  export const TOOL_NAME = "send_wechat";
@@ -85,7 +86,7 @@ export function registerTools(pi: ExtensionAPI, deps: ToolDeps): void {
85
86
  const text = reuseText(context.lastComponent);
86
87
  const body = (args as { text?: unknown }).text;
87
88
  const label = context.argsComplete && typeof body === "string" ? sanitizeLine(body) : "…";
88
- text.setText(theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + theme.fg("muted", label));
89
+ text.setText(theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "meta", label));
89
90
  return text;
90
91
  },
91
92
 
@@ -93,16 +94,16 @@ export function registerTools(pi: ExtensionAPI, deps: ToolDeps): void {
93
94
  const text = reuseText(context.lastComponent);
94
95
  const details = toolDetails<{ sent?: boolean; error?: string }>(result.details);
95
96
  if (details?.sent) {
96
- text.setText(theme.fg("success", "sent to WeChat owner"));
97
+ text.setText(paint(theme, "ok", "sent to WeChat owner"));
97
98
  return text;
98
99
  }
99
100
  if (details?.error) {
100
- text.setText(theme.fg("error", `Error: ${sanitizeLine(details.error)}`));
101
+ text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
101
102
  return text;
102
103
  }
103
104
  // Pi's rejection result carries `{}`; show its message instead.
104
105
  const first = result.content[0];
105
- text.setText(first?.type === "text" ? first.text : theme.fg("error", "Error: send failed"));
106
+ text.setText(first?.type === "text" ? first.text : paint(theme, "bad", "Error: send failed"));
106
107
  return text;
107
108
  },
108
109
  });
@@ -7,7 +7,7 @@ import { appendFileSync, existsSync, readFileSync } from "node:fs";
7
7
  import { basename, join, resolve } from "node:path";
8
8
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
  import { isInside } from "../../lib/path.ts";
10
- import { GLYPHS } from "../../lib/ui.ts";
10
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
11
11
  import { loadConfig, type WorktreeConfig } from "./config.ts";
12
12
  import { WorktreeCancelledError } from "./errors.ts";
13
13
  import {
@@ -24,7 +24,7 @@ import { existsSync } from "node:fs";
24
24
  import { fileURLToPath } from "node:url";
25
25
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
26
  import { isExtensionEnabled } from "../../lib/env.ts";
27
- import { GLYPHS } from "../../lib/ui.ts";
27
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
28
28
  import { canonicalize, checkCheckout, repoRoot, unlockWorktree } from "./git.ts";
29
29
  import { analyzeBashCommand, guardFileTool } from "./guard.ts";
30
30
  import {
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
- import { STATUS_KEYS } from "../../lib/ui.ts";
11
+ import { STATUS_KEYS } from "../../lib/tui/glyphs.ts";
12
12
  import { ENV_ROOT } from "../../lib/env.ts";
13
13
  import { loadConfig, type WorktreeConfig } from "./config.ts";
14
14
  import type { WorktreeState } from "./state.ts";
package/lib/README.md CHANGED
@@ -24,7 +24,7 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
24
24
  | [`interaction.ts`](./interaction.ts) | Turn-affinity prompt contract: the channel a remote adapter installs on `ctx.ui` under a string key, the `InteractionChannel` interface (`request` / `post` / `postFile`), and `remoteChannel` / `isRemoteTurn` / `supportsCustomUI` / `askHuman` (with an optional file/text preface) plus the shared “Other” label. |
25
25
  | [`git/`](./git/) | Shared git plumbing. [`git/runner.ts`](./git/runner.ts) is the `RunGit` seam (`createExecRunner` with timeout/env handling, `runGitOrThrow`, `GitError`); [`git/read.ts`](./git/read.ts) holds the read helpers (`repoRoot`, `repoRootFor`, `gitDir`, `revParse`, `hasCommits`, `currentBranch`); [`git/index.ts`](./git/index.ts) is the barrel. |
26
26
  | [`highlight.ts`](./highlight.ts) | First-party, escape-first syntax tokenizer (`detectLanguage`, `fenceLanguage`, `highlightLines`, `HighlightLanguage`, `TokenClass`) for js/ts, json, css, html, shell, python, java, yaml, and sql; shared by `file-browse` and plan's code blocks. |
27
- | [`list-cursor.ts`](./list-cursor.ts) | Scrollable-list behavior for the `/rewind`, `/bytefly`, `/todos`, and `/jobs` screens: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` state. |
27
+ | [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface: [`tui/tokens.ts`](./tui/tokens.ts) (semantic role -> theme color, `paint`), [`tui/glyphs.ts`](./tui/glyphs.ts) (`GLYPHS`, `SEPARATORS`, `STATUS_KEYS`, `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, `BODY_INDENT` / `GLYPH_GAP` — the vocabulary both sides of a status must agree on), [`tui/chrome.ts`](./tui/chrome.ts) (`screenHint`, `dockListFrame` (the docked-list frame), `viewportRows`, `FULL_SCREEN_OVERLAY`, `reuseText`, `editorTheme`), [`tui/rules.ts`](./tui/rules.ts) (`plainRule` / `titleRule` / `centeredRule`), [`tui/rows.ts`](./tui/rows.ts) (`listRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`), [`tui/columns.ts`](./tui/columns.ts) (`splitRow`, `fitCells`, `flowCells`, `padTo`, `clip`), [`tui/gauge.ts`](./tui/gauge.ts) (`computeGauge`, `gaugeRole`, `gaugeBar`), [`tui/line-pane.ts`](./tui/line-pane.ts) (`LinePane`, the scrollable log/match pane), [`tui/status-controller.ts`](./tui/status-controller.ts) (`createStatusController`, the footer chips `job` and `watch` share), and [`tui/cursor.ts`](./tui/cursor.ts) (scroll and navigation: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` (by index) and `IdCursor` (by stable id) state). |
28
28
  | [`path.ts`](./path.ts) | `isInside` (string containment) plus `realPathOfNearest` / `isInsideReal` (symlink-aware): the containment predicates the file-browse, worktree, and doc guards share. |
29
29
  | [`pi-invocation.ts`](./pi-invocation.ts) | `getPiInvocation`: how to launch a nested `pi` process, shared by `subagent` and bytefly's doctor surface. |
30
30
  | [`policy.ts`](./policy.ts) | `createReadOnlyPolicy`: default-deny tool classification from `readOnlyHint` + explicit allow/deny. |
@@ -32,8 +32,6 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
32
32
  | [`requirements.ts`](./requirements.ts) | Per-extension runtime prerequisite table plus a pure `probeExtension(seams)` over injected seams (exec, config reads, optional imports): bytefly's doctor surface reports on it and the extensions that own a dependency format their errors from it. |
33
33
  | [`shell.ts`](./shell.ts) | Shell escape rule shared by the worktree isolation guard and plan mode's read-only git guard: `SHELL_ESCAPABLE`, `isShellEscapable`, `hasShellEscape`. |
34
34
  | [`tool-names.ts`](./tool-names.ts) | Tool names and one parameter name that more than one extension must agree on, so an orchestrating `ctx.executeTool()` call or schema probe breaks the build on a rename. |
35
- | [`tui.ts`](./tui.ts) | Screen chrome: `screenHeader`, `screenHint`, `dockListFrame` (the shared docked-list assembly), `viewportRows`, and `FULL_SCREEN_OVERLAY`, plus `reuseText` (per-render `Text` reuse) and `editorTheme` (inline-editor theme). |
36
- | [`ui.ts`](./ui.ts) | Shared UI vocabulary: `GLYPHS`, `SEPARATORS`, `STATUS_KEYS` (`ctx.ui.setStatus` keys), `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, and the transcript rail `BODY_INDENT` / `GLYPH_GAP`. |
37
35
  | [`web/`](./web/) | The Bytefly web brand system shared by the browser surfaces: [`web/tokens.ts`](./web/tokens.ts) (palette derived from the Bytefly terminal themes, scales, and the `tok-*` map), [`web/base.ts`](./web/base.ts), [`web/components.ts`](./web/components.ts), [`web/layout.ts`](./web/layout.ts), [`web/runtime.ts`](./web/runtime.ts) (`WEB_THEME_STORAGE_KEY`, `THEME_JS`, `THEME_TOGGLE_JS`), [`web/markup.ts`](./web/markup.ts), and the [`web/index.ts`](./web/index.ts) barrel. |
38
36
 
39
37
  ## Conventions
@@ -43,7 +41,12 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
43
41
  - **Inject the host seam.** Modules that need a host capability take a function
44
42
  (`RunGit`, `HttpRunner`) rather than the whole `ExtensionAPI`, so the logic is
45
43
  testable without a runtime.
46
- - **Constants that must agree across both sides live in `ui.ts`** — a glyph and
47
- the `setStatus` key are one contract, not two literals. Tool names shared by
48
- an orchestrating tool and the tool it calls — and parameter names one
49
- extension probes on another — live in `tool-names.ts`.
44
+ - **Constants that must agree across both sides live in `tui/glyphs.ts`** — a
45
+ glyph and the `setStatus` key are one contract, not two literals. Tool names
46
+ shared by an orchestrating tool and the tool it calls — and parameter names
47
+ one extension probes on another — live in `tool-names.ts`.
48
+ - **A color is a role, not a token.** Draw through [`tui/tokens.ts`](./tui/tokens.ts)
49
+ (`paint(theme, role, text)`) so a theme swap re-skins every surface; the
50
+ primitives in `tui/` already do, and a new one should too. The one exception is
51
+ Pi's tool-rendering vocabulary (`toolOutput`, `toolTitle`), which stays direct:
52
+ those tokens style a tool block, and no role maps to them.
@@ -166,7 +166,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
166
166
  optional: [{ kind: "module", name: "unpdf", enables: "PDF text extraction in `web_fetch`", hint: pdfGuidance() }],
167
167
  },
168
168
  "file-browse": {
169
- optional: [gitCheck("the branch chip")],
169
+ optional: [gitCheck("the branch chip and the diff view")],
170
170
  notes: ["The default-browser opener (`open` / `xdg-open` / `cmd`) is best-effort; the URL is printed either way."],
171
171
  },
172
172
  document: {},
@@ -194,7 +194,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
194
194
  bytefly: {
195
195
  configFiles: [SHARED_CONFIG_FILE],
196
196
  notes: [
197
- "TUI only for the header, footer, and turn rule; `/bytefly` (and the `/extensions` and `/doctor` aliases) prints a listing and report in RPC, JSON, and print modes.",
197
+ "TUI only for the header, footer, and turn rule; `/bytefly` prints a listing and `/bytefly doctor` the report in RPC, JSON, and print modes.",
198
198
  "The panel writes extension enablement through Pi's own settings; project scope requires a trusted project.",
199
199
  "`/bytefly`'s list screen and the proactive missing-prerequisite warning need an interactive UI.",
200
200
  ],
@@ -18,7 +18,7 @@
18
18
 
19
19
  import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
20
20
  import { Text, truncateToWidth, visibleWidth, type EditorTheme, type OverlayOptions } from "@earendil-works/pi-tui";
21
- import { SEPARATORS } from "./ui.ts";
21
+ import { SEPARATORS } from "./glyphs.ts";
22
22
 
23
23
  /**
24
24
  * `ctx.ui.custom` options that give a screen the whole terminal.
@@ -36,21 +36,6 @@ export const FULL_SCREEN_OVERLAY: { overlay: true; overlayOptions: OverlayOption
36
36
  overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", margin: 0 },
37
37
  };
38
38
 
39
- /**
40
- * Top border with `label` centered-left, exactly `width` columns wide. When the
41
- * terminal is too narrow for the title and a border on each side, it degrades to
42
- * a plain rule rather than truncating the title into an ellipsis.
43
- */
44
- export function screenHeader(theme: Theme, width: number, label: string): string {
45
- const text = ` ${label} `;
46
- const prefix = "───";
47
- if (width < visibleWidth(prefix) + visibleWidth(text) + 1) {
48
- return theme.fg("borderMuted", "─".repeat(Math.max(0, width)));
49
- }
50
- const remaining = width - visibleWidth(prefix) - visibleWidth(text);
51
- return theme.fg("borderMuted", prefix) + theme.fg("accent", text) + theme.fg("borderMuted", "─".repeat(remaining));
52
- }
53
-
54
39
  /** A row count, or a live getter so a screen re-sizes with the terminal. */
55
40
  export type ViewportRowsSource = number | (() => number | undefined);
56
41
 
@@ -105,12 +90,8 @@ export function screenHint(theme: Theme, width: number, hints: ScreenHint[]): st
105
90
  * the frame only places them.
106
91
  */
107
92
  export interface DockListFrame {
108
- /** Semantic theme for the header rule and the hint row. */
109
- theme: Theme;
110
- /** Body width in columns. */
111
- width: number;
112
- /** The header label, e.g. `Extensions`. */
113
- title: string;
93
+ /** The pre-rendered header line, usually `titleRule(theme, width, title)`. */
94
+ header: string;
114
95
  /** A summary line under the header; a blank line follows it. */
115
96
  summary?: string;
116
97
  /** The list rows, or the screen's single empty-state line. */
@@ -124,18 +105,16 @@ export interface DockListFrame {
124
105
  }
125
106
 
126
107
  /**
127
- * Assemble a docked list screen: the `─` header rule, an optional summary and
128
- * its blank line, the body, an optional range row, optional detail rows, and
108
+ * Assemble a docked list screen: the caller's header rule, an optional summary
109
+ * and its blank line, the body, an optional range row, optional detail rows, and
129
110
  * the blank-line-padded key-hint footer.
130
111
  *
131
- * This is the convention the bytefly panel (`/bytefly`, and the `/extensions`
132
- * and `/doctor` aliases) shares with `/jobs` and `/todos`. `/rewind` (a blank
133
- * after the header and no blank before the hint) places its blanks differently,
134
- * so it stays hand-rolled rather than changing its output to fit this helper.
112
+ * This is the convention every docked list screen shares — the bytefly panel,
113
+ * `/jobs`, `/watch`, `/todos`, `/memory`, and `/rewind`. The header line is built
114
+ * by the caller (usually `titleRule`), so the frame never picks a rule style.
135
115
  */
136
116
  export function dockListFrame(frame: DockListFrame): string[] {
137
- const width = Math.max(1, frame.width);
138
- const lines: string[] = [screenHeader(frame.theme, width, frame.title)];
117
+ const lines: string[] = [frame.header];
139
118
  if (frame.summary !== undefined) lines.push(frame.summary, "");
140
119
  lines.push(...frame.body);
141
120
  if (frame.range !== undefined) lines.push(frame.range);
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Width arithmetic for the terminal: justify, fit, pad, and clip lines.
3
+ *
4
+ * Every function works in terminal columns via `visibleWidth` /
5
+ * `truncateToWidth`, never string length, and every result fits the width it
6
+ * was given. Themed strings pass through unchanged except where a cell has to
7
+ * be clipped, so a color survives every cell that fits.
8
+ *
9
+ * Value-only: pure functions over their arguments.
10
+ */
11
+
12
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
+
14
+ /** A usable origin: terminal arithmetic starts from one column, never zero. */
15
+ function columns(width: number): number {
16
+ return Math.max(1, Math.floor(width));
17
+ }
18
+
19
+ /** Clip a possibly-themed string to `width` columns, marking a cut with `ellipsis`. */
20
+ export function clip(text: string, width: number, ellipsis = "…"): string {
21
+ return truncateToWidth(text, columns(width), ellipsis);
22
+ }
23
+
24
+ /** Pad `text` on the right to exactly `width` columns. Never truncates. */
25
+ export function padTo(text: string, width: number): string {
26
+ const missing = columns(width) - visibleWidth(text);
27
+ return missing > 0 ? text + " ".repeat(missing) : text;
28
+ }
29
+
30
+ /**
31
+ * Left/right justify on one line: the left cell sits at the margin, the right
32
+ * cell at the edge, and at least `minGap` columns separate them.
33
+ *
34
+ * The right cell is the anchor — it is clipped only after the left cell has
35
+ * been clipped away — so a value at the end of a line (a model name, a metric)
36
+ * survives a tight terminal instead of vanishing. An empty right cell means a
37
+ * plain left-aligned line with no trailing spaces.
38
+ */
39
+ export function splitRow(left: string, right: string, width: number, minGap = 1): string {
40
+ const w = columns(width);
41
+ if (!right) return clip(left, w);
42
+ const gap = Math.max(0, Math.floor(minGap));
43
+ const rightCell = clip(right, w);
44
+ const rightWidth = visibleWidth(rightCell);
45
+ const leftBudget = w - rightWidth - gap;
46
+ if (leftBudget <= 0) return rightCell;
47
+ return padTo(clip(left, leftBudget), w - rightWidth) + rightCell;
48
+ }
49
+
50
+ /**
51
+ * Join as many whole cells as fit, dropping trailing cells rather than
52
+ * clipping one in half. The first cell is always kept, so an over-wide line
53
+ * degrades to a clipped first cell instead of an empty one.
54
+ */
55
+ export function fitCells(cells: readonly string[], width: number, separator = " "): string {
56
+ const w = columns(width);
57
+ const kept: string[] = [];
58
+ for (const cell of cells) {
59
+ const candidate = [...kept, cell].join(separator);
60
+ if (kept.length > 0 && visibleWidth(candidate) > w) break;
61
+ kept.push(cell);
62
+ }
63
+ return clip(kept.join(separator), w);
64
+ }
65
+
66
+ /**
67
+ * Lay atomic, already-themed cells onto lines no wider than `width`.
68
+ *
69
+ * Cells are never split: one that would not fit moves to the next line, and one
70
+ * wider than the whole width is clipped. Use this for a row of independent
71
+ * tokens (tabs, tags) where `fitCells`' single-line drop would lose one; unlike
72
+ * {@link fitCells} it may return several lines, and always returns one.
73
+ */
74
+ export function flowCells(cells: readonly string[], width: number): string[] {
75
+ const w = columns(width);
76
+ const lines: string[] = [];
77
+ let current = "";
78
+ let currentWidth = 0;
79
+ for (const cell of cells) {
80
+ let text = cell;
81
+ let cellWidth = visibleWidth(cell);
82
+ if (cellWidth > w) {
83
+ text = truncateToWidth(cell, w);
84
+ cellWidth = visibleWidth(text);
85
+ }
86
+ if (currentWidth > 0 && currentWidth + cellWidth > w) {
87
+ lines.push(current);
88
+ current = "";
89
+ currentWidth = 0;
90
+ }
91
+ current += text;
92
+ currentWidth += cellWidth;
93
+ }
94
+ lines.push(current);
95
+ return lines;
96
+ }