@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
@@ -3,16 +3,20 @@
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 { BODY_INDENT } from "../../../lib/tui/indent.ts";
18
+ import { RULE_GLYPH } from "../../../lib/tui/rules.ts";
19
+ import { paint } from "../../../lib/tui/tokens.ts";
16
20
  import { HEADER_CONFIG, type HelpEntry } from "./config.ts";
17
21
 
18
22
  /** The header's shape, resolved once per apply so `render` stays cheap. */
@@ -21,6 +25,8 @@ export interface HeaderView {
21
25
  showVersion: boolean;
22
26
  /** Whether the terminal renders half blocks; false draws the wordmark instead. */
23
27
  halfBlocks: boolean;
28
+ /** Columns of left margin: Pi's output pad, so the nameplate sits on the transcript margin. */
29
+ pad: number;
24
30
  }
25
31
 
26
32
  /** The mark block is four columns on both rows, so the identity column aligns. */
@@ -33,9 +39,9 @@ function markBlock(line: string): string {
33
39
 
34
40
  /** The two mark lines: the half-block logo, or the wordmark where it will not render. */
35
41
  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"), ""];
42
+ const { markRole, markAccentRole } = HEADER_CONFIG;
43
+ if (halfBlocks) return [paint(theme, markRole, "█▀█"), paint(theme, markAccentRole, "█▀ █")];
44
+ return [paint(theme, markRole, "P") + paint(theme, markAccentRole, "i"), ""];
39
45
  }
40
46
 
41
47
  /** One help entry split into the text to print and its description. */
@@ -61,20 +67,21 @@ export class ByteflyHeader implements Component {
61
67
  const w = Math.max(1, width);
62
68
  const theme = this.getTheme();
63
69
  const view = this.view();
64
- const { padX, nameColor, versionColor, chromeTagline, chromeName, chromeVersion, help } = HEADER_CONFIG;
65
- const pad = " ".repeat(Math.min(padX, Math.max(0, w - 1)));
70
+ const { nameRole, chromeRole, detailRole, chromeTagline, chromeName, chromeVersion, help } = HEADER_CONFIG;
71
+ const padWidth = Math.max(0, Math.min(Math.floor(view.pad), Math.max(0, w - 1)));
72
+ const pad = " ".repeat(padWidth);
66
73
  const [markTop, markBottom] = markLines(theme, view.halfBlocks);
67
74
 
68
- const version = view.showVersion && this.version ? theme.fg(versionColor, `v${this.version}`) : "";
75
+ const version = view.showVersion && this.version ? paint(theme, detailRole, `v${this.version}`) : "";
69
76
  // The wordmark mark spells the app name, so the agent cell carries only the
70
77
  // version there; the half-block mark does not, so the name stays beside it.
71
78
  const agent = view.halfBlocks
72
79
  ? version
73
- ? `${theme.fg(nameColor, HEADER_CONFIG.appName)} ${version}`
74
- : theme.fg(nameColor, HEADER_CONFIG.appName)
80
+ ? `${theme.bold(paint(theme, nameRole, HEADER_CONFIG.appName))} ${version}`
81
+ : theme.bold(paint(theme, nameRole, HEADER_CONFIG.appName))
75
82
  : version;
76
- const chrome = `${theme.fg(versionColor, chromeTagline)} ${theme.fg(nameColor, chromeName)}`;
77
- const chromeVersioned = chromeVersion ? `${chrome} ${theme.fg(versionColor, `v${chromeVersion}`)}` : chrome;
83
+ const chrome = `${paint(theme, detailRole, chromeTagline)} ${paint(theme, chromeRole, chromeName)}`;
84
+ const chromeVersioned = chromeVersion ? `${chrome} ${paint(theme, detailRole, `v${chromeVersion}`)}` : chrome;
78
85
 
79
86
  // The identity rows shed as one block: chrome version, chrome line, agent.
80
87
  const states: Array<readonly [string, string]> = [
@@ -89,24 +96,45 @@ export class ByteflyHeader implements Component {
89
96
  return [
90
97
  this.identityLine(pad, markTop, agentCell, w),
91
98
  this.identityLine(pad, markBottom, chromeCell, w),
92
- `${pad}${theme.fg("borderMuted", "─".repeat(Math.max(0, w - pad.length)))}`,
93
- ...help.map((row) => this.helpLine(theme, w, row)),
99
+ this.rule(theme, w, pad),
100
+ ...help.map((row) => this.helpLine(theme, w, row, padWidth)),
94
101
  ];
95
102
  }
96
103
 
104
+ /**
105
+ * The nameplate's signature rule: an accent lead as wide as the mark, so the
106
+ * rule is anchored to the mark, then a muted fill to the transcript's right
107
+ * edge. The header stops where the nameplate and turn rules stop, one pad in,
108
+ * instead of running to the last terminal column.
109
+ */
110
+ private rule(theme: Theme, width: number, pad: string): string {
111
+ const remaining = Math.max(0, width - pad.length * 2);
112
+ const lead = Math.min(MARK_WIDTH, remaining);
113
+ const fill = remaining - lead;
114
+ return (
115
+ pad +
116
+ paint(theme, HEADER_CONFIG.ruleAccentRole, RULE_GLYPH.repeat(lead)) +
117
+ paint(theme, HEADER_CONFIG.ruleRole, RULE_GLYPH.repeat(fill))
118
+ );
119
+ }
120
+
97
121
  /** A nameplate row: the padded mark, a gutter, then the identity cell if it survived. */
98
122
  private identityLine(pad: string, mark: string, cell: string, width: number): string {
99
123
  const line = cell ? `${pad}${markBlock(mark)} ${cell}` : `${pad}${markBlock(mark)}`;
100
124
  return truncateToWidth(line.replace(/ +$/, ""), width, "");
101
125
  }
102
126
 
103
- /** One help group: whole entries only, dropped as the width runs out. */
104
- private helpLine(theme: Theme, width: number, row: readonly HelpEntry[]): string {
127
+ /**
128
+ * One help group: whole entries only, dropped as the width runs out. The hints
129
+ * hang one body indent inside the header's margin, like a screen's hint footer
130
+ * under its title rule.
131
+ */
132
+ private helpLine(theme: Theme, width: number, row: readonly HelpEntry[], padWidth: number): string {
105
133
  const hints = row
106
134
  .map(helpParts)
107
135
  .filter((part) => part.key)
108
136
  .map((part) => keyHintText(theme, part.key, part.description));
109
- return hints.length > 0 ? screenHint(theme, width, hints) : "";
137
+ return hints.length > 0 ? screenHint(theme, width, hints, padWidth + BODY_INDENT) : "";
110
138
  }
111
139
  }
112
140
 
@@ -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,16 +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;
34
- /** Columns of left padding, matching Pi's built-in header indent. */
35
- readonly padX: number;
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;
36
42
  /** The tagline before the chrome name, so the relationship reads in words. */
37
43
  readonly chromeTagline: string;
38
44
  /** The chrome's name. */
@@ -46,11 +52,13 @@ export interface HeaderConfig {
46
52
  export const HEADER_CONFIG: HeaderConfig = {
47
53
  appName: "Pi",
48
54
  showVersion: true,
49
- markColor: "accent",
50
- markAccent: "warning",
51
- nameColor: "muted",
52
- versionColor: "dim",
53
- padX: 1,
55
+ markRole: "accent",
56
+ markAccentRole: "warn",
57
+ nameRole: "label",
58
+ chromeRole: "meta",
59
+ detailRole: "faint",
60
+ ruleAccentRole: "accent",
61
+ ruleRole: "rule",
54
62
  chromeTagline: "enhanced by",
55
63
  chromeName: "bytefly",
56
64
  chromeVersion: pkg.version,
@@ -74,7 +82,6 @@ export const HEADER_CONFIG: HeaderConfig = {
74
82
  { raw: "!", description: "bash" },
75
83
  { raw: "!!", description: "bash (no context)" },
76
84
  { raw: "/bytefly", description: "extensions" },
77
- { raw: "/doctor", description: "checks" },
78
85
  { binding: "app.message.followUp", description: "to queue follow-up" },
79
86
  { binding: "app.clipboard.pasteImage", description: "paste files" },
80
87
  ],
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
11
  import { isAppleTerminalSession } from "@earendil-works/pi-tui";
12
+ import { DEFAULT_HOST_PAD } from "../pad.ts";
12
13
  import { createHeader, type HeaderView } from "./component.ts";
13
14
  import { HEADER_CONFIG } from "./config.ts";
14
15
 
@@ -17,10 +18,14 @@ export function registerHeader(pi: ExtensionAPI): void {
17
18
  // Half blocks misalign in Apple Terminal, so the mark falls back to a wordmark
18
19
  // there, mirroring Pi's own built-in header.
19
20
  const halfBlocks = !isAppleTerminalSession();
20
- const view = (): HeaderView => ({ showVersion: HEADER_CONFIG.showVersion, halfBlocks });
21
21
 
22
22
  const apply = (ctx: ExtensionContext): void => {
23
23
  if (ctx.mode !== "tui") return;
24
+ // The nameplate sits on the transcript margin, which is Pi's output pad — the
25
+ // value the tool frame and the notice inset by. An older host without
26
+ // `getSettings` keeps the default margin.
27
+ const pad = pi.getSettings?.().outputPad ?? DEFAULT_HOST_PAD;
28
+ const view = (): HeaderView => ({ showVersion: HEADER_CONFIG.showVersion, halfBlocks, pad });
24
29
  ctx.ui.setHeader(createHeader(() => ctx.ui.theme, view));
25
30
  };
26
31
 
@@ -1,30 +1,40 @@
1
1
  /**
2
- * bytefly — the package's terminal chrome and diagnostics, composed from six
2
+ * bytefly — the package's terminal chrome and diagnostics, composed from ten
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
- * `separator/` (the transcript turn rule), `panel/` (the unified `/bytefly`
6
- * screen, also reachable as `/extensions` and `/doctor`), `doctor/` (the
7
- * `run_doctor` tool and its session-scoped probe runtime), and `notify/` (OS
8
- * notifications for unattended runs).
5
+ * `message/` (the user-message nameplate), `notice/` (the shared transcript
6
+ * notice renderer), `block/` (the unified tool-block frame), `separator/` (the
7
+ * transcript turn rule), `working/` (the live elapsed counter on Pi's working
8
+ * row), `panel/` (the unified `/bytefly` screen and its
9
+ * `doctor` report), `doctor/` (the `run_doctor` tool and its session-scoped
10
+ * probe runtime), and `notify/` (OS notifications for unattended runs).
9
11
  *
10
- * The header, footer, and turn rule are installed only in interactive (`tui`)
11
- * sessions and are always on; `pi config` turns bytefly's chrome off. The panel
12
- * and doctor work in every mode, and notify works wherever the environment can
13
- * deliver it. The footer is priced from DeepSeek's published rates by default
14
- * and from `bytefly.json` otherwise, the same file whose `notify` section
15
- * configures notifications.
12
+ * The header, footer, turn rule, and working timer are installed only in
13
+ * interactive (`tui`) sessions, and are always on; the user-message marker,
14
+ * notice, and tool blocks register at load and take effect wherever a
15
+ * transcript renders. `pi config` turns bytefly's chrome off. The panel and doctor work
16
+ * in every mode, and notify works wherever the environment can deliver it. The
17
+ * footer is priced from DeepSeek's published rates by default and from
18
+ * `bytefly.json` otherwise, the same file whose `notify` section configures
19
+ * notifications.
16
20
  *
17
21
  * Load with: pi --extension ./extensions/bytefly
18
22
  */
19
23
 
20
24
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
25
  import { isExtensionEnabled } from "../../lib/env.ts";
26
+ import { registerToolBlocks } from "./block/register.ts";
27
+ import { registerChrome } from "./chrome.ts";
22
28
  import { type DoctorDeps, registerDoctor } from "./doctor/register.ts";
23
29
  import { registerFooter } from "./footer/register.ts";
24
30
  import { registerHeader } from "./header/register.ts";
31
+ import { registerUserMessage } from "./message/register.ts";
32
+ import { registerNotice } from "./notice/register.ts";
25
33
  import { type NotifyDeps, registerNotify } from "./notify/register.ts";
34
+ import { createPadStore } from "./pad.ts";
26
35
  import { type PanelCommandDeps, registerPanel } from "./panel/commands.ts";
27
36
  import { registerTurnSeparator } from "./separator/register.ts";
37
+ import { registerWorkingTimer } from "./working/register.ts";
28
38
 
29
39
  /** Host seams, injected by tests. */
30
40
  export interface ByteflyDeps {
@@ -42,7 +52,17 @@ export default function bytefly(pi: ExtensionAPI, deps: ByteflyDeps = {}): void
42
52
 
43
53
  registerHeader(pi);
44
54
  registerFooter(pi);
45
- registerTurnSeparator(pi);
55
+ registerUserMessage(pi);
56
+ // One store per load: the tool frame and the notice record Pi's output pad and
57
+ // the turn separator reads it, so bytefly's transcript blocks share one margin.
58
+ const pad = createPadStore();
59
+ // The rail glyph is read from `bytefly.json` at session start; the tool results
60
+ // and the notice read it through this one store.
61
+ const rail = registerChrome(pi);
62
+ registerNotice(pi, pad, rail);
63
+ registerToolBlocks(pi, pad, rail);
64
+ registerTurnSeparator(pi, pad);
65
+ registerWorkingTimer(pi);
46
66
  const doctor = registerDoctor(pi, deps.doctor);
47
67
  registerPanel(pi, { doctor, discover: deps.extensions?.discover });
48
68
  registerNotify(pi, deps.notify);
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Tuning for the user-message nameplate.
3
+ *
4
+ * Pi parses the transformed user message as Markdown and owns its colors, so
5
+ * this surface has no roles: it only names the label and the rule.
6
+ */
7
+
8
+ import { RULE_GLYPH } from "../../../lib/tui/rules.ts";
9
+
10
+ export const MESSAGE_CONFIG = {
11
+ /** The label word in the heading. */
12
+ label: "You",
13
+ /** Repeatable glyph that fills the rule row. */
14
+ ruleGlyph: RULE_GLYPH,
15
+ } as const;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The user-message nameplate: a two-row heading, the `You` label over a rule
3
+ * that spans the width, above the message.
4
+ *
5
+ * Pi's user message component parses the transformed string as Markdown and
6
+ * owns its colors, so shaping text is the only lever: the surface can add no
7
+ * component and no color of its own. The nameplate is a level-2 heading, which
8
+ * Pi renders bold in `mdHeading` and prints without its `#` prefix; the bytefly
9
+ * themes point `mdHeading` at the accent, so the label and rule carry the accent
10
+ * while the message itself stays in the default text color. Markdown cannot
11
+ * express a two-line heading, and Pi puts a blank line after a heading block,
12
+ * so the label is padded to the width and the rule is appended: the heading's
13
+ * text wraps, putting the rule on its own row with no blank between. A blank
14
+ * line separates the nameplate from the message, so the message's own Markdown
15
+ * (a fence, a list, a second paragraph) parses exactly as it would without it.
16
+ */
17
+
18
+ import type { MarkdownTransformContext } from "@earendil-works/pi-coding-agent";
19
+ import { visibleWidth } from "@earendil-works/pi-tui";
20
+ import { MESSAGE_CONFIG } from "./config.ts";
21
+
22
+ const { label, ruleGlyph } = MESSAGE_CONFIG;
23
+
24
+ /**
25
+ * The two-row nameplate: a heading whose text is the label, padded to the
26
+ * width, followed by a full-width rule. The wrap puts the rule on the second
27
+ * row; the whole nameplate is dropped when not even the label fits.
28
+ */
29
+ export function nameplate(width: number): string | undefined {
30
+ const labelWidth = visibleWidth(label);
31
+ const w = Math.max(1, Math.floor(width));
32
+ if (labelWidth > w) return undefined;
33
+ const pad = " ".repeat(w - labelWidth);
34
+ return `## ${label}${pad}${ruleGlyph.repeat(w)}`;
35
+ }
36
+
37
+ /** Put the nameplate above a user message; every other message passes through. */
38
+ export function styleUserMessage(markdown: string, context: MarkdownTransformContext): string {
39
+ if (context.messageType !== "user" || markdown.trim() === "") return markdown;
40
+ const head = nameplate(context.availableWidth);
41
+ return head ? `${head}\n\n${markdown}` : markdown;
42
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Registers bytefly's user-message markdown transformer.
3
+ *
4
+ * Registration happens at extension load, like the turn separator's entry
5
+ * renderer: the transformer only matters where a transcript renders, and Pi
6
+ * calls it for every message, so no session lifecycle is needed.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { styleUserMessage } from "./format.ts";
11
+
12
+ /** Register the transformer that marks each user message. */
13
+ export function registerUserMessage(pi: ExtensionAPI): void {
14
+ pi.registerMarkdownTransformer((markdown, context) => styleUserMessage(markdown, context));
15
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The transcript notice block, drawn from the shared `NoticeDetails`.
3
+ *
4
+ * Pure `noticeLines` shapes the body and hangs the rows from the transcript
5
+ * rail; `NoticeComponent` is the `Component` Pi mounts in place of its default
6
+ * custom-message box. Every field is untrusted — a job label, a matched log line
7
+ * — so it goes through `sanitizeLine` before it reaches the terminal, and every
8
+ * line is clipped to the width it is given. The rail is painted by the notice's
9
+ * worst severity, so a failure reads red at the edge like an errored tool
10
+ * result; the `source · title` headline stays at the margin.
11
+ */
12
+
13
+ import type { Theme } from "@earendil-works/pi-coding-agent";
14
+ import type { Component } from "@earendil-works/pi-tui";
15
+ import { sanitizeLine } from "../../../lib/format.ts";
16
+ import type { NoticeDetails, NoticeItem, NoticeSeverity } from "../../../lib/notice.ts";
17
+ import { GLYPH_GAP, SEPARATORS } from "../../../lib/tui/glyphs.ts";
18
+ import { BODY_INDENT, indentBlockLines } from "../../../lib/tui/indent.ts";
19
+ import { paint } from "../../../lib/tui/tokens.ts";
20
+ import { DEFAULT_RAIL_GLYPH, railGlyph } from "../chrome.ts";
21
+ import { NOTICE_CONFIG } from "./config.ts";
22
+
23
+ /** Severity order, so the rail can carry the worst one. */
24
+ const SEVERITY_RANK: Readonly<Record<NoticeSeverity, number>> = { info: 0, ok: 1, warn: 2, bad: 3 };
25
+
26
+ /** The severity a row draws with, falling back to the notice's own. */
27
+ function severityOf(item: NoticeItem, fallback: NoticeSeverity): NoticeSeverity {
28
+ return item.severity ?? fallback;
29
+ }
30
+
31
+ /** The worst severity across the headline and its rows; the rail's role. */
32
+ function worstSeverity(details: NoticeDetails): NoticeSeverity {
33
+ const fallback = details.severity ?? "info";
34
+ let worst: NoticeSeverity = fallback;
35
+ for (const item of details.items ?? []) {
36
+ const severity = severityOf(item, fallback);
37
+ if (SEVERITY_RANK[severity] > SEVERITY_RANK[worst]) worst = severity;
38
+ }
39
+ return worst;
40
+ }
41
+
42
+ /** One severity glyph, painted in its role. */
43
+ function severityGlyph(theme: Theme, severity: NoticeSeverity): string {
44
+ return paint(theme, NOTICE_CONFIG.glyphRoles[severity], NOTICE_CONFIG.glyphs[severity]);
45
+ }
46
+
47
+ /**
48
+ * The notice: a `source · title` headline at the margin and body-indented rows
49
+ * of `glyph label · detail` behind the rail, then a `… N more` line when the
50
+ * rows were capped. The rail is painted by the notice's worst severity. Every
51
+ * line is clipped to the width it is given; `pad` is Pi's output pad, the
52
+ * block's own left margin.
53
+ */
54
+ export function noticeLines(
55
+ details: NoticeDetails,
56
+ theme: Theme,
57
+ width: number,
58
+ pad = 0,
59
+ glyph = DEFAULT_RAIL_GLYPH,
60
+ ): string[] {
61
+ const w = Math.max(1, width);
62
+ const sep = paint(theme, NOTICE_CONFIG.detailRole, SEPARATORS.item);
63
+ const source = paint(theme, NOTICE_CONFIG.sourceRole, sanitizeLine(details.source));
64
+ const title = paint(theme, NOTICE_CONFIG.titleRole, sanitizeLine(details.title));
65
+ const items = details.items ?? [];
66
+ const shown = items.slice(0, NOTICE_CONFIG.maxItems);
67
+ const fallback = details.severity ?? "info";
68
+ const gap = " ".repeat(GLYPH_GAP);
69
+
70
+ const rows = shown.map((item) => {
71
+ const label = paint(theme, NOTICE_CONFIG.titleRole, sanitizeLine(item.label));
72
+ const detail = item.detail ? `${sep}${paint(theme, NOTICE_CONFIG.detailRole, sanitizeLine(item.detail))}` : "";
73
+ return `${severityGlyph(theme, severityOf(item, fallback))}${gap}${label}${detail}`;
74
+ });
75
+ const more = items.length - shown.length;
76
+ if (more > 0) {
77
+ rows.push(`${" ".repeat(1 + GLYPH_GAP)}${paint(theme, NOTICE_CONFIG.detailRole, `… ${more} more`)}`);
78
+ }
79
+
80
+ // The shared indent owns the column math: the headline at the margin, every
81
+ // row behind the rail, and each row clipped with the notice's ellipsis.
82
+ return indentBlockLines([`${source}${sep}${title}`, ...rows], w, {
83
+ indent: Math.max(0, Math.floor(pad)) + BODY_INDENT,
84
+ rail: railGlyph(theme, NOTICE_CONFIG.railRoles[worstSeverity(details)], glyph),
85
+ headline: true,
86
+ trimTrailing: false,
87
+ bodyClip: "…",
88
+ });
89
+ }
90
+
91
+ /** The notice block Pi mounts for a custom message carrying `NoticeDetails`. */
92
+ export class NoticeComponent implements Component {
93
+ constructor(
94
+ private readonly details: NoticeDetails,
95
+ private readonly theme: Theme,
96
+ private readonly pad = 0,
97
+ private readonly glyph = DEFAULT_RAIL_GLYPH,
98
+ ) {}
99
+
100
+ invalidate(): void {
101
+ // The rail is rebuilt from the live theme on every render.
102
+ }
103
+
104
+ render(width: number): string[] {
105
+ return noticeLines(this.details, this.theme, Math.max(1, width), this.pad, this.glyph);
106
+ }
107
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Tuning for the transcript notice block.
3
+ *
4
+ * The notice sits at the transcript's margin — a `source · title` headline and
5
+ * rows behind the shared transcript rail, each row led by its own severity
6
+ * glyph. The glyphs come from the shared vocabulary, the roles from the shared
7
+ * rail map, and the rail glyph from the `chrome` config, so a notice reads like
8
+ * the tool results around it.
9
+ */
10
+
11
+ import type { NoticeSeverity } from "../../../lib/notice.ts";
12
+ import { GLYPHS } from "../../../lib/tui/glyphs.ts";
13
+ import type { TuiRole } from "../../../lib/tui/tokens.ts";
14
+ import { RAIL_ROLES } from "../chrome.ts";
15
+
16
+ export const NOTICE_CONFIG = {
17
+ /** Role for the source name at the head of the block. */
18
+ sourceRole: "accent" as TuiRole,
19
+ /** Role for the headline's title text and each row's label. */
20
+ titleRole: "label" as TuiRole,
21
+ /** Role for the `·` separators and each row's trailing detail. */
22
+ detailRole: "faint" as TuiRole,
23
+ /** Width-1 glyph per severity, from the shared vocabulary. */
24
+ glyphs: {
25
+ info: GLYPHS.pending,
26
+ ok: GLYPHS.check,
27
+ warn: GLYPHS.warn,
28
+ bad: GLYPHS.cross,
29
+ } as Record<NoticeSeverity, string>,
30
+ /** Design role per severity for a row's glyph: a check is green, a cross red. */
31
+ glyphRoles: {
32
+ info: "info",
33
+ ok: "ok",
34
+ warn: "warn",
35
+ bad: "bad",
36
+ } as Record<NoticeSeverity, TuiRole>,
37
+ /** The rail's neutral-by-default role per severity, shared with every rail. */
38
+ railRoles: RAIL_ROLES,
39
+ /** Rows drawn before the rest collapse into a `… N more` line. */
40
+ maxItems: 8,
41
+ } as const;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Registers bytefly's renderer for the shared transcript notice.
3
+ *
4
+ * Registration happens at extension load, like the turn separator's entry
5
+ * renderer: a message renderer only matters where a transcript renders, so no
6
+ * session lifecycle is needed. Pi resolves it by the exact `NOTICE_TYPE`;
7
+ * without one it would fall back to its default custom-message box.
8
+ */
9
+
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { NOTICE_TYPE, type NoticeDetails } from "../../../lib/notice.ts";
12
+ import { createRailSource, type RailSource } from "../chrome.ts";
13
+ import { createPadStore, type PadSource } from "../pad.ts";
14
+ import { NoticeComponent } from "./component.ts";
15
+
16
+ /** Register the notice renderer; a message without details keeps Pi's default. */
17
+ export function registerNotice(
18
+ pi: ExtensionAPI,
19
+ pad: PadSource = createPadStore(),
20
+ rail: RailSource = createRailSource(),
21
+ ): void {
22
+ pi.registerMessageRenderer<NoticeDetails>(NOTICE_TYPE, (message, options, theme) =>
23
+ message.details
24
+ ? new NoticeComponent(message.details, theme, pad.resolve(options.outputPad), rail.resolve())
25
+ : undefined,
26
+ );
27
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The transcript's left margin, observed from Pi's built-in output padding.
3
+ *
4
+ * Pi pads its own messages, and hands the value to tool renderers
5
+ * (`context.outputPad`) and message renderers (`options.outputPad`) so they can
6
+ * pad themselves. Entry renderers get only `{ expanded }`, and Pi's chat
7
+ * container carries no padding of its own, so a surface that owns its output
8
+ * must inset itself. This tiny store is the one place the value is shared: the
9
+ * tool frame and the notice record the pad they are handed, and the turn
10
+ * separator reads the last one.
11
+ *
12
+ * There is no module state: the extension factory creates one store per load and
13
+ * injects it, so tests get a fresh one and a second session cannot leak either.
14
+ */
15
+
16
+ /** Where a bytefly transcript surface reads its left margin. */
17
+ export interface PadSource {
18
+ /**
19
+ * The host's output pad. Pass the pad Pi handed this render to record it for
20
+ * a surface that never receives one; omit it to read the last recorded value.
21
+ */
22
+ resolve(hostPad?: number): number;
23
+ }
24
+
25
+ /** Pi's output pad in an interactive session, before one is observed. */
26
+ export const DEFAULT_HOST_PAD = 1;
27
+
28
+ /** A store that follows Pi's output pad, falling back until one is observed. */
29
+ export function createPadStore(defaultHostPad: number = DEFAULT_HOST_PAD): PadSource {
30
+ const clamp = (value: number): number => Math.max(0, Math.floor(value));
31
+ let hostPad = Number.isFinite(defaultHostPad) ? clamp(defaultHostPad) : DEFAULT_HOST_PAD;
32
+ // A non-finite observation is ignored rather than resetting the margin.
33
+ const record = (observed: number): void => {
34
+ if (Number.isFinite(observed)) hostPad = clamp(observed);
35
+ };
36
+ return {
37
+ resolve(observed?: number): number {
38
+ if (observed !== undefined) record(observed);
39
+ return hostPad;
40
+ },
41
+ };
42
+ }