@gavin-hu/my-pi-agent 0.8.1 → 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 +8 -7
  2. package/README.zh-CN.md +7 -7
  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 +51 -9
  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 +385 -19
  30. package/extensions/file-browse/index.ts +15 -4
  31. package/extensions/file-browse/markdown.ts +33 -0
  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 +1 -1
  98. /package/lib/{ui.ts → tui/glyphs.ts} +0 -0
@@ -3,26 +3,23 @@
3
3
  *
4
4
  * Each segment carries progressively shorter `forms` and a `weight`; the layout
5
5
  * engine advances forms and drops segments from the highest weight down, so the
6
- * bar degrades predictably instead of clipping arbitrary text.
6
+ * bar degrades predictably instead of clipping arbitrary text. Colors come from
7
+ * the design-system roles, so the bar follows the active theme.
7
8
  */
8
9
 
9
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
10
11
  import { stripTerminalSequences } from "@earendil-works/pi-tui";
11
12
  import { formatTokens, sanitize, sanitizeLine } from "../../../../lib/format.ts";
13
+ import { gaugeBar } from "../../../../lib/tui/gauge.ts";
14
+ import { chip } from "../../../../lib/tui/rows.ts";
15
+ import { paint } from "../../../../lib/tui/tokens.ts";
12
16
  import { CONFIG } from "../config.ts";
13
- import {
14
- computeGauge,
15
- contextColor,
16
- formatCost,
17
- formatCwd,
18
- formatPercent,
19
- thinkingColor,
20
- truncateLabel,
21
- } from "./format.ts";
17
+ import { contextRole, formatCost, formatCwd, formatPercent, thinkingColor, truncateLabel } from "./format.ts";
22
18
  import { compactStatus, dropStatusDetail } from "./status.ts";
23
19
  import type { Segment, StatusSnapshot } from "../types.ts";
24
20
 
25
- const dim = (theme: Theme, text: string): string => theme.fg("dim", text);
21
+ /** Tertiary text: paths and de-emphasized meters. */
22
+ const faint = (theme: Theme, text: string): string => paint(theme, "faint", text);
26
23
 
27
24
  /**
28
25
  * The label part of a themed status chip: strip its escape sequences, sanitize
@@ -41,9 +38,9 @@ export function pwdSegment(snapshot: StatusSnapshot, theme: Theme, home: string
41
38
  droppable: false,
42
39
  separator: "",
43
40
  forms: [
44
- dim(theme, formatCwd(snapshot.cwd, home, 0)),
45
- dim(theme, formatCwd(snapshot.cwd, home, 1)),
46
- dim(theme, formatCwd(snapshot.cwd, home, 2)),
41
+ faint(theme, formatCwd(snapshot.cwd, home, 0)),
42
+ faint(theme, formatCwd(snapshot.cwd, home, 1)),
43
+ faint(theme, formatCwd(snapshot.cwd, home, 2)),
47
44
  ],
48
45
  };
49
46
  }
@@ -54,13 +51,13 @@ export function sessionSegment(snapshot: StatusSnapshot, theme: Theme): Segment
54
51
  const short = truncateLabel(name, CONFIG.sessionLabelMax);
55
52
  // Brackets and a brighter token keep the session name from reading as a
56
53
  // trailing path segment.
57
- const forms = [theme.fg("muted", `[${name}]`)];
58
- if (short !== name) forms.push(theme.fg("muted", `[${short}]`));
54
+ const forms = [paint(theme, "meta", `[${name}]`)];
55
+ if (short !== name) forms.push(paint(theme, "meta", `[${short}]`));
59
56
  return {
60
57
  id: "session",
61
58
  weight: 3,
62
59
  droppable: true,
63
- separator: dim(theme, CONFIG.separators.item),
60
+ separator: faint(theme, CONFIG.separators.item),
64
61
  forms,
65
62
  };
66
63
  }
@@ -68,14 +65,13 @@ export function sessionSegment(snapshot: StatusSnapshot, theme: Theme): Segment
68
65
  export function branchSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
69
66
  if (!snapshot.branch) return null;
70
67
  const detached = snapshot.branch === CONFIG.labels.detached;
71
- const color = detached ? "warning" : "muted";
72
68
  const icon = detached ? CONFIG.icons.detached : CONFIG.icons.branch;
73
69
  return {
74
70
  id: "branch",
75
71
  weight: 1,
76
72
  droppable: true,
77
- separator: dim(theme, CONFIG.separators.item),
78
- forms: [theme.fg(color, `${icon} ${snapshot.branch}`)],
73
+ separator: faint(theme, CONFIG.separators.item),
74
+ forms: [chip(theme, icon, snapshot.branch, detached ? "warn" : "meta")],
79
75
  };
80
76
  }
81
77
 
@@ -83,27 +79,16 @@ export function worktreeSegment(snapshot: StatusSnapshot, theme: Theme): Segment
83
79
  const raw = snapshot.statuses.get(CONFIG.worktreeStatusKey);
84
80
  if (!raw) return null;
85
81
  const icon = CONFIG.icons.worktree;
82
+ const separator = faint(theme, CONFIG.separators.item);
86
83
  const label = chipLabel(raw);
87
84
  if (!label) {
88
- return {
89
- id: "worktree",
90
- weight: 3,
91
- droppable: true,
92
- separator: dim(theme, CONFIG.separators.item),
93
- forms: [theme.fg("success", icon)],
94
- };
85
+ return { id: "worktree", weight: 3, droppable: true, separator, forms: [paint(theme, "ok", icon)] };
95
86
  }
96
87
  const short = truncateLabel(label, CONFIG.worktreeLabelMax);
97
- const forms = [theme.fg("success", `${icon} ${label}`)];
98
- if (short !== label) forms.push(theme.fg("success", `${icon} ${short}`));
99
- forms.push(theme.fg("success", icon));
100
- return {
101
- id: "worktree",
102
- weight: 3,
103
- droppable: true,
104
- separator: dim(theme, CONFIG.separators.item),
105
- forms,
106
- };
88
+ const forms = [chip(theme, icon, label, "ok")];
89
+ if (short !== label) forms.push(chip(theme, icon, short, "ok"));
90
+ forms.push(paint(theme, "ok", icon));
91
+ return { id: "worktree", weight: 3, droppable: true, separator, forms };
107
92
  }
108
93
 
109
94
  export function browseSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
@@ -111,25 +96,20 @@ export function browseSegment(snapshot: StatusSnapshot, theme: Theme): Segment |
111
96
  if (!raw) return null;
112
97
  const icon = CONFIG.icons.browse;
113
98
  const label = chipLabel(raw);
114
- const separator = dim(theme, CONFIG.separators.item);
99
+ const separator = faint(theme, CONFIG.separators.item);
115
100
  if (!label) {
116
- return {
117
- id: "browse",
118
- weight: 3,
119
- droppable: true,
120
- separator,
121
- forms: [theme.fg("success", icon)],
122
- };
101
+ return { id: "browse", weight: 3, droppable: true, separator, forms: [paint(theme, "ok", icon)] };
123
102
  }
103
+ // A two-tone chip: the `◉` glyph stays green while the port reads as accent.
124
104
  return {
125
105
  id: "browse",
126
106
  weight: 3,
127
107
  droppable: true,
128
108
  separator,
129
109
  forms: [
130
- `${theme.fg("success", icon)} ${theme.fg("accent", label)}`,
131
- `${theme.fg("success", icon)}${theme.fg("accent", label)}`,
132
- theme.fg("success", icon),
110
+ `${paint(theme, "ok", icon)} ${paint(theme, "accent", label)}`,
111
+ `${paint(theme, "ok", icon)}${paint(theme, "accent", label)}`,
112
+ paint(theme, "ok", icon),
133
113
  ],
134
114
  };
135
115
  }
@@ -150,7 +130,7 @@ export function modesSegment(snapshot: StatusSnapshot, theme: Theme): Segment |
150
130
  })
151
131
  .filter(Boolean);
152
132
  if (others.length === 0) return null;
153
- const full = others.join(dim(theme, CONFIG.separators.item));
133
+ const full = others.join(faint(theme, CONFIG.separators.item));
154
134
  // The compact form keeps each status's own colour (see `compactStatus`); a
155
135
  // two-token badge keeps its count (`↺ 2` -> `↺2`) instead of collapsing to a
156
136
  // bare icon.
@@ -161,31 +141,22 @@ export function modesSegment(snapshot: StatusSnapshot, theme: Theme): Segment |
161
141
  // than truncating the model, which sits at the end of the line.
162
142
  weight: 1,
163
143
  droppable: true,
164
- separator: dim(theme, CONFIG.separators.group),
144
+ separator: faint(theme, CONFIG.separators.group),
165
145
  forms: [full, icons],
166
146
  };
167
147
  }
168
148
 
169
149
  export function contextSegment(snapshot: StatusSnapshot, theme: Theme): Segment {
170
150
  const percent = snapshot.context.percent;
171
- const color = contextColor(percent);
172
- const percentText = theme.fg(color, formatPercent(percent));
173
- const gaugeWidths: readonly number[] = percent === null ? [0] : CONFIG.gauge.widths;
174
- const forms = gaugeWidths.map((blocks) => {
175
- const { filled, empty } = computeGauge(percent, blocks);
176
- const gauge =
177
- blocks > 0
178
- ? theme.fg(color, CONFIG.gauge.full.repeat(filled)) + theme.fg("dim", CONFIG.gauge.empty.repeat(empty)) + " "
179
- : "";
180
- return `${gauge}${percentText}`;
181
- });
182
- return {
183
- id: "context",
184
- weight: 1,
185
- droppable: false,
186
- separator: "",
187
- forms,
188
- };
151
+ const role = contextRole(percent);
152
+ const percentText = paint(theme, role, formatPercent(percent));
153
+ // Unknown usage draws no track, so `?` sits where the gauge would be rather
154
+ // than a full row of empty blocks claiming a known zero.
155
+ const widths: readonly number[] = percent === null ? [0] : CONFIG.gauge.widths;
156
+ const forms = widths.map((blocks) =>
157
+ blocks > 0 ? `${gaugeBar(theme, { percent, blocks, role, emptyRole: "faint" })} ${percentText}` : percentText,
158
+ );
159
+ return { id: "context", weight: 1, droppable: false, separator: "", forms };
189
160
  }
190
161
 
191
162
  export function windowSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
@@ -195,7 +166,7 @@ export function windowSegment(snapshot: StatusSnapshot, theme: Theme): Segment |
195
166
  weight: 3,
196
167
  droppable: true,
197
168
  separator: "",
198
- forms: [dim(theme, `/${formatTokens(snapshot.context.contextWindow)}`)],
169
+ forms: [faint(theme, `/${formatTokens(snapshot.context.contextWindow)}`)],
199
170
  };
200
171
  }
201
172
 
@@ -205,8 +176,8 @@ export function costSegment(snapshot: StatusSnapshot, theme: Theme): Segment | n
205
176
  id: "cost",
206
177
  weight: 2,
207
178
  droppable: true,
208
- separator: dim(theme, CONFIG.separators.item),
209
- forms: [theme.fg("muted", formatCost(snapshot.usage.cost, snapshot.currency))],
179
+ separator: faint(theme, CONFIG.separators.item),
180
+ forms: [paint(theme, "meta", formatCost(snapshot.usage.cost, snapshot.currency))],
210
181
  };
211
182
  }
212
183
 
@@ -220,8 +191,8 @@ export function tokensSegment(snapshot: StatusSnapshot, theme: Theme): Segment |
220
191
  id: "tokens",
221
192
  weight: 4,
222
193
  droppable: true,
223
- separator: dim(theme, CONFIG.separators.item),
224
- forms: [dim(theme, parts.join(" "))],
194
+ separator: faint(theme, CONFIG.separators.item),
195
+ forms: [faint(theme, parts.join(" "))],
225
196
  };
226
197
  }
227
198
 
@@ -240,8 +211,8 @@ export function cacheSegment(snapshot: StatusSnapshot, theme: Theme): Segment |
240
211
  id: "cache",
241
212
  weight: 5,
242
213
  droppable: true,
243
- separator: dim(theme, CONFIG.separators.item),
244
- forms: [dim(theme, parts.join(" "))],
214
+ separator: faint(theme, CONFIG.separators.item),
215
+ forms: [faint(theme, parts.join(" "))],
245
216
  };
246
217
  }
247
218
 
@@ -254,7 +225,7 @@ export function modelSegment(snapshot: StatusSnapshot, theme: Theme): Segment {
254
225
  weight: 1,
255
226
  droppable: false,
256
227
  separator: "",
257
- forms: [theme.fg("muted", CONFIG.labels.noModel)],
228
+ forms: [paint(theme, "meta", CONFIG.labels.noModel)],
258
229
  };
259
230
  }
260
231
  const provider = snapshot.providerCount > 1 ? `(${model.provider}) ` : "";
@@ -263,19 +234,21 @@ export function modelSegment(snapshot: StatusSnapshot, theme: Theme): Segment {
263
234
  weight: 1,
264
235
  droppable: false,
265
236
  separator: "",
266
- forms: [theme.fg("accent", `${provider}${model.id}`), theme.fg("accent", model.id)],
237
+ forms: [paint(theme, "accent", `${provider}${model.id}`), paint(theme, "accent", model.id)],
267
238
  };
268
239
  }
269
240
 
270
241
  export function thinkingSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
271
242
  if (!snapshot.model?.reasoning) return null;
272
243
  const level = snapshot.thinkingLevel ?? "off";
244
+ // The reasoning scale is Pi's own seven-token ramp, not a design role: a
245
+ // level colors itself from `thinkingColor`.
273
246
  const color = thinkingColor(level);
274
247
  return {
275
248
  id: "thinking",
276
249
  weight: 2,
277
250
  droppable: true,
278
- separator: dim(theme, CONFIG.separators.item),
251
+ separator: faint(theme, CONFIG.separators.item),
279
252
  forms: [theme.fg(color, level)],
280
253
  };
281
254
  }
@@ -3,16 +3,18 @@
3
3
  *
4
4
  * It draws bytefly's nameplate: a two-tone mark beside the agent identity (Pi
5
5
  * and its version) and the chrome identity (the tagline, bytefly, and its
6
- * version), a `─` rule, and a grouped help block. The mark is theme-colored and
7
- * falls back to a wordmark where half blocks do not render, the help keys are
8
- * resolved live from Pi's keybindings, and every line is truncated to the width
9
- * it is given.
6
+ * version), a signature rule whose accent lead sits under the mark, and a
7
+ * grouped help block. The mark and text are theme-colored through the shared
8
+ * design-system roles, the mark falls back to a wordmark where half blocks do
9
+ * not render, the help keys are resolved live from Pi's keybindings, and every
10
+ * line is truncated to the width it is given.
10
11
  */
11
12
 
12
13
  import { keyText, type Theme, VERSION } from "@earendil-works/pi-coding-agent";
13
14
  import { type Component, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
14
- import { screenHint } from "../../../lib/tui.ts";
15
- import { keyHintText } from "../../../lib/ui.ts";
15
+ import { screenHint } from "../../../lib/tui/chrome.ts";
16
+ import { keyHintText } from "../../../lib/tui/glyphs.ts";
17
+ import { paint } from "../../../lib/tui/tokens.ts";
16
18
  import { HEADER_CONFIG, type HelpEntry } from "./config.ts";
17
19
 
18
20
  /** The header's shape, resolved once per apply so `render` stays cheap. */
@@ -33,9 +35,9 @@ function markBlock(line: string): string {
33
35
 
34
36
  /** The two mark lines: the half-block logo, or the wordmark where it will not render. */
35
37
  function markLines(theme: Theme, halfBlocks: boolean): [string, string] {
36
- const { markColor, markAccent } = HEADER_CONFIG;
37
- if (halfBlocks) return [theme.fg(markColor, "█▀█"), theme.fg(markAccent, "█▀ █")];
38
- return [theme.fg(markColor, "P") + theme.fg(markAccent, "i"), ""];
38
+ const { markRole, markAccentRole } = HEADER_CONFIG;
39
+ if (halfBlocks) return [paint(theme, markRole, "█▀█"), paint(theme, markAccentRole, "█▀ █")];
40
+ return [paint(theme, markRole, "P") + paint(theme, markAccentRole, "i"), ""];
39
41
  }
40
42
 
41
43
  /** One help entry split into the text to print and its description. */
@@ -61,20 +63,20 @@ export class ByteflyHeader implements Component {
61
63
  const w = Math.max(1, width);
62
64
  const theme = this.getTheme();
63
65
  const view = this.view();
64
- const { padX, nameColor, versionColor, chromeTagline, chromeName, chromeVersion, help } = HEADER_CONFIG;
66
+ const { padX, nameRole, chromeRole, detailRole, chromeTagline, chromeName, chromeVersion, help } = HEADER_CONFIG;
65
67
  const pad = " ".repeat(Math.min(padX, Math.max(0, w - 1)));
66
68
  const [markTop, markBottom] = markLines(theme, view.halfBlocks);
67
69
 
68
- const version = view.showVersion && this.version ? theme.fg(versionColor, `v${this.version}`) : "";
70
+ const version = view.showVersion && this.version ? paint(theme, detailRole, `v${this.version}`) : "";
69
71
  // The wordmark mark spells the app name, so the agent cell carries only the
70
72
  // version there; the half-block mark does not, so the name stays beside it.
71
73
  const agent = view.halfBlocks
72
74
  ? version
73
- ? `${theme.fg(nameColor, HEADER_CONFIG.appName)} ${version}`
74
- : theme.fg(nameColor, HEADER_CONFIG.appName)
75
+ ? `${theme.bold(paint(theme, nameRole, HEADER_CONFIG.appName))} ${version}`
76
+ : theme.bold(paint(theme, nameRole, HEADER_CONFIG.appName))
75
77
  : version;
76
- const chrome = `${theme.fg(versionColor, chromeTagline)} ${theme.fg(nameColor, chromeName)}`;
77
- const chromeVersioned = chromeVersion ? `${chrome} ${theme.fg(versionColor, `v${chromeVersion}`)}` : chrome;
78
+ const chrome = `${paint(theme, detailRole, chromeTagline)} ${paint(theme, chromeRole, chromeName)}`;
79
+ const chromeVersioned = chromeVersion ? `${chrome} ${paint(theme, detailRole, `v${chromeVersion}`)}` : chrome;
78
80
 
79
81
  // The identity rows shed as one block: chrome version, chrome line, agent.
80
82
  const states: Array<readonly [string, string]> = [
@@ -89,11 +91,26 @@ export class ByteflyHeader implements Component {
89
91
  return [
90
92
  this.identityLine(pad, markTop, agentCell, w),
91
93
  this.identityLine(pad, markBottom, chromeCell, w),
92
- `${pad}${theme.fg("borderMuted", "─".repeat(Math.max(0, w - pad.length)))}`,
94
+ this.rule(theme, w, pad),
93
95
  ...help.map((row) => this.helpLine(theme, w, row)),
94
96
  ];
95
97
  }
96
98
 
99
+ /**
100
+ * The nameplate's signature rule: an accent lead as wide as the mark, so the
101
+ * rule is anchored to the mark, then a muted fill to the edge.
102
+ */
103
+ private rule(theme: Theme, width: number, pad: string): string {
104
+ const remaining = Math.max(0, width - pad.length);
105
+ const lead = Math.min(MARK_WIDTH, remaining);
106
+ const fill = remaining - lead;
107
+ return (
108
+ pad +
109
+ paint(theme, HEADER_CONFIG.ruleAccentRole, "─".repeat(lead)) +
110
+ paint(theme, HEADER_CONFIG.ruleRole, "─".repeat(fill))
111
+ );
112
+ }
113
+
97
114
  /** A nameplate row: the padded mark, a gutter, then the identity cell if it survived. */
98
115
  private identityLine(pad: string, mark: string, cell: string, width: number): string {
99
116
  const line = cell ? `${pad}${markBlock(mark)} ${cell}` : `${pad}${markBlock(mark)}`;
@@ -3,14 +3,16 @@
3
3
  *
4
4
  * The header is a nameplate: a two-tone mark beside the agent identity (Pi and
5
5
  * its version) and the chrome identity (the tagline, bytefly, and its version),
6
- * a `─` rule, and a grouped help block. The mark and the text are drawn from the
7
- * active theme's tokens, and the help keys are resolved live from Pi's
8
- * keybindings, so a rebound key is respected.
6
+ * a signature rule whose accent lead sits under the mark, and a grouped help
7
+ * block. Every color is a design-system role resolved by the active theme, and
8
+ * the help keys are resolved live from Pi's keybindings, so a rebound key is
9
+ * respected.
9
10
  */
10
11
 
11
- import type { AppKeybinding, ThemeColor } from "@earendil-works/pi-coding-agent";
12
+ import type { AppKeybinding } from "@earendil-works/pi-coding-agent";
13
+ import { EXPAND_KEYBINDING } from "../../../lib/tui/glyphs.ts";
14
+ import type { TuiRole } from "../../../lib/tui/tokens.ts";
12
15
  import pkg from "../../../package.json";
13
- import { EXPAND_KEYBINDING } from "../../../lib/ui.ts";
14
16
 
15
17
  /** One help entry: a Pi keybinding resolved live, or a raw key or command prefix. */
16
18
  export type HelpEntry =
@@ -23,14 +25,20 @@ export interface HeaderConfig {
23
25
  readonly appName: string;
24
26
  /** Print Pi's version on the agent line. */
25
27
  readonly showVersion: boolean;
26
- /** Theme token for the mark's top line. */
27
- readonly markColor: ThemeColor;
28
- /** Theme token for the mark's bottom line. */
29
- readonly markAccent: ThemeColor;
30
- /** Theme token for the agent and chrome names. */
31
- readonly nameColor: ThemeColor;
32
- /** Theme token for the versions, the tagline, and the help descriptions. */
33
- readonly versionColor: ThemeColor;
28
+ /** Role for the mark's top line. */
29
+ readonly markRole: TuiRole;
30
+ /** Role for the mark's bottom line. */
31
+ readonly markAccentRole: TuiRole;
32
+ /** Role for the agent name. */
33
+ readonly nameRole: TuiRole;
34
+ /** Role for the chrome name. */
35
+ readonly chromeRole: TuiRole;
36
+ /** Role for the versions, the tagline, and the help descriptions. */
37
+ readonly detailRole: TuiRole;
38
+ /** Role for the rule's accent lead under the mark. */
39
+ readonly ruleAccentRole: TuiRole;
40
+ /** Role for the rest of the rule. */
41
+ readonly ruleRole: TuiRole;
34
42
  /** Columns of left padding, matching Pi's built-in header indent. */
35
43
  readonly padX: number;
36
44
  /** The tagline before the chrome name, so the relationship reads in words. */
@@ -46,10 +54,13 @@ export interface HeaderConfig {
46
54
  export const HEADER_CONFIG: HeaderConfig = {
47
55
  appName: "Pi",
48
56
  showVersion: true,
49
- markColor: "accent",
50
- markAccent: "warning",
51
- nameColor: "muted",
52
- versionColor: "dim",
57
+ markRole: "accent",
58
+ markAccentRole: "warn",
59
+ nameRole: "label",
60
+ chromeRole: "meta",
61
+ detailRole: "faint",
62
+ ruleAccentRole: "accent",
63
+ ruleRole: "rule",
53
64
  padX: 1,
54
65
  chromeTagline: "enhanced by",
55
66
  chromeName: "bytefly",
@@ -74,7 +85,6 @@ export const HEADER_CONFIG: HeaderConfig = {
74
85
  { raw: "!", description: "bash" },
75
86
  { raw: "!!", description: "bash (no context)" },
76
87
  { raw: "/bytefly", description: "extensions" },
77
- { raw: "/doctor", description: "checks" },
78
88
  { binding: "app.message.followUp", description: "to queue follow-up" },
79
89
  { binding: "app.clipboard.pasteImage", description: "paste files" },
80
90
  ],
@@ -3,7 +3,7 @@
3
3
  * feature folders: `header/` (the themed startup mark and version), `footer/`
4
4
  * (the two-line, width-adaptive status bar with its price calculation),
5
5
  * `separator/` (the transcript turn rule), `panel/` (the unified `/bytefly`
6
- * screen, also reachable as `/extensions` and `/doctor`), `doctor/` (the
6
+ * screen and its `doctor` report), `doctor/` (the
7
7
  * `run_doctor` tool and its session-scoped probe runtime), and `notify/` (OS
8
8
  * notifications for unattended runs).
9
9
  *
@@ -1,12 +1,12 @@
1
1
  /**
2
- * The `/bytefly` surface: one runner that opens the unified panel, with
3
- * `/extensions` and `/doctor` registered against the same handler.
2
+ * The `/bytefly` surface: one command, `/bytefly [doctor] [extension] [refresh]`.
4
3
  *
5
- * In `tui` mode it mounts the dock screen; every other mode prints the same
6
- * information as text. Discovery, the doctor probe, and the dirty-to-reload
7
- * confirm all live here, so the three command names cannot drift. The doctor
8
- * runtime is injected by the composition root, so `run_doctor` and the screen
9
- * keep reading one session cache.
4
+ * Without `doctor` it opens the extension screen: the dock in `tui`, the
5
+ * grouped listing elsewhere. With `doctor` it produces the doctor report — the
6
+ * same text `run_doctor` returns — for the whole set or one named extension; in
7
+ * `tui` the report is the dock screen. Discovery, the doctor probe, and the
8
+ * dirty-to-reload confirm all live here. The doctor runtime is injected by the
9
+ * composition root, so `run_doctor` and the screen keep reading one cache.
10
10
  */
11
11
 
12
12
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
@@ -14,10 +14,10 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
14
14
  import { sanitizeLine } from "../../../lib/format.ts";
15
15
  import { withRailsSuppressed } from "../../../lib/rails.ts";
16
16
  import { REQUIREMENTS, type ExtensionReport } from "../../../lib/requirements.ts";
17
- import { formatExtension, hasProblems } from "../doctor/format.ts";
17
+ import { formatExtension, formatReport, hasProblems } from "../doctor/format.ts";
18
18
  import type { DoctorRuntime } from "../doctor/runtime.ts";
19
19
  import { discoverExtensions, type DiscoveryDeps } from "../picker/discovery.ts";
20
- import { createExtensionsPickerRuntime } from "../picker/runtime.ts";
20
+ import { createExtensionsPickerRuntime, type ExtensionsPickerRuntime } from "../picker/runtime.ts";
21
21
  import type { Discovery } from "../picker/types.ts";
22
22
  import { formatListing } from "./format.ts";
23
23
  import { buildPanelModel, extensionName } from "./join.ts";
@@ -42,20 +42,63 @@ function selectableNames(discovery: Discovery): Set<string> {
42
42
  return names;
43
43
  }
44
44
 
45
- /** The shared completion list: every declared extension, then `refresh`. */
45
+ /** Argument completions: the `doctor` subcommand, every declared extension, then `refresh`. */
46
46
  function completions(prefix: string): Array<{ value: string; label: string }> | null {
47
- const options = [...Object.keys(REQUIREMENTS), "refresh"].filter((option) => option.startsWith(prefix));
47
+ const options = ["doctor", ...Object.keys(REQUIREMENTS), "refresh"].filter((option) => option.startsWith(prefix));
48
48
  return options.length > 0 ? options.map((value) => ({ value, label: value })) : null;
49
49
  }
50
50
 
51
- /** Register `/bytefly`, `/extensions`, and `/doctor` against one runner. */
51
+ /** Which bytefly surface the arguments select. */
52
+ type PanelMode = "screen" | "doctor";
53
+
54
+ /** `/bytefly [doctor] [extension] [refresh]`, parsed. */
55
+ interface PanelArgs {
56
+ mode: PanelMode;
57
+ /** The extension to narrow to, when one is named. */
58
+ name?: string;
59
+ /** Re-probe the doctor instead of reusing the session cache. */
60
+ refresh: boolean;
61
+ }
62
+
63
+ /** Parse `/bytefly [doctor] [extension] [refresh]`; extra tokens are ignored. */
64
+ function parseArgs(args: string): PanelArgs {
65
+ const parts = args.trim().split(/\s+/).filter(Boolean);
66
+ const refresh = parts.includes("refresh");
67
+ const rest = parts.filter((part) => part !== "refresh");
68
+ const mode: PanelMode = rest[0] === "doctor" ? "doctor" : "screen";
69
+ return { mode, name: rest[mode === "doctor" ? 1 : 0], refresh };
70
+ }
71
+
72
+ /**
73
+ * Print one extension's full findings, or say why it has none. The screen's
74
+ * named form and the `doctor` subcommand share this text path.
75
+ */
76
+ function notifyExtension(
77
+ ctx: ExtensionCommandContext,
78
+ name: string,
79
+ reports: readonly ExtensionReport[],
80
+ runtime: ExtensionsPickerRuntime,
81
+ ): void {
82
+ const report = reports.find((entry) => entry.extension === name);
83
+ if (report) {
84
+ ctx.ui.notify(formatExtension(report), hasProblems([report]) ? "warning" : "info");
85
+ return;
86
+ }
87
+ const item = runtime.items().find((entry) => extensionName(entry.path) === name);
88
+ ctx.ui.notify(
89
+ item
90
+ ? `bytefly: ${sanitizeLine(item.path)} — no doctor report`
91
+ : `bytefly: no extension named "${sanitizeLine(name)}"`,
92
+ "info",
93
+ );
94
+ }
95
+
96
+ /** Register the single `/bytefly` command. */
52
97
  export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
53
98
  const discover = deps.discover ?? discoverExtensions;
54
99
 
55
100
  const handler = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
56
- const parts = args.trim().split(/\s+/).filter(Boolean);
57
- const refresh = parts.includes("refresh");
58
- const name = parts.find((part) => part !== "refresh");
101
+ const { mode, name, refresh } = parseArgs(args);
59
102
 
60
103
  const cwd = ctx.cwd ?? process.cwd();
61
104
  const agentDir = getAgentDir();
@@ -88,18 +131,11 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
88
131
 
89
132
  if (ctx.mode !== "tui") {
90
133
  if (name !== undefined) {
91
- const report = reports.find((entry) => entry.extension === name);
92
- if (report) {
93
- ctx.ui.notify(formatExtension(report), hasProblems([report]) ? "warning" : "info");
94
- return;
95
- }
96
- const item = runtime.items().find((entry) => extensionName(entry.path) === name);
97
- ctx.ui.notify(
98
- item
99
- ? `bytefly: ${sanitizeLine(item.path)} — no doctor report`
100
- : `bytefly: no extension named "${sanitizeLine(name)}"`,
101
- "info",
102
- );
134
+ notifyExtension(ctx, name, reports, runtime);
135
+ return;
136
+ }
137
+ if (mode === "doctor") {
138
+ ctx.ui.notify(formatReport(reports), hasProblems(reports) ? "warning" : "info");
103
139
  return;
104
140
  }
105
141
  const model = buildPanelModel({ items: runtime.items(), reports, agentDir });
@@ -136,12 +172,10 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
136
172
  if (reload) await ctx.reload();
137
173
  };
138
174
 
139
- const descriptions: Array<[string, string]> = [
140
- ["bytefly", "Open the bytefly extension screen: /bytefly [extension] [refresh]"],
141
- ["extensions", "List and enable/disable extensions"],
142
- ["doctor", "Check extensions and enable or disable one: /doctor [extension] [refresh]"],
143
- ];
144
- for (const [command, description] of descriptions) {
145
- pi.registerCommand(command, { description, getArgumentCompletions: completions, handler });
146
- }
175
+ pi.registerCommand("bytefly", {
176
+ description:
177
+ "Open the bytefly extension screen: /bytefly [extension] [refresh]; /bytefly doctor [extension] [refresh] prints the doctor report",
178
+ getArgumentCompletions: completions,
179
+ handler,
180
+ });
147
181
  }
@@ -8,18 +8,18 @@
8
8
  * came from a probe is sanitized by `doctor/format.ts`.
9
9
  */
10
10
 
11
- import type { ThemeColor } from "@earendil-works/pi-coding-agent";
12
- import { GLYPHS } from "../../../lib/ui.ts";
11
+ import type { TuiRole } from "../../../lib/tui/tokens.ts";
12
+ import { GLYPHS } from "../../../lib/tui/glyphs.ts";
13
13
  import { reportSeverity, reportSummary } from "../doctor/format.ts";
14
14
  import type { PanelModel, PanelRow } from "./types.ts";
15
15
 
16
16
  /** The settings scope the screen shows. */
17
17
  export type PanelScope = "global" | "project";
18
18
 
19
- /** One segment of the headline, with its semantic color for the TUI. */
19
+ /** One segment of the headline, with its design role for the TUI. */
20
20
  export interface SummarySegment {
21
21
  text: string;
22
- color: ThemeColor;
22
+ color: TuiRole;
23
23
  }
24
24
 
25
25
  /** Row and problem counts over a model. */
@@ -67,19 +67,19 @@ export function summarySegments(model: PanelModel, scope: PanelScope, options: S
67
67
  const counts = panelCounts(model);
68
68
  const segments: SummarySegment[] = [{ text: scope === "global" ? "Global" : "Project", color: "accent" }];
69
69
  if (options.filter !== undefined) {
70
- segments.push({ text: `${options.filter} only`, color: "muted" });
70
+ segments.push({ text: `${options.filter} only`, color: "meta" });
71
71
  } else {
72
72
  const noun = options.compact ? "ext" : counts.extensions === 1 ? "extension" : "extensions";
73
- segments.push({ text: `${counts.extensions} ${noun}`, color: "muted" });
73
+ segments.push({ text: `${counts.extensions} ${noun}`, color: "meta" });
74
74
  if (counts.disabled > 0) {
75
- segments.push({ text: `${counts.disabled} ${options.compact ? "off" : "disabled"}`, color: "muted" });
75
+ segments.push({ text: `${counts.disabled} ${options.compact ? "off" : "disabled"}`, color: "meta" });
76
76
  }
77
77
  }
78
78
  const problems: string[] = [];
79
79
  if (counts.missing > 0) problems.push(`${counts.missing} missing`);
80
80
  if (counts.degraded > 0) problems.push(`${counts.degraded} degraded`);
81
81
  if (problems.length > 0) {
82
- segments.push({ text: problems.join(", "), color: counts.missing > 0 ? "error" : "warning" });
82
+ segments.push({ text: problems.join(", "), color: counts.missing > 0 ? "bad" : "warn" });
83
83
  }
84
84
  return segments;
85
85
  }