@gavin-hu/my-pi-agent 0.9.0 → 0.11.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 (140) hide show
  1. package/README.md +28 -2
  2. package/README.zh-CN.md +27 -2
  3. package/assets/bytefly.schema.json +17 -1
  4. package/assets/chrome.png +0 -0
  5. package/assets/job.png +0 -0
  6. package/assets/plan-review.png +0 -0
  7. package/assets/question.png +0 -0
  8. package/assets/rewind.png +0 -0
  9. package/assets/todo.png +0 -0
  10. package/assets/tool-renderings.png +0 -0
  11. package/assets/tools/ask_user_question.png +0 -0
  12. package/assets/tools/bash.png +0 -0
  13. package/assets/tools/edit.png +0 -0
  14. package/assets/tools/enter_plan_mode.png +0 -0
  15. package/assets/tools/enter_worktree.png +0 -0
  16. package/assets/tools/exit_plan_mode.png +0 -0
  17. package/assets/tools/exit_worktree.png +0 -0
  18. package/assets/tools/find.png +0 -0
  19. package/assets/tools/goal.png +0 -0
  20. package/assets/tools/grep.png +0 -0
  21. package/assets/tools/job.png +0 -0
  22. package/assets/tools/list_worktrees.png +0 -0
  23. package/assets/tools/ls.png +0 -0
  24. package/assets/tools/memory.png +0 -0
  25. package/assets/tools/notice.png +0 -0
  26. package/assets/tools/open_path.png +0 -0
  27. package/assets/tools/prune_worktrees.png +0 -0
  28. package/assets/tools/read.png +0 -0
  29. package/assets/tools/read_doc.png +0 -0
  30. package/assets/tools/run_doctor.png +0 -0
  31. package/assets/tools/send_wechat.png +0 -0
  32. package/assets/tools/set_plan_steps.png +0 -0
  33. package/assets/tools/subagent.png +0 -0
  34. package/assets/tools/todo.png +0 -0
  35. package/assets/tools/turn-rule.png +0 -0
  36. package/assets/tools/user-message.png +0 -0
  37. package/assets/tools/watch.png +0 -0
  38. package/assets/tools/web_fetch.png +0 -0
  39. package/assets/tools/web_search.png +0 -0
  40. package/assets/tools/write.png +0 -0
  41. package/assets/tools/write_plan.png +0 -0
  42. package/extensions/bytefly/README.md +261 -36
  43. package/extensions/bytefly/block/component.ts +282 -0
  44. package/extensions/bytefly/block/config.ts +24 -0
  45. package/extensions/bytefly/block/fallback.ts +120 -0
  46. package/extensions/bytefly/block/register.ts +164 -0
  47. package/extensions/bytefly/chrome.ts +97 -0
  48. package/extensions/bytefly/doctor/tools.ts +5 -2
  49. package/extensions/bytefly/header/component.ts +21 -10
  50. package/extensions/bytefly/header/config.ts +0 -3
  51. package/extensions/bytefly/header/register.ts +6 -1
  52. package/extensions/bytefly/index.ts +32 -12
  53. package/extensions/bytefly/message/config.ts +15 -0
  54. package/extensions/bytefly/message/format.ts +42 -0
  55. package/extensions/bytefly/message/register.ts +15 -0
  56. package/extensions/bytefly/notice/component.ts +107 -0
  57. package/extensions/bytefly/notice/config.ts +41 -0
  58. package/extensions/bytefly/notice/register.ts +27 -0
  59. package/extensions/bytefly/pad.ts +42 -0
  60. package/extensions/bytefly/separator/component.ts +2 -1
  61. package/extensions/bytefly/separator/format.ts +17 -10
  62. package/extensions/bytefly/separator/register.ts +3 -2
  63. package/extensions/bytefly/working/config.ts +20 -0
  64. package/extensions/bytefly/working/format.ts +25 -0
  65. package/extensions/bytefly/working/register.ts +88 -0
  66. package/extensions/document/README.md +11 -4
  67. package/extensions/document/paging.ts +10 -10
  68. package/extensions/document/render.ts +7 -7
  69. package/extensions/file-browse/README.md +3 -1
  70. package/extensions/file-browse/client.ts +9 -2
  71. package/extensions/file-browse/git.ts +4 -7
  72. package/extensions/file-browse/html.ts +27 -17
  73. package/extensions/file-open/README.md +6 -0
  74. package/extensions/goal/README.md +16 -10
  75. package/extensions/goal/format.ts +7 -20
  76. package/extensions/goal/tools.ts +5 -17
  77. package/extensions/goal/tui.ts +12 -40
  78. package/extensions/job/README.md +113 -35
  79. package/extensions/job/commands.ts +1 -0
  80. package/extensions/job/config.ts +4 -0
  81. package/extensions/job/format.ts +19 -7
  82. package/extensions/job/index.ts +25 -20
  83. package/extensions/job/process.ts +24 -2
  84. package/extensions/job/registry.ts +3 -0
  85. package/extensions/job/runtime.ts +253 -18
  86. package/extensions/job/schema.ts +34 -0
  87. package/extensions/job/store.ts +13 -1
  88. package/extensions/job/tools.ts +55 -15
  89. package/extensions/job/tui.ts +41 -15
  90. package/extensions/job/types.ts +9 -1
  91. package/extensions/memory/README.md +6 -0
  92. package/extensions/memory/format.ts +5 -3
  93. package/extensions/plan/README.md +32 -12
  94. package/extensions/plan/tools.ts +20 -5
  95. package/extensions/plan/web-client.ts +58 -17
  96. package/extensions/plan/web-page.ts +34 -9
  97. package/extensions/plan/web-router.ts +40 -9
  98. package/extensions/plan/web.ts +22 -2
  99. package/extensions/question/README.md +13 -1
  100. package/extensions/question/answers.ts +25 -18
  101. package/extensions/question/tools.ts +11 -6
  102. package/extensions/rewind/README.md +2 -0
  103. package/extensions/rewind/format.ts +6 -1
  104. package/extensions/rewind/index.ts +3 -1
  105. package/extensions/rewind/snapshot-run.ts +1 -1
  106. package/extensions/subagent/README.md +6 -0
  107. package/extensions/subagent/index.ts +1 -1
  108. package/extensions/subagent/render.ts +56 -29
  109. package/extensions/todo/README.md +19 -12
  110. package/extensions/todo/format.ts +17 -25
  111. package/extensions/todo/tools.ts +2 -1
  112. package/extensions/todo/tui.ts +3 -2
  113. package/extensions/watch/README.md +14 -6
  114. package/extensions/watch/format.ts +2 -2
  115. package/extensions/watch/index.ts +28 -23
  116. package/extensions/watch/tools.ts +5 -3
  117. package/extensions/watch/tui.ts +12 -7
  118. package/extensions/web-access/README.md +16 -7
  119. package/extensions/web-access/fetch/tool.ts +1 -1
  120. package/extensions/web-access/search/tool.ts +3 -3
  121. package/extensions/wechat/README.md +6 -0
  122. package/extensions/worktree/README.md +6 -0
  123. package/lib/README.md +2 -1
  124. package/lib/format.ts +17 -0
  125. package/lib/notice.ts +87 -0
  126. package/lib/tui/chrome.ts +8 -4
  127. package/lib/tui/cursor.ts +2 -1
  128. package/lib/tui/glyphs.ts +10 -11
  129. package/lib/tui/indent.ts +166 -0
  130. package/lib/tui/index.ts +3 -1
  131. package/lib/tui/rows.ts +39 -5
  132. package/lib/tui/rules.ts +9 -3
  133. package/lib/tui/tokens.ts +14 -0
  134. package/lib/web/base.ts +2 -1
  135. package/lib/web/components.ts +3 -1
  136. package/lib/web/layout.ts +5 -1
  137. package/package.json +7 -3
  138. package/themes/bytefly-dark.json +7 -8
  139. package/themes/bytefly-light.json +10 -11
  140. package/assets/bytefly-chrome.png +0 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The transcript rail and the shared `chrome` section of `bytefly.json`.
3
+ *
4
+ * The rail is the vertical stroke that leads a block's body — a tool result, a
5
+ * notice row — while the headline stays at the margin. Its glyph is the one
6
+ * configurable part; the geometry is fixed by `lib/tui/indent.ts`. A glyph must
7
+ * be exactly one visible column, or it would push the body right and drift the
8
+ * width math, so `normalizeChromeConfig` rejects anything else and falls back.
9
+ *
10
+ * The value is read at `session_start`/`session_tree` (nothing watches the
11
+ * document), held in a `RailSource` closure created once per load, and read by
12
+ * the renderers at render time, so no module state lives here.
13
+ */
14
+
15
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
16
+ import { visibleWidth } from "@earendil-works/pi-tui";
17
+ import { cleanString, loadConfigSection } from "../../lib/config.ts";
18
+ import { GLYPHS } from "../../lib/tui/glyphs.ts";
19
+ import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
20
+
21
+ /** How a railed block reads at a glance. */
22
+ export type RailStatus = "info" | "ok" | "warn" | "bad";
23
+
24
+ /**
25
+ * Status to design role: a rail is neutral by default, so a finished or merely
26
+ * informational block draws no colour and only `warn` and `bad` stand out. A
27
+ * surface whose `info` means *active* rather than *informational* brightens it
28
+ * itself — a running tool result takes the accent (see `block/config.ts`).
29
+ */
30
+ export const RAIL_ROLES: Readonly<Record<RailStatus, TuiRole>> = {
31
+ info: "info",
32
+ ok: "faint",
33
+ warn: "warn",
34
+ bad: "bad",
35
+ };
36
+
37
+ /** The rail glyph, painted in the role. `lib/tui/indent.ts` adds the gap. */
38
+ export function railGlyph(theme: Theme, role: TuiRole, glyph: string): string {
39
+ return paint(theme, role, glyph);
40
+ }
41
+
42
+ /** Transcript chrome presentation. */
43
+ export interface ChromeConfig {
44
+ /** The rail glyph: exactly one visible column. */
45
+ rail: string;
46
+ }
47
+
48
+ /** The default rail glyph, from the shared vocabulary. */
49
+ export const DEFAULT_RAIL_GLYPH: string = GLYPHS.divider;
50
+
51
+ export const DEFAULT_CHROME_CONFIG: ChromeConfig = { rail: DEFAULT_RAIL_GLYPH };
52
+
53
+ /** Whether `value` is a single, unstyled terminal column. */
54
+ function isRailGlyph(value: string): boolean {
55
+ // Control characters first: a bare ESC confuses a width measurement.
56
+ if (/[\u0000-\u001f\u007f]/.test(value)) return false;
57
+ return [...value].length === 1 && visibleWidth(value) === 1;
58
+ }
59
+
60
+ /** Validate the `chrome` section over `base`. Total: never throws, never guesses. */
61
+ export function normalizeChromeConfig(raw: Record<string, unknown> | undefined, base: ChromeConfig): ChromeConfig {
62
+ if (!raw) return base;
63
+ const rail = cleanString(raw.rail, "");
64
+ return { rail: isRailGlyph(rail) ? rail : base.rail };
65
+ }
66
+
67
+ /** Effective chrome config for `cwd` (defaults, then the shared `chrome` section). */
68
+ export function loadChromeConfig(cwd: string): ChromeConfig {
69
+ return loadConfigSection(cwd, "chrome", DEFAULT_CHROME_CONFIG, normalizeChromeConfig);
70
+ }
71
+
72
+ /** Where a render-time surface reads the current rail glyph. */
73
+ export interface RailSource {
74
+ /** The configured glyph. */
75
+ resolve(): string;
76
+ /** Replace the glyph; a value that is not one column keeps the previous one. */
77
+ set(glyph: string): void;
78
+ }
79
+
80
+ /** A store holding the configured rail glyph, created once per extension load. */
81
+ export function createRailSource(initial: string = DEFAULT_RAIL_GLYPH): RailSource {
82
+ let glyph = isRailGlyph(initial) ? initial : DEFAULT_RAIL_GLYPH;
83
+ return {
84
+ resolve: () => glyph,
85
+ set(next: string) {
86
+ if (isRailGlyph(next)) glyph = next;
87
+ },
88
+ };
89
+ }
90
+
91
+ /** Register the chrome lifecycle and hand back the rail source it keeps current. */
92
+ export function registerChrome(pi: ExtensionAPI, rail: RailSource = createRailSource()): RailSource {
93
+ const load = (ctx: ExtensionContext): void => rail.set(loadChromeConfig(ctx.cwd).rail);
94
+ pi.on("session_start", (_event, ctx) => load(ctx));
95
+ pi.on("session_tree", (_event, ctx) => load(ctx));
96
+ return rail;
97
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { sanitizeLine } from "../../../lib/format.ts";
11
12
  import { missingFindings } from "../../../lib/requirements.ts";
12
13
  import { reuseText, toolDetails } from "../../../lib/tui/chrome.ts";
13
14
  import { paint, type TuiRole } from "../../../lib/tui/tokens.ts";
@@ -92,9 +93,11 @@ export function registerTools(pi: ExtensionAPI, runtime: DoctorRuntime): void {
92
93
 
93
94
  renderCall(args, theme, context) {
94
95
  const input = args as DoctorArgs;
95
- const scope = `${input.extension ? ` ${input.extension}` : ""}${input.refresh ? " (refresh)" : ""}`;
96
+ const scope = typeof input.extension === "string" && input.extension ? sanitizeLine(input.extension) : "";
96
97
  const text = reuseText(context?.lastComponent);
97
- text.setText(`${theme.fg("toolTitle", "doctor")}${paint(theme, "faint", scope)}`);
98
+ text.setText(
99
+ `${theme.fg("toolTitle", theme.bold(TOOL_NAME))}${scope ? ` ${paint(theme, "label", scope)}` : ""}${input.refresh ? paint(theme, "faint", " (refresh)") : ""}`,
100
+ );
98
101
  return text;
99
102
  },
100
103
 
@@ -14,6 +14,8 @@ import { keyText, type Theme, VERSION } from "@earendil-works/pi-coding-agent";
14
14
  import { type Component, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
15
  import { screenHint } from "../../../lib/tui/chrome.ts";
16
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";
17
19
  import { paint } from "../../../lib/tui/tokens.ts";
18
20
  import { HEADER_CONFIG, type HelpEntry } from "./config.ts";
19
21
 
@@ -23,6 +25,8 @@ export interface HeaderView {
23
25
  showVersion: boolean;
24
26
  /** Whether the terminal renders half blocks; false draws the wordmark instead. */
25
27
  halfBlocks: boolean;
28
+ /** Columns of left margin: Pi's output pad, so the nameplate sits on the transcript margin. */
29
+ pad: number;
26
30
  }
27
31
 
28
32
  /** The mark block is four columns on both rows, so the identity column aligns. */
@@ -63,8 +67,9 @@ export class ByteflyHeader implements Component {
63
67
  const w = Math.max(1, width);
64
68
  const theme = this.getTheme();
65
69
  const view = this.view();
66
- const { padX, nameRole, chromeRole, detailRole, chromeTagline, chromeName, chromeVersion, help } = HEADER_CONFIG;
67
- 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);
68
73
  const [markTop, markBottom] = markLines(theme, view.halfBlocks);
69
74
 
70
75
  const version = view.showVersion && this.version ? paint(theme, detailRole, `v${this.version}`) : "";
@@ -92,22 +97,24 @@ export class ByteflyHeader implements Component {
92
97
  this.identityLine(pad, markTop, agentCell, w),
93
98
  this.identityLine(pad, markBottom, chromeCell, w),
94
99
  this.rule(theme, w, pad),
95
- ...help.map((row) => this.helpLine(theme, w, row)),
100
+ ...help.map((row) => this.helpLine(theme, w, row, padWidth)),
96
101
  ];
97
102
  }
98
103
 
99
104
  /**
100
105
  * 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.
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.
102
109
  */
103
110
  private rule(theme: Theme, width: number, pad: string): string {
104
- const remaining = Math.max(0, width - pad.length);
111
+ const remaining = Math.max(0, width - pad.length * 2);
105
112
  const lead = Math.min(MARK_WIDTH, remaining);
106
113
  const fill = remaining - lead;
107
114
  return (
108
115
  pad +
109
- paint(theme, HEADER_CONFIG.ruleAccentRole, "─".repeat(lead)) +
110
- paint(theme, HEADER_CONFIG.ruleRole, "─".repeat(fill))
116
+ paint(theme, HEADER_CONFIG.ruleAccentRole, RULE_GLYPH.repeat(lead)) +
117
+ paint(theme, HEADER_CONFIG.ruleRole, RULE_GLYPH.repeat(fill))
111
118
  );
112
119
  }
113
120
 
@@ -117,13 +124,17 @@ export class ByteflyHeader implements Component {
117
124
  return truncateToWidth(line.replace(/ +$/, ""), width, "");
118
125
  }
119
126
 
120
- /** One help group: whole entries only, dropped as the width runs out. */
121
- 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 {
122
133
  const hints = row
123
134
  .map(helpParts)
124
135
  .filter((part) => part.key)
125
136
  .map((part) => keyHintText(theme, part.key, part.description));
126
- return hints.length > 0 ? screenHint(theme, width, hints) : "";
137
+ return hints.length > 0 ? screenHint(theme, width, hints, padWidth + BODY_INDENT) : "";
127
138
  }
128
139
  }
129
140
 
@@ -39,8 +39,6 @@ export interface HeaderConfig {
39
39
  readonly ruleAccentRole: TuiRole;
40
40
  /** Role for the rest of the rule. */
41
41
  readonly ruleRole: TuiRole;
42
- /** Columns of left padding, matching Pi's built-in header indent. */
43
- readonly padX: number;
44
42
  /** The tagline before the chrome name, so the relationship reads in words. */
45
43
  readonly chromeTagline: string;
46
44
  /** The chrome's name. */
@@ -61,7 +59,6 @@ export const HEADER_CONFIG: HeaderConfig = {
61
59
  detailRole: "faint",
62
60
  ruleAccentRole: "accent",
63
61
  ruleRole: "rule",
64
- padX: 1,
65
62
  chromeTagline: "enhanced by",
66
63
  chromeName: "bytefly",
67
64
  chromeVersion: pkg.version,
@@ -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 and its `doctor` report), `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
+ }
@@ -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
  }
@@ -10,14 +10,21 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
10
10
  import { centeredRule } from "../../../lib/tui/rules.ts";
11
11
  import { CONFIG } from "./config.ts";
12
12
 
13
- /** One rendered line: the centered turn rule, colored by role. */
14
- export function separatorLine(turn: number, width: number, theme: Theme): string[] {
15
- return [
16
- centeredRule(theme, width, `${CONFIG.labelPrefix} ${turn}`, {
17
- glyph: CONFIG.glyph,
18
- pad: CONFIG.pad,
19
- fillRole: CONFIG.dashRole,
20
- labelRole: CONFIG.labelRole,
21
- }),
22
- ];
13
+ /**
14
+ * One rendered line: the centered turn rule, colored by role.
15
+ *
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.
19
+ */
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}`];
23
30
  }