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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +35 -8
  2. package/README.zh-CN.md +33 -8
  3. package/assets/bytefly.schema.json +17 -1
  4. package/assets/chrome.png +0 -0
  5. package/assets/file-browse.png +0 -0
  6. package/assets/job.png +0 -0
  7. package/assets/plan-review.png +0 -0
  8. package/assets/question.png +0 -0
  9. package/assets/rewind.png +0 -0
  10. package/assets/todo.png +0 -0
  11. package/assets/tool-renderings.png +0 -0
  12. package/assets/tools/ask_user_question.png +0 -0
  13. package/assets/tools/bash.png +0 -0
  14. package/assets/tools/edit.png +0 -0
  15. package/assets/tools/enter_plan_mode.png +0 -0
  16. package/assets/tools/enter_worktree.png +0 -0
  17. package/assets/tools/exit_plan_mode.png +0 -0
  18. package/assets/tools/exit_worktree.png +0 -0
  19. package/assets/tools/find.png +0 -0
  20. package/assets/tools/goal.png +0 -0
  21. package/assets/tools/grep.png +0 -0
  22. package/assets/tools/job.png +0 -0
  23. package/assets/tools/list_worktrees.png +0 -0
  24. package/assets/tools/ls.png +0 -0
  25. package/assets/tools/memory.png +0 -0
  26. package/assets/tools/notice.png +0 -0
  27. package/assets/tools/open_path.png +0 -0
  28. package/assets/tools/prune_worktrees.png +0 -0
  29. package/assets/tools/read.png +0 -0
  30. package/assets/tools/read_doc.png +0 -0
  31. package/assets/tools/run_doctor.png +0 -0
  32. package/assets/tools/send_wechat.png +0 -0
  33. package/assets/tools/set_plan_steps.png +0 -0
  34. package/assets/tools/subagent.png +0 -0
  35. package/assets/tools/todo.png +0 -0
  36. package/assets/tools/turn-rule.png +0 -0
  37. package/assets/tools/user-message.png +0 -0
  38. package/assets/tools/watch.png +0 -0
  39. package/assets/tools/web_fetch.png +0 -0
  40. package/assets/tools/web_search.png +0 -0
  41. package/assets/tools/write.png +0 -0
  42. package/assets/tools/write_plan.png +0 -0
  43. package/extensions/bytefly/README.md +306 -68
  44. package/extensions/bytefly/block/component.ts +282 -0
  45. package/extensions/bytefly/block/config.ts +24 -0
  46. package/extensions/bytefly/block/fallback.ts +120 -0
  47. package/extensions/bytefly/block/register.ts +164 -0
  48. package/extensions/bytefly/chrome.ts +97 -0
  49. package/extensions/bytefly/doctor/format.ts +36 -10
  50. package/extensions/bytefly/doctor/tools.ts +10 -6
  51. package/extensions/bytefly/footer/config.ts +1 -1
  52. package/extensions/bytefly/footer/render/format.ts +5 -18
  53. package/extensions/bytefly/footer/render/layout.ts +2 -1
  54. package/extensions/bytefly/footer/render/segments.ts +51 -78
  55. package/extensions/bytefly/header/component.ts +49 -21
  56. package/extensions/bytefly/header/config.ts +28 -21
  57. package/extensions/bytefly/header/register.ts +6 -1
  58. package/extensions/bytefly/index.ts +32 -12
  59. package/extensions/bytefly/message/config.ts +15 -0
  60. package/extensions/bytefly/message/format.ts +42 -0
  61. package/extensions/bytefly/message/register.ts +15 -0
  62. package/extensions/bytefly/notice/component.ts +107 -0
  63. package/extensions/bytefly/notice/config.ts +41 -0
  64. package/extensions/bytefly/notice/register.ts +27 -0
  65. package/extensions/bytefly/pad.ts +42 -0
  66. package/extensions/bytefly/panel/commands.ts +69 -35
  67. package/extensions/bytefly/panel/format.ts +8 -8
  68. package/extensions/bytefly/panel/tui.ts +45 -70
  69. package/extensions/bytefly/separator/component.ts +2 -1
  70. package/extensions/bytefly/separator/config.ts +16 -8
  71. package/extensions/bytefly/separator/format.ts +18 -38
  72. package/extensions/bytefly/separator/register.ts +3 -2
  73. package/extensions/bytefly/separator/types.ts +0 -10
  74. package/extensions/bytefly/working/config.ts +20 -0
  75. package/extensions/bytefly/working/format.ts +25 -0
  76. package/extensions/bytefly/working/register.ts +88 -0
  77. package/extensions/document/README.md +12 -5
  78. package/extensions/document/paging.ts +10 -10
  79. package/extensions/document/render.ts +17 -16
  80. package/extensions/document/tool.ts +1 -1
  81. package/extensions/file-browse/README.md +54 -10
  82. package/extensions/file-browse/client.ts +110 -2
  83. package/extensions/file-browse/config.ts +12 -0
  84. package/extensions/file-browse/diff.ts +187 -0
  85. package/extensions/file-browse/git.ts +180 -8
  86. package/extensions/file-browse/html.ts +401 -25
  87. package/extensions/file-browse/index.ts +15 -4
  88. package/extensions/file-browse/markdown.ts +33 -0
  89. package/extensions/file-browse/router.ts +94 -2
  90. package/extensions/file-browse/server.ts +4 -1
  91. package/extensions/file-open/README.md +6 -0
  92. package/extensions/goal/README.md +16 -10
  93. package/extensions/goal/format.ts +13 -25
  94. package/extensions/goal/tools.ts +11 -22
  95. package/extensions/goal/tui.ts +12 -40
  96. package/extensions/job/README.md +22 -13
  97. package/extensions/job/format.ts +36 -13
  98. package/extensions/job/index.ts +26 -21
  99. package/extensions/job/tools.ts +15 -12
  100. package/extensions/job/tui.ts +148 -222
  101. package/extensions/job/ui.ts +24 -49
  102. package/extensions/memory/README.md +7 -1
  103. package/extensions/memory/format.ts +5 -3
  104. package/extensions/memory/tools.ts +4 -3
  105. package/extensions/memory/tui.ts +32 -36
  106. package/extensions/plan/README.md +86 -36
  107. package/extensions/plan/blocks.ts +44 -2
  108. package/extensions/plan/feedback.ts +20 -5
  109. package/extensions/plan/index.ts +1 -1
  110. package/extensions/plan/runtime.ts +3 -2
  111. package/extensions/plan/tools.ts +43 -27
  112. package/extensions/plan/tui.ts +21 -27
  113. package/extensions/plan/web-client.ts +386 -40
  114. package/extensions/plan/web-page.ts +138 -33
  115. package/extensions/plan/web-router.ts +46 -13
  116. package/extensions/plan/web.ts +63 -12
  117. package/extensions/question/README.md +14 -2
  118. package/extensions/question/answers.ts +25 -18
  119. package/extensions/question/tools.ts +22 -16
  120. package/extensions/question/tui.ts +132 -76
  121. package/extensions/rewind/README.md +3 -1
  122. package/extensions/rewind/format.ts +35 -2
  123. package/extensions/rewind/index.ts +3 -1
  124. package/extensions/rewind/rewind-tui.ts +66 -62
  125. package/extensions/rewind/snapshot-run.ts +1 -1
  126. package/extensions/rewind/status.ts +3 -2
  127. package/extensions/subagent/README.md +6 -0
  128. package/extensions/subagent/index.ts +1 -1
  129. package/extensions/subagent/render.ts +108 -66
  130. package/extensions/todo/README.md +20 -13
  131. package/extensions/todo/format.ts +47 -34
  132. package/extensions/todo/tools.ts +6 -4
  133. package/extensions/todo/tui.ts +46 -26
  134. package/extensions/watch/README.md +16 -8
  135. package/extensions/watch/format.ts +33 -13
  136. package/extensions/watch/index.ts +29 -24
  137. package/extensions/watch/tools.ts +16 -9
  138. package/extensions/watch/tui.ts +142 -196
  139. package/extensions/watch/ui.ts +24 -46
  140. package/extensions/web-access/README.md +16 -7
  141. package/extensions/web-access/fetch/tool.ts +12 -11
  142. package/extensions/web-access/search/tool.ts +16 -13
  143. package/extensions/wechat/README.md +8 -2
  144. package/extensions/wechat/index.ts +4 -3
  145. package/extensions/wechat/login-screen.ts +8 -5
  146. package/extensions/wechat/status-screen.ts +41 -22
  147. package/extensions/wechat/tools.ts +6 -5
  148. package/extensions/worktree/README.md +6 -0
  149. package/extensions/worktree/enter.ts +1 -1
  150. package/extensions/worktree/index.ts +1 -1
  151. package/extensions/worktree/runtime.ts +1 -1
  152. package/lib/README.md +11 -7
  153. package/lib/format.ts +17 -0
  154. package/lib/notice.ts +87 -0
  155. package/lib/requirements.ts +2 -2
  156. package/lib/{tui.ts → tui/chrome.ts} +17 -34
  157. package/lib/tui/columns.ts +96 -0
  158. package/lib/{list-cursor.ts → tui/cursor.ts} +58 -4
  159. package/lib/tui/gauge.ts +78 -0
  160. package/lib/{ui.ts → tui/glyphs.ts} +10 -11
  161. package/lib/tui/indent.ts +166 -0
  162. package/lib/tui/index.ts +27 -0
  163. package/lib/tui/line-pane.ts +173 -0
  164. package/lib/tui/rows.ts +143 -0
  165. package/lib/tui/rules.ts +94 -0
  166. package/lib/tui/status-controller.ts +107 -0
  167. package/lib/tui/tokens.ts +72 -0
  168. package/lib/web/base.ts +2 -1
  169. package/lib/web/components.ts +3 -1
  170. package/lib/web/layout.ts +5 -1
  171. package/package.json +7 -3
  172. package/themes/bytefly-dark.json +7 -8
  173. package/themes/bytefly-light.json +10 -11
  174. package/assets/bytefly-chrome.png +0 -0
@@ -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
  }
@@ -5,33 +5,29 @@
5
5
  * The component is derived: it rebuilds the merged model from the picker
6
6
  * runtime and the current reports on every render, keeps the focused row's key
7
7
  * across rebuilds, and requests a repaint only after an input changes state.
8
- * Screen chrome and scroll math come from `lib/tui.ts` and `lib/list-cursor.ts`.
9
- * A row without a report renders no glyph and no summary, never a false `ok`.
8
+ * The screen is drawn from the design system — a label-first `titleRule`, one
9
+ * `listRow` grammar, one `checkbox` — and its scroll math from
10
+ * `lib/tui/cursor.ts`. A row without a report renders no glyph and no summary,
11
+ * never a false `ok`.
10
12
  */
11
13
 
12
14
  import type { Theme } from "@earendil-works/pi-coding-agent";
13
- import {
14
- Key,
15
- matchesKey,
16
- truncateToWidth,
17
- visibleWidth,
18
- type Component,
19
- type TuiMouseEvent,
20
- type TuiMouseEventResult,
21
- } from "@earendil-works/pi-tui";
15
+ import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
22
16
  import { sanitizeLine } from "../../../lib/format.ts";
23
- import {
24
- fitRows,
25
- formatRange,
26
- isCloseKey,
27
- ListCursor,
28
- navIntent,
29
- selectionMarker,
30
- wheelDelta,
31
- } from "../../../lib/list-cursor.ts";
17
+ import { dockListFrame, screenHint, type ViewportRowsSource } from "../../../lib/tui/chrome.ts";
18
+ import { clip, fitCells } from "../../../lib/tui/columns.ts";
19
+ import { fitRows, formatRange, isCloseKey, ListCursor, navIntent, wheelDelta } from "../../../lib/tui/cursor.ts";
20
+ import { checkbox, emptyState, listRow } from "../../../lib/tui/rows.ts";
21
+ import { titleRule } from "../../../lib/tui/rules.ts";
22
+ import { paint } from "../../../lib/tui/tokens.ts";
32
23
  import type { ExtensionReport } from "../../../lib/requirements.ts";
33
- import { dockListFrame, screenHint, type ViewportRowsSource } from "../../../lib/tui.ts";
34
- import { detailLines as reportDetailLines, reportSeverity, reportSummary, severityGlyph } from "../doctor/format.ts";
24
+ import {
25
+ detailLines as reportDetailLines,
26
+ reportSeverity,
27
+ reportSummary,
28
+ severityGlyphChar,
29
+ severityRole,
30
+ } from "../doctor/format.ts";
35
31
  import type { ExtensionsPickerRuntime } from "../picker/runtime.ts";
36
32
  import { summarySegments } from "./format.ts";
37
33
  import { buildPanelModel } from "./join.ts";
@@ -283,50 +279,37 @@ export class PanelComponent implements Component {
283
279
  return { handled: true };
284
280
  }
285
281
 
282
+ /** The headline: whole segments only, so a narrow screen drops a count. */
286
283
  private summary(width: number, model: PanelModel): string {
287
284
  const segments = summarySegments(model, this.options.runtime.scope(), {
288
285
  filter: this.options.filter,
289
286
  compact: width < 72,
290
287
  });
291
- const separator = this.theme.fg("dim", " · ");
292
- const kept: string[] = [];
293
- for (const segment of segments) {
294
- const styled = this.theme.fg(segment.color, segment.text);
295
- if (kept.length > 0 && visibleWidth(` ${[...kept, styled].join(separator)}`) > width) break;
296
- kept.push(styled);
297
- }
298
- return truncateToWidth(` ${kept.join(separator)}`, width, "…");
288
+ const separator = paint(this.theme, "faint", " · ");
289
+ const styled = segments.map((segment) => paint(this.theme, segment.color, segment.text));
290
+ return clip(` ${fitCells(styled, Math.max(1, width - 2), separator)}`, width);
299
291
  }
300
292
 
301
293
  private groupLine(entry: Extract<PanelEntry, { kind: "group" }>, width: number): string {
302
- const label = this.theme.fg("muted", sanitizeLine(entry.label));
303
- const count = this.theme.fg("dim", `(${entry.count})`);
304
- return truncateToWidth(` ${label} ${count}`, width, "…");
294
+ const label = paint(this.theme, "meta", sanitizeLine(entry.label));
295
+ const count = paint(this.theme, "faint", `(${entry.count})`);
296
+ return clip(` ${label} ${count}`, width);
305
297
  }
306
298
 
307
299
  private rowLine(row: PanelRow, selected: boolean, width: number): string {
308
- const checkbox = row.item
309
- ? row.item.enabled
310
- ? this.theme.fg("success", "[x]")
311
- : this.theme.fg("dim", "[ ]")
312
- : this.theme.fg("dim", " ");
313
300
  const severity = row.report ? reportSeverity(row.report) : undefined;
314
- const glyph = severity ? severityGlyph(severity, this.theme) : " ";
315
- const label = sanitizeLine(row.label);
316
- const styled = selected
317
- ? this.theme.fg("accent", label)
318
- : row.item && !row.item.enabled
319
- ? this.theme.fg("dim", label)
320
- : label;
321
- const summary = row.report ? reportSummary(row.report) : "";
322
- const head = `${selectionMarker(this.theme, selected)}${checkbox} ${glyph} `;
323
- if (!summary) return truncateToWidth(`${head}${styled}`, width, "…");
324
-
325
- const available = Math.max(1, width - visibleWidth(head) - visibleWidth(summary) - 1);
326
- const name = truncateToWidth(styled, available, "…");
327
- const pad = " ".repeat(Math.max(0, available - visibleWidth(name)));
328
- const tone = severity === "missing" ? "error" : severity === "degraded" ? "warning" : "dim";
329
- return truncateToWidth(`${head}${name}${pad} ${this.theme.fg(tone, summary)}`, width, "…");
301
+ return listRow(this.theme, width, {
302
+ selected,
303
+ // A reported-only row has no enable pattern, but still reserves the box
304
+ // column so the list stays aligned.
305
+ leading: row.item ? checkbox(this.theme, row.item.enabled) : " ",
306
+ glyph: severity ? severityGlyphChar(severity) : " ",
307
+ glyphRole: severity ? severityRole(severity) : "faint",
308
+ label: sanitizeLine(row.label),
309
+ labelRole: selected ? "accent" : row.item && !row.item.enabled ? "faint" : "label",
310
+ trailing: row.report ? reportSummary(row.report) : undefined,
311
+ trailingRole: severity === "missing" ? "bad" : severity === "degraded" ? "warn" : "faint",
312
+ });
330
313
  }
331
314
 
332
315
  /** The focused row's label, findings, and the pending-changes note. */
@@ -334,19 +317,14 @@ export class PanelComponent implements Component {
334
317
  const lines: string[] = [];
335
318
  if (row?.report) {
336
319
  const tail = reportSummary(row.report);
337
- lines.push(
338
- truncateToWidth(
339
- ` ${this.theme.fg("muted", sanitizeLine(row.label))} ${this.theme.fg("dim", "—")} ${this.theme.fg(row.report.enabled ? "muted" : "warning", tail)}`,
340
- width,
341
- "…",
342
- ),
343
- );
320
+ const head = `${paint(this.theme, "meta", sanitizeLine(row.label))} ${paint(this.theme, "faint", "—")} ${paint(this.theme, row.report.enabled ? "meta" : "warn", tail)}`;
321
+ lines.push(clip(` ${head}`, width));
344
322
  if (budget > 0) lines.push(...reportDetailLines(row.report, this.theme, width, budget, this.showDetail));
345
323
  } else if (row?.item) {
346
- lines.push(truncateToWidth(` ${this.theme.fg("muted", sanitizeLine(row.item.path))}`, width, "…"));
324
+ lines.push(clip(` ${paint(this.theme, "meta", sanitizeLine(row.item.path))}`, width));
347
325
  }
348
326
  if (this.options.runtime.isDirty()) {
349
- lines.push(truncateToWidth(` ${this.theme.fg("warning", "Saved · reload to apply")}`, width, "…"));
327
+ lines.push(clip(` ${paint(this.theme, "warn", "Saved · reload to apply")}`, width));
350
328
  }
351
329
  return lines;
352
330
  }
@@ -356,14 +334,13 @@ export class PanelComponent implements Component {
356
334
  const model = this.model();
357
335
  const entries = entriesOf(model);
358
336
  const summary = this.summary(w, model);
337
+ const header = titleRule(this.theme, w, "bytefly");
359
338
 
360
339
  if (entries.length === 0) {
361
340
  return dockListFrame({
362
- theme: this.theme,
363
- width: w,
364
- title: "bytefly",
341
+ header,
365
342
  summary,
366
- body: [truncateToWidth(` ${this.theme.fg("dim", "No extensions found.")}`, w)],
343
+ body: emptyState(this.theme, w, { label: "No extensions found." }),
367
344
  hint: screenHint(this.theme, w, ["Esc close"]),
368
345
  });
369
346
  }
@@ -394,9 +371,7 @@ export class PanelComponent implements Component {
394
371
  }
395
372
 
396
373
  return dockListFrame({
397
- theme: this.theme,
398
- width: w,
399
- title: "bytefly",
374
+ header,
400
375
  summary,
401
376
  body,
402
377
  range: this.cursor.clipped(entries.length)
@@ -14,6 +14,7 @@ export class SeparatorComponent implements Component {
14
14
  constructor(
15
15
  private readonly turn: number,
16
16
  private readonly theme: Theme,
17
+ private readonly pad = 0,
17
18
  ) {}
18
19
 
19
20
  invalidate(): void {
@@ -21,6 +22,6 @@ export class SeparatorComponent implements Component {
21
22
  }
22
23
 
23
24
  render(width: number): string[] {
24
- return separatorLine(this.turn, width, this.theme);
25
+ return separatorLine(this.turn, width, this.theme, this.pad);
25
26
  }
26
27
  }
@@ -2,11 +2,12 @@
2
2
  * Tuning for bytefly's turn separator.
3
3
  *
4
4
  * Edit this file to change how the line between completed turns looks. The
5
- * glyphs and colors are plain theme tokens, so the line follows the active
6
- * theme automatically.
5
+ * glyph and the colors are plain design-system roles, so the line follows the
6
+ * active theme automatically.
7
7
  */
8
8
 
9
- import { GLYPHS } from "../../../lib/ui.ts";
9
+ import { GLYPHS } from "../../../lib/tui/glyphs.ts";
10
+ import type { TuiRole } from "../../../lib/tui/tokens.ts";
10
11
 
11
12
  export const CONFIG = {
12
13
  /** Custom entry type. Changing it orphans separators recorded by an older build. */
@@ -17,8 +18,15 @@ export const CONFIG = {
17
18
  labelPrefix: "turn",
18
19
  /** Spaces on each side of the label. */
19
20
  pad: 1,
20
- /** Theme token for the dashes. */
21
- dashColor: "border",
22
- /** Theme token for the label. */
23
- labelColor: "muted",
24
- } as const;
21
+ /** Role for the dashes. */
22
+ dashRole: "ruleStrong",
23
+ /** Role for the label. */
24
+ labelRole: "meta",
25
+ } as const satisfies {
26
+ customType: string;
27
+ glyph: string;
28
+ labelPrefix: string;
29
+ pad: number;
30
+ dashRole: TuiRole;
31
+ labelRole: TuiRole;
32
+ };
@@ -1,50 +1,30 @@
1
1
  /**
2
2
  * Pure formatting for bytefly's turn separator line.
3
3
  *
4
- * `separatorParts` decides the layout from a plain turn number and a column
5
- * count; `separatorLine` applies theme colors. Neither touches Pi state, so
6
- * both are cheap to unit test.
4
+ * The centered `╌╌╌ turn N ╌╌╌` layout comes from the design system's
5
+ * `centeredRule`, so this module only names the turn; the shared rule owns the
6
+ * centering, the degradation, and the colors.
7
7
  */
8
8
 
9
9
  import type { Theme } from "@earendil-works/pi-coding-agent";
10
- import { visibleWidth } from "@earendil-works/pi-tui";
10
+ import { centeredRule } from "../../../lib/tui/rules.ts";
11
11
  import { CONFIG } from "./config.ts";
12
- import type { SeparatorParts } from "./types.ts";
13
-
14
- /** ` turn N ` — the label printed in the middle of the line. */
15
- export function separatorLabel(turn: number): string {
16
- const pad = " ".repeat(CONFIG.pad);
17
- return `${pad}${CONFIG.labelPrefix} ${turn}${pad}`;
18
- }
19
12
 
20
13
  /**
21
- * Split `width` columns into centered dashes and a label.
14
+ * One rendered line: the centered turn rule, colored by role.
22
15
  *
23
- * When the label does not fit, the line degrades to a plain dashed rule so it
24
- * never overflows the transcript.
16
+ * `pad` is Pi's output pad. Pi gives an entry renderer no pad and adds its
17
+ * component raw, so the rule insets itself symmetrically by that many columns,
18
+ * keeping its left edge on the transcript's margin.
25
19
  */
26
- export function separatorParts(turn: number, width: number): SeparatorParts {
27
- const target = Math.max(1, Math.floor(width));
28
- const label = separatorLabel(turn);
29
- const labelWidth = visibleWidth(label);
30
-
31
- if (labelWidth >= target) {
32
- return { left: CONFIG.glyph.repeat(target), label: "", right: "" };
33
- }
34
-
35
- const remaining = target - labelWidth;
36
- const left = Math.floor(remaining / 2);
37
- const right = remaining - left;
38
- return {
39
- left: CONFIG.glyph.repeat(left),
40
- label,
41
- right: CONFIG.glyph.repeat(right),
42
- };
43
- }
44
-
45
- /** One rendered line: colored dashes around a colored label. */
46
- export function separatorLine(turn: number, width: number, theme: Theme): string[] {
47
- const { left, label, right } = separatorParts(turn, width);
48
- const dashes = (text: string) => (text ? theme.fg(CONFIG.dashColor, text) : "");
49
- return [dashes(left) + (label ? theme.fg(CONFIG.labelColor, label) : "") + dashes(right)];
20
+ export function separatorLine(turn: number, width: number, theme: Theme, pad = 0): string[] {
21
+ const w = Math.max(1, Math.floor(width));
22
+ const inset = Math.max(0, Math.min(Math.floor(pad), Math.floor((w - 1) / 2)));
23
+ const line = centeredRule(theme, Math.max(1, w - inset * 2), `${CONFIG.labelPrefix} ${turn}`, {
24
+ glyph: CONFIG.glyph,
25
+ pad: CONFIG.pad,
26
+ fillRole: CONFIG.dashRole,
27
+ labelRole: CONFIG.labelRole,
28
+ });
29
+ return [`${" ".repeat(inset)}${line}`];
50
30
  }
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
+ import { createPadStore, type PadSource } from "../pad.ts";
15
16
  import { SeparatorComponent } from "./component.ts";
16
17
  import { CONFIG } from "./config.ts";
17
18
  import type { TurnSeparatorData } from "./types.ts";
@@ -26,11 +27,11 @@ function countSeparators(entries: readonly { type: string; customType?: string }
26
27
  }
27
28
 
28
29
  /** Register the separator renderer and append one on every settled turn. */
29
- export function registerTurnSeparator(pi: ExtensionAPI): void {
30
+ export function registerTurnSeparator(pi: ExtensionAPI, pad: PadSource = createPadStore()): void {
30
31
  pi.registerEntryRenderer<TurnSeparatorData>(CONFIG.customType, (entry, _options, theme) => {
31
32
  const turn = entry.data?.turn;
32
33
  if (typeof turn !== "number" || !Number.isFinite(turn)) return undefined;
33
- return new SeparatorComponent(turn, theme);
34
+ return new SeparatorComponent(turn, theme, pad.resolve());
34
35
  });
35
36
 
36
37
  pi.on("agent_settled", (_event, ctx) => {
@@ -10,13 +10,3 @@ export interface TurnSeparatorData {
10
10
  /** 1-based index of the completed user turn this separator follows. */
11
11
  turn: number;
12
12
  }
13
-
14
- /** A separator line split into its dashes and label. */
15
- export interface SeparatorParts {
16
- /** Dashes before the label. */
17
- left: string;
18
- /** The centred `turn N` label. */
19
- label: string;
20
- /** Dashes after the label. */
21
- right: string;
22
- }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Tuning for bytefly's live working timer.
3
+ *
4
+ * While Pi's working row is up, the counter is written into the working message
5
+ * Pi renders beside its spinner. Edit this file to change the word or how often
6
+ * the count advances.
7
+ */
8
+
9
+ export const WORKING_CONFIG = {
10
+ /** Word before the count. */
11
+ working: "Working",
12
+ /** Between the word and the count. */
13
+ ellipsis: "…",
14
+ /** How often the count advances, in milliseconds. */
15
+ intervalMs: 1000,
16
+ } as const satisfies {
17
+ working: string;
18
+ ellipsis: string;
19
+ intervalMs: number;
20
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Pure text for the live working timer.
3
+ *
4
+ * Whole seconds, because the counter advances on a one-second tick: `0s` at the
5
+ * start, `42s`, `1m05s`, `1h02m`. Negative and non-finite input reads as `0s`,
6
+ * so a clock that steps backwards cannot print a negative count.
7
+ */
8
+
9
+ import { WORKING_CONFIG } from "./config.ts";
10
+
11
+ /** Compact elapsed time in whole seconds: `0s`, `42s`, `1m05s`, `1h02m`. */
12
+ export function formatElapsed(ms: number): string {
13
+ const total = Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0;
14
+ const seconds = total % 60;
15
+ const minutes = Math.floor(total / 60) % 60;
16
+ const hours = Math.floor(total / 3600);
17
+ if (hours > 0) return `${hours}h${String(minutes).padStart(2, "0")}m`;
18
+ if (minutes > 0) return `${minutes}m${String(seconds).padStart(2, "0")}s`;
19
+ return `${seconds}s`;
20
+ }
21
+
22
+ /** The working-row message for an elapsed time, e.g. `Working… 4s`. */
23
+ export function workingCounter(ms: number): string {
24
+ return `${WORKING_CONFIG.working}${WORKING_CONFIG.ellipsis} ${formatElapsed(ms)}`;
25
+ }