@gavin-hu/my-pi-agent 0.9.0 → 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 (132) 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 +21 -12
  79. package/extensions/job/format.ts +2 -2
  80. package/extensions/job/index.ts +25 -20
  81. package/extensions/job/tools.ts +5 -3
  82. package/extensions/job/tui.ts +13 -8
  83. package/extensions/memory/README.md +6 -0
  84. package/extensions/memory/format.ts +5 -3
  85. package/extensions/plan/README.md +32 -12
  86. package/extensions/plan/tools.ts +20 -5
  87. package/extensions/plan/web-client.ts +58 -17
  88. package/extensions/plan/web-page.ts +34 -9
  89. package/extensions/plan/web-router.ts +40 -9
  90. package/extensions/plan/web.ts +22 -2
  91. package/extensions/question/README.md +13 -1
  92. package/extensions/question/answers.ts +25 -18
  93. package/extensions/question/tools.ts +11 -6
  94. package/extensions/rewind/README.md +2 -0
  95. package/extensions/rewind/format.ts +6 -1
  96. package/extensions/rewind/index.ts +3 -1
  97. package/extensions/rewind/snapshot-run.ts +1 -1
  98. package/extensions/subagent/README.md +6 -0
  99. package/extensions/subagent/index.ts +1 -1
  100. package/extensions/subagent/render.ts +56 -29
  101. package/extensions/todo/README.md +19 -12
  102. package/extensions/todo/format.ts +17 -25
  103. package/extensions/todo/tools.ts +2 -1
  104. package/extensions/todo/tui.ts +3 -2
  105. package/extensions/watch/README.md +14 -6
  106. package/extensions/watch/format.ts +2 -2
  107. package/extensions/watch/index.ts +28 -23
  108. package/extensions/watch/tools.ts +5 -3
  109. package/extensions/watch/tui.ts +12 -7
  110. package/extensions/web-access/README.md +16 -7
  111. package/extensions/web-access/fetch/tool.ts +1 -1
  112. package/extensions/web-access/search/tool.ts +3 -3
  113. package/extensions/wechat/README.md +6 -0
  114. package/extensions/worktree/README.md +6 -0
  115. package/lib/README.md +2 -1
  116. package/lib/format.ts +17 -0
  117. package/lib/notice.ts +87 -0
  118. package/lib/tui/chrome.ts +8 -4
  119. package/lib/tui/cursor.ts +2 -1
  120. package/lib/tui/glyphs.ts +10 -11
  121. package/lib/tui/indent.ts +166 -0
  122. package/lib/tui/index.ts +3 -1
  123. package/lib/tui/rows.ts +39 -5
  124. package/lib/tui/rules.ts +9 -3
  125. package/lib/tui/tokens.ts +14 -0
  126. package/lib/web/base.ts +2 -1
  127. package/lib/web/components.ts +3 -1
  128. package/lib/web/layout.ts +5 -1
  129. package/package.json +7 -3
  130. package/themes/bytefly-dark.json +7 -8
  131. package/themes/bytefly-light.json +10 -11
  132. package/assets/bytefly-chrome.png +0 -0
@@ -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) => {
@@ -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
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * bytefly's live working timer.
3
+ *
4
+ * Pi's working row shows a static `Working`, so a long run reads the same as a
5
+ * short one. This surface advances the message once a second while the row is
6
+ * up, and hands it back to Pi when the row comes down.
7
+ *
8
+ * The timer is tied to the row's visibility. The host shows the working
9
+ * indicator in its own `turn_start` handler and clears it at `agent_end`
10
+ * (`interactive-mode.js`), so the tick starts at `turn_start` and stops at
11
+ * `agent_end` — the two moments the row appears and disappears. Turns inside
12
+ * one run do not restart it: the host leaves the same indicator up across
13
+ * turns, and `start` is a no-op while it is counting.
14
+ *
15
+ * Compaction and auto-retry also replace the row, but both run outside this
16
+ * window (`auto_retry_start` follows `agent_end`; the before-settle compaction
17
+ * runs after it), and neither is an extension event, so there is nothing to
18
+ * hook and nothing to undo.
19
+ *
20
+ * The handle is global (`setWorkingMessage` drives the single working row), so
21
+ * the tick is cancelled and the message reset on stop and, belt and braces, on
22
+ * `session_shutdown` — the factory itself only registers.
23
+ */
24
+
25
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
26
+ import { WORKING_CONFIG } from "./config.ts";
27
+ import { workingCounter } from "./format.ts";
28
+
29
+ /** Host seams, injected by tests so the tick is deterministic. */
30
+ export interface WorkingDeps {
31
+ /** The clock the count is read from. */
32
+ now?: () => number;
33
+ /** Start the repeating tick; returns a cancel handle. */
34
+ schedule?: (tick: () => void, intervalMs: number) => () => void;
35
+ }
36
+
37
+ /** A repeating timer that never holds the process open on its own. */
38
+ function defaultSchedule(tick: () => void, intervalMs: number): () => void {
39
+ const timer = setInterval(tick, intervalMs);
40
+ (timer as { unref?: () => void }).unref?.();
41
+ return () => clearInterval(timer);
42
+ }
43
+
44
+ /** Register the timer; it runs only in interactive (`tui`) sessions. */
45
+ export function registerWorkingTimer(pi: ExtensionAPI, deps: WorkingDeps = {}): void {
46
+ const now = deps.now ?? (() => Date.now());
47
+ const schedule = deps.schedule ?? defaultSchedule;
48
+ let cancel: (() => void) | undefined;
49
+ let startedAt = 0;
50
+ // A boolean, not `startedAt === 0`: an injected clock is allowed to start at 0.
51
+ let active = false;
52
+
53
+ /** Stop the tick and hand the working message back to Pi. Idempotent. */
54
+ const stop = (ctx: ExtensionContext): void => {
55
+ cancel?.();
56
+ cancel = undefined;
57
+ if (!active) return;
58
+ active = false;
59
+ ctx.ui.setWorkingMessage(undefined);
60
+ };
61
+
62
+ /** Start the count for the current turn; a no-op while already counting. */
63
+ const start = (ctx: ExtensionContext): void => {
64
+ if (active) return;
65
+ startedAt = now();
66
+ active = true;
67
+ const tick = (): void => {
68
+ ctx.ui.setWorkingMessage(workingCounter(now() - startedAt));
69
+ };
70
+ tick();
71
+ cancel = schedule(tick, WORKING_CONFIG.intervalMs);
72
+ };
73
+
74
+ // The host shows the working indicator on its own `turn_start` handler, so
75
+ // this is the first frame the row is up.
76
+ pi.on("turn_start", (_event, ctx) => {
77
+ if (ctx.mode !== "tui") return;
78
+ start(ctx);
79
+ });
80
+
81
+ // The host clears the working indicator at `agent_end`.
82
+ pi.on("agent_end", (_event, ctx) => {
83
+ if (ctx.mode !== "tui") return;
84
+ stop(ctx);
85
+ });
86
+
87
+ pi.on("session_shutdown", (_event, ctx) => stop(ctx));
88
+ }
@@ -8,6 +8,12 @@
8
8
  the effective working directory with the same symlink-aware guard the built-in
9
9
  path tools use.
10
10
 
11
+ ## Tool rendering
12
+
13
+ How the `read_doc` call and its result look in the transcript — the call line as a headline and the body indented under it:
14
+
15
+ <img src="../../assets/tools/read_doc.png" alt="The read_doc tool's result: the call line and an indented body with the file path, format, size, and the extracted text preview" width="100%">
16
+
11
17
  ## Quickstart
12
18
 
13
19
  ```bash
@@ -76,19 +82,20 @@ interactive transcript (and HTML exports).
76
82
 
77
83
  The call line carries the path (`~`-shortened, OSC-8 linked when the terminal
78
84
  supports it) and any paging options. The result line drops the path — it is
79
- already on the call line — and leads with the format and a humanized range:
85
+ already on the call line — and leads with the format and the character range,
86
+ grouped so a `k` suffix never sits on one bound and not the other:
80
87
 
81
88
  ```
82
89
  read_doc ~/docs/spec.pdf (from 40k, max 80k)
83
- PDF · 1–40k of 42k chars · more at 40k · ctrl+o
90
+ PDF · chars 40,001–120,000 of 250,000 · next at 120,000 · ctrl+o
84
91
  ```
85
92
 
86
93
  Expanded adds a blank line before the preview body, matching the built-in
87
94
  header/output gap:
88
95
 
89
96
  ```
90
- read_doc ~/docs/spec.pdf
91
- PDF · 1–40k of 42k chars · more at 40k
97
+ read_doc ~/docs/spec.pdf (from 40k, max 80k)
98
+ PDF · chars 40,001–120,000 of 250,000 · next at 120,000
92
99
 
93
100
  Chapter 1
94
101
  This specification defines ...
@@ -6,7 +6,7 @@
6
6
  * the clean slice; the text adds a header and any truncation note.
7
7
  */
8
8
 
9
- import { formatTokens } from "../../lib/format.ts";
9
+ import { formatCount } from "../../lib/format.ts";
10
10
  import type { FormatId } from "./formats.ts";
11
11
 
12
12
  export interface DocPageInput {
@@ -72,9 +72,9 @@ export interface DocSummaryInput {
72
72
  export interface DocSummary {
73
73
  /** Uppercase format id, e.g. `PDF`. */
74
74
  format: string;
75
- /** Range detail, e.g. `1–40k of 42k chars`. */
75
+ /** Range detail, e.g. `chars 1–40,000 of 42,478`. */
76
76
  detail: string;
77
- /** `more at N` when truncated, `complete` for a non-first final page. */
77
+ /** `next at N` when truncated, `complete` for a non-first final page. */
78
78
  note?: string;
79
79
  /** Whether more text remains; the renderer colours the note with this. */
80
80
  truncated: boolean;
@@ -83,9 +83,9 @@ export interface DocSummary {
83
83
  /**
84
84
  * Describe a `read_doc` result for the transcript, without theme colours.
85
85
  *
86
- * Counts are humanized. A complete first read shows the total; a paged read
87
- * shows the returned range so a truncated result is never mistaken for the
88
- * whole document.
86
+ * A complete first read shows the total; a paged read shows the returned range
87
+ * and where to continue, so a truncated result is never mistaken for the whole
88
+ * document.
89
89
  */
90
90
  export function summarizeDoc(input: DocSummaryInput): DocSummary {
91
91
  const format = input.format.toUpperCase();
@@ -93,18 +93,18 @@ export function summarizeDoc(input: DocSummaryInput): DocSummary {
93
93
  if (input.startIndex >= input.chars) {
94
94
  return {
95
95
  format,
96
- detail: `no text at ${formatTokens(input.startIndex)} of ${formatTokens(input.chars)} chars`,
96
+ detail: `no text at ${formatCount(input.startIndex)} of ${formatCount(input.chars)} chars`,
97
97
  truncated: false,
98
98
  };
99
99
  }
100
100
  if (input.startIndex <= 0 && !input.truncated) {
101
- return { format, detail: `${formatTokens(input.chars)} chars`, truncated: false };
101
+ return { format, detail: `${formatCount(input.chars)} chars`, truncated: false };
102
102
  }
103
- const range = `${formatTokens(input.startIndex + 1)}–${formatTokens(input.nextIndex)} of ${formatTokens(input.chars)} chars`;
103
+ const range = `chars ${formatCount(input.startIndex + 1)}–${formatCount(input.nextIndex)} of ${formatCount(input.chars)}`;
104
104
  return {
105
105
  format,
106
106
  detail: range,
107
- note: input.truncated ? `more at ${formatTokens(input.nextIndex)}` : "complete",
107
+ note: input.truncated ? `next at ${formatCount(input.nextIndex)}` : "complete",
108
108
  truncated: input.truncated,
109
109
  };
110
110
  }
@@ -2,11 +2,11 @@
2
2
  * Transcript rendering for `read_doc`.
3
3
  *
4
4
  * Theme-aware formatting only: the tool definition in `tool.ts` wires these to
5
- * `renderCall` / `renderResult`. The call line carries the path (accent,
5
+ * `renderCall` / `renderResult`. The call line carries the path (body colour,
6
6
  * `~`-shortened, OSC-8 linked when the terminal supports it); the result line
7
- * leads with the format and a humanized range, so the path is never repeated.
8
- * The expanded view appends a sanitized, line-capped preview of the extracted
9
- * text. Every field is sanitized before a theme colour is applied.
7
+ * leads with the format and the exact character range, so the path is never
8
+ * repeated. The expanded view appends a sanitized, line-capped preview of the
9
+ * extracted text. Every field is sanitized before a theme colour is applied.
10
10
  */
11
11
 
12
12
  import { isAbsolute, resolve } from "node:path";
@@ -22,12 +22,12 @@ import { TOOL_NAME, type DocArgs, type DocResult } from "./schema.ts";
22
22
  /** Lines of extracted text shown in the expanded preview. */
23
23
  export const PREVIEW_LINES = 20;
24
24
 
25
- /** Render the argument path: accent, `~`-shortened, OSC-8 linked when supported. */
25
+ /** Render the argument path: body colour, `~`-shortened, OSC-8 linked when supported. */
26
26
  export function renderDocPath(rawPath: string | undefined, theme: Theme, cwd: string): string {
27
27
  if (typeof rawPath !== "string") return paint(theme, "bad", "[invalid arg]");
28
28
  const value = sanitizeLine(rawPath);
29
29
  if (!value) return theme.fg("toolOutput", "...");
30
- const styled = paint(theme, "accent", shortenPath(value));
30
+ const styled = paint(theme, "label", shortenPath(value));
31
31
  if (!getCapabilities().hyperlinks) return styled;
32
32
  const abs = isAbsolute(value) ? value : resolve(cwd, value);
33
33
  return hyperlink(styled, pathToFileURL(abs).href);
@@ -64,7 +64,7 @@ export function formatDocCall(args: DocArgs, theme: Theme, context: { cwd: strin
64
64
  const options: string[] = [];
65
65
  if (typeof startIndex === "number" && startIndex > 0) options.push(`from ${formatTokens(startIndex)}`);
66
66
  if (typeof maxChars === "number") options.push(`max ${formatTokens(maxChars)}`);
67
- if (options.length > 0) text += theme.fg("toolOutput", ` (${options.join(", ")})`);
67
+ if (options.length > 0) text += paint(theme, "faint", ` (${options.join(", ")})`);
68
68
  return text;
69
69
  }
70
70
 
@@ -63,7 +63,9 @@ pi -e . # load the whole @gavin-hu/my-pi-agen
63
63
  code viewport while its body scrolls.
64
64
  - **Read-only and loopback-scoped.** No editing, no writing, no LAN exposure.
65
65
  - **Git context.** A read-only header chip shows the checked-out branch (or
66
- detached head) and a dirty marker, and reflows on narrow screens.
66
+ detached head) and reflows on narrow screens. Uncommitted changes are stated
67
+ once, by the changes chip below; turn the diff view off (`gitDiff: false`) and
68
+ the header reports no changes at all.
67
69
  - **Git diff view.** A `● N changes +A −D` chip links to `/diff`, a change
68
70
  overview with `git diff --stat` and a per-file status table, and `/diff/<rel>`
69
71
  renders one file's unified diff, whose head carries its own `+A −D` and hunk
@@ -550,12 +550,19 @@ export const CLIENT_JS = `(function () {
550
550
  // The hunk whose header is pinned (or last passed), then one step either way.
551
551
  function moveHunk(delta) {
552
552
  if (hunkAnchors.length === 0) return;
553
+ var scroller = primaryScroller();
554
+ // The line a pinned hunk header sticks to: the code viewport's top inset
555
+ // by its padding. With no scrolling pane (narrow screens) the page is the
556
+ // viewport, so the anchor is the window top.
557
+ var lineTop = 8;
558
+ if (scroller) lineTop = scroller.getBoundingClientRect().top + (parseFloat(getComputedStyle(scroller).paddingTop) || 0);
553
559
  var current = -1;
554
560
  for (var index = 0; index < hunkAnchors.length; index++) {
555
- if (hunkAnchors[index].getBoundingClientRect().top <= 8) current = index;
561
+ if (hunkAnchors[index].getBoundingClientRect().top <= lineTop + 2) current = index;
556
562
  }
557
563
  var next = Math.max(0, Math.min(hunkAnchors.length - 1, current + delta));
558
- hunkAnchors[next].scrollIntoView({ block: "start" });
564
+ if (scroller) scroller.scrollTop += hunkAnchors[next].getBoundingClientRect().top - lineTop;
565
+ else hunkAnchors[next].scrollIntoView({ block: "start" });
559
566
  }
560
567
 
561
568
  // The page's primary scroller: the card body for a file or diff, the main
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Read-only git context for the browse pages.
3
3
  *
4
- * The header shows the checked-out branch (or the detached head) and a dirty
5
- * marker. Queries go through the shared `lib/git` seams so the read-only
4
+ * The header shows the checked-out branch (or the detached head); uncommitted
5
+ * changes are the changes chip's concern. Queries go through the shared
6
+ * `lib/git` seams so the read-only
6
7
  * plumbing lives in one place. State (the TTL cache) lives in the factory
7
8
  * closure, never in `lib/`.
8
9
  */
@@ -25,8 +26,6 @@ export interface GitStatus {
25
26
  branch?: string;
26
27
  /** Short object id, when HEAD is detached. */
27
28
  head?: string;
28
- /** True when `git status --porcelain` reports any change. */
29
- dirty: boolean;
30
29
  }
31
30
 
32
31
  export type GitStatusProvider = () => Promise<GitStatus | undefined>;
@@ -60,9 +59,7 @@ export function createGitStatus(
60
59
  const branch = await currentBranch(run, root);
61
60
  const head = branch ? undefined : (await revParse(run, root, "HEAD"))?.slice(0, 7);
62
61
  if (!branch && !head) return undefined;
63
- const status = await run(["status", "--porcelain"], { cwd: root, timeoutMs: 1000 });
64
- if (status.code !== 0) return undefined;
65
- return { branch, head, dirty: status.stdout.trim().length > 0 };
62
+ return { branch, head };
66
63
  }
67
64
 
68
65
  return async () => {
@@ -41,15 +41,14 @@ function iconMarkup(spec: IconSpec, hidden = false): string {
41
41
  return `<svg class="browse-cat ${spec.colorClass}"${hidden ? " hidden" : ""} aria-hidden="true" title="${escapeHtml(spec.label)}"><use href="${iconHref(spec.icon)}"/></svg>`;
42
42
  }
43
43
 
44
- /** The header's git chip: branch (or detached head) plus a dirty dot. */
44
+ /** The header's git chip: the branch, or the detached head; changes are the changes chip's job. */
45
45
  function renderGit(git: GitStatus | undefined): string {
46
46
  if (!git) return "";
47
47
  const name = git.branch ?? git.head;
48
48
  if (!name) return "";
49
49
  const glyph = git.branch ? GLYPHS.branch : GLYPHS.detached;
50
- const aria = `${git.branch ? `git branch ${git.branch}` : `git detached at ${git.head ?? ""}`}${git.dirty ? ", uncommitted changes" : ""}`;
51
- const dot = git.dirty ? `<span class="browse-dirty" aria-hidden="true" title="uncommitted changes">●</span>` : "";
52
- return `<span class="bf-chip browse-git${git.dirty ? " browse-git--dirty" : ""}" title="${escapeHtml(aria)}" aria-label="${escapeHtml(aria)}"><span class="browse-git__glyph" aria-hidden="true">${escapeHtml(glyph)}</span>${escapeHtml(name)}${dot}</span>`;
50
+ const aria = git.branch ? `git branch ${git.branch}` : `git detached at ${git.head ?? ""}`;
51
+ return `<span class="bf-chip browse-git" title="${escapeHtml(aria)}" aria-label="${escapeHtml(aria)}"><span class="browse-git__glyph" aria-hidden="true">${escapeHtml(glyph)}</span>${escapeHtml(name)}</span>`;
53
52
  }
54
53
 
55
54
  /** The changed-path index for markers, plus the totals shown in the chip. */
@@ -98,8 +97,6 @@ ${WEB_COMPONENTS_CSS}
98
97
  .browse-side{padding:var(--space-3)}
99
98
  .browse-root{flex:1 1 auto;direction:rtl;text-align:left}
100
99
  .browse-git{font-family:var(--font-mono)}
101
- .browse-git--dirty .browse-git__glyph{color:var(--warn)}
102
- .browse-dirty{color:var(--warn);font-size:9px;line-height:1}
103
100
  .browse-quick-open{display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);width:100%;margin-bottom:var(--space-2);padding:6px var(--space-2);border:1px solid var(--border);border-radius:var(--radius-md);background:var(--canvas);color:var(--muted);font:var(--fs-sm) var(--font-ui);cursor:pointer}
104
101
  .browse-quick-open:hover{border-color:var(--accent);color:var(--accent)}
105
102
  .browse-quick-open kbd{font-family:var(--font-mono);font-size:var(--fs-xs);color:var(--dim)}
@@ -149,7 +146,10 @@ body.palette-open{overflow:hidden}
149
146
  .bf-diff-line--hunk{color:var(--accent);background:var(--canvas)}
150
147
  .bf-diff-line--meta{color:var(--muted)}
151
148
  .bf-diff-line--note{color:var(--muted);font-style:italic}
152
- /* A hunk header pins to the top of the diff viewport while its body scrolls. */
149
+ /* A hunk header pins to the top of the diff viewport while its body scrolls.
150
+ Each hunk is a section so the pinned header is bounded by its own body and
151
+ cannot stack on the next one. */
152
+ .bf-diff-hunk{display:block}
153
153
  .bf-diff .bf-diff-line--hunk{position:sticky;top:0;z-index:1}
154
154
  /* Reading progress, pinned to the sticky header's bottom edge. */
155
155
  .browse-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden}
@@ -575,17 +575,27 @@ function highlightDiffText(lines: DiffLine[], language?: HighlightLanguage): str
575
575
  /** Render unified-diff lines with a two-column gutter and per-kind styling. */
576
576
  export function renderDiffLines(lines: DiffLine[], language?: HighlightLanguage): string {
577
577
  const html = highlightDiffText(lines, language);
578
+ // Every hunk is a section so its sticky header is bounded by its own body;
579
+ // the section also gives the keyboard a stable, non-sticky scroll target.
580
+ const out: string[] = [];
578
581
  let hunks = 0;
579
- return lines
580
- .map((line, index) => {
581
- const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : line.kind === "ctx" ? " " : "";
582
- const gutter = `<span class="browse-diff-no" aria-hidden="true">${line.oldNo ?? ""}</span><span class="browse-diff-no" aria-hidden="true">${line.newNo ?? ""}</span>`;
583
- const marker = `<span class="browse-diff-sign" aria-hidden="true">${sign}</span>`;
584
- // Hunk headers carry an anchor so the keyboard can jump between them.
585
- const anchor = line.kind === "hunk" ? ` id="hunk-${++hunks}" data-hunk="${hunks}"` : "";
586
- return `<span class="bf-code__line bf-diff-line bf-diff-line--${line.kind}"${anchor}>${gutter}${marker}${html[index]}</span>`;
587
- })
588
- .join("");
582
+ let open = false;
583
+ lines.forEach((line, index) => {
584
+ if (line.kind === "hunk") {
585
+ if (open) out.push("</span>");
586
+ hunks += 1;
587
+ out.push(`<span class="bf-diff-hunk" id="hunk-${hunks}" data-hunk="${hunks}">`);
588
+ open = true;
589
+ }
590
+ const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : line.kind === "ctx" ? " " : "";
591
+ const gutter = `<span class="browse-diff-no" aria-hidden="true">${line.oldNo ?? ""}</span><span class="browse-diff-no" aria-hidden="true">${line.newNo ?? ""}</span>`;
592
+ const marker = `<span class="browse-diff-sign" aria-hidden="true">${sign}</span>`;
593
+ out.push(
594
+ `<span class="bf-code__line bf-diff-line bf-diff-line--${line.kind}">${gutter}${marker}${html[index]}</span>`,
595
+ );
596
+ });
597
+ if (open) out.push("</span>");
598
+ return out.join("");
589
599
  }
590
600
 
591
601
  /** The scope label for a mode. */
@@ -5,6 +5,12 @@ handler, or reveals it in the file manager. `/open` does it interactively and
5
5
  the `open_path` tool does it from a script, both resolving the path against the
6
6
  effective working directory.
7
7
 
8
+ ## Tool rendering
9
+
10
+ How the `open_path` call and its result look in the transcript — the call line as a headline and the body indented under it:
11
+
12
+ <img src="../../assets/tools/open_path.png" alt="The open_path tool's call and result, drawn by the generic fallback" width="100%">
13
+
8
14
  ## Quickstart
9
15
 
10
16
  ```bash
@@ -9,6 +9,12 @@ pi -e . # load the whole @gavin-hu/my-pi-agent packa
9
9
  pi install ./ # install the package
10
10
  ```
11
11
 
12
+ ## Tool rendering
13
+
14
+ How the `goal` call and its result look in the transcript — the call line as a headline and the body indented under it:
15
+
16
+ <img src="../../assets/tools/goal.png" alt="The goal tool's result: the call line and an indented body with the Goal header and the objective body" width="100%">
17
+
12
18
  ## What it does
13
19
 
14
20
  - Registers one model-callable tool, `goal`, that records the session
@@ -110,14 +116,13 @@ The tool works in every mode. The persistent widget requires interactive
110
116
  `/goal <prompt>` runs the loop: each model call uses a cancellable `tui` spinner
111
117
  in interactive mode and runs directly in RPC; the questions and the confirm step
112
118
  are host dialogs, forwarded by RPC. In print and JSON mode the loop is skipped
113
- and the typed text is sent. The widget is always a single line; the transcript result
114
- keeps the full glyph rail, separated from the call line by a blank line:
119
+ and the typed text is sent. The widget is always a single line; the transcript
120
+ result is the status header alone, directly under the call line that names the
121
+ objective:
115
122
 
116
123
  ```
117
124
  goal → set: Refactor the parser to support streaming input
118
-
119
- Goal · active
120
- ⊚ Refactor the parser to support streaming input and ship it with tests
125
+ Goal · active
121
126
  ```
122
127
 
123
128
  The renderer reuses the slot's component (`context.lastComponent`) rather than
@@ -205,9 +210,10 @@ depends on config.
205
210
  hide all rails and restore them on close.
206
211
  - **One-line widget, achieved hidden by default.** The rail follows the
207
212
  `todo` grammar (`Goal · active · <objective>`): accent label leads, status
208
- follows, no prefixed glyph, clipped to the terminal. The transcript keeps the
209
- multi-line `⊚` / `✓` rail. `achieved: "show"` restores the dimmed line; config
210
- only affects presentation.
213
+ follows, no prefixed glyph, clipped to the terminal. The transcript result is
214
+ the status header alone (`Goal · active`), because the call line above it
215
+ already carries the objective; it does not restate it. `achieved: "show"`
216
+ restores the dimmed widget line; config only affects presentation.
211
217
  - **Sanitize at the boundary.** `normalizeGoal` and `parseClarification` both route
212
218
  model text through `lib/format.ts`'s `sanitizeLine`, so an embedded newline
213
219
  cannot corrupt the widget and raw escapes cannot restyle the terminal.
@@ -223,9 +229,9 @@ depends on config.
223
229
  | `clarify-parse.ts` | Parse the `{ understood, goal, questions }` reply (and the plain-text fallback), normalize questions, and expose the limits (pure). |
224
230
  | `clarify.ts` | The two model calls, their cancellable loaders, the in-command questionnaire, and the confirm/edit dialog (host). |
225
231
  | `state.ts` | Branch reconstruction (pure). |
226
- | `format.ts` | Model-facing and terminal text plus the transcript `⊚` / `✓` symbols and the rail vocabulary (pure). |
232
+ | `format.ts` | Model-facing and terminal text plus the header and rail vocabulary (pure). |
227
233
  | `config.ts` | Widget config load/validation (the `goal` section of `bytefly.json`). |
228
- | `tui.ts` | The persistent goal widget and the transcript result rail. |
234
+ | `tui.ts` | The persistent goal widget and the transcript status header. |
229
235
  | `runtime.ts` | Session-scoped state and widget synchronization. |
230
236
  | `tools.ts` | `goal` tool registration and rendering. |
231
237
  | `commands.ts` | `/goal`. |
@@ -1,34 +1,21 @@
1
1
  /**
2
2
  * Model-facing and transcript text for the goal (pure).
3
3
  *
4
- * The transcript result is drawn as a glyph rail shared with `todo`: a header
5
- * line (`Goal · active`) and an indented body row led by `⊚` (active) or `✓`
6
- * (achieved), with wrapped continuation rows aligned under the text. The
7
- * persistent widget, by contrast, is a plain label-first line
8
- * (`Goal · active · <objective>`) matching the `todo` and `jobs` widgets. The
9
- * symbols and labels live here; the rail indent constants are shared with
10
- * `todo` in `lib/tui/glyphs.ts`.
4
+ * The transcript result is the status header (`Goal · active`) under the call
5
+ * line, which already names the objective; the persistent widget is a plain
6
+ * label-first line (`Goal · active · <objective>`) matching the `todo` and
7
+ * `jobs` widgets. The status labels live here.
11
8
  */
12
9
 
13
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
14
11
  import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
15
- import { GLYPHS } from "../../lib/tui/glyphs.ts";
12
+ import { SEPARATORS } from "../../lib/tui/glyphs.ts";
16
13
  import { paint } from "../../lib/tui/tokens.ts";
17
14
  import type { Goal, GoalStatus } from "./types.ts";
18
15
 
19
- /** Active-goal glyph. */
20
- export const ACTIVE_SYMBOL = GLYPHS.target;
21
- /** Achieved-goal glyph. */
22
- export const ACHIEVED_SYMBOL = GLYPHS.check;
23
-
24
16
  /** Longest objective preview shown in the transcript call line. */
25
17
  const CALL_PREVIEW_WIDTH = 48;
26
18
 
27
- /** Themed status glyph, shared by the widget and the transcript renderer. */
28
- export function goalGlyph(goal: Goal, theme: Theme): string {
29
- return goal.status === "achieved" ? paint(theme, "ok", ACHIEVED_SYMBOL) : paint(theme, "accent", ACTIVE_SYMBOL);
30
- }
31
-
32
19
  /** Human label for a status, e.g. `active`. */
33
20
  export function goalStatusLabel(status: GoalStatus): string {
34
21
  return status === "achieved" ? "achieved" : "active";
@@ -40,7 +27,7 @@ export function goalStatusLabel(status: GoalStatus): string {
40
27
  */
41
28
  export function goalHeader(goal: Goal, theme: Theme): string {
42
29
  const label = paint(theme, "accent", "Goal");
43
- const separator = paint(theme, "faint", " · ");
30
+ const separator = paint(theme, "faint", SEPARATORS.item);
44
31
  const word = goalStatusLabel(goal.status);
45
32
  const status = goal.status === "achieved" ? paint(theme, "ok", word) : paint(theme, "faint", word);
46
33
  return `${label}${separator}${status}`;
@@ -53,7 +40,7 @@ export function goalHeader(goal: Goal, theme: Theme): string {
53
40
  * Callers clip it to the available width.
54
41
  */
55
42
  export function goalLine(goal: Goal, theme: Theme): string {
56
- const separator = paint(theme, "faint", " · ");
43
+ const separator = paint(theme, "faint", SEPARATORS.item);
57
44
  const word = goalStatusLabel(goal.status);
58
45
  const status = goal.status === "achieved" ? paint(theme, "ok", word) : paint(theme, "faint", word);
59
46
  return `${paint(theme, "accent", "Goal")}${separator}${status}${separator}${goalObjective(goal, theme)}`;
@@ -8,13 +8,13 @@
8
8
  */
9
9
 
10
10
  import type { JsonValue } from "@earendil-works/pi-ai";
11
- import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { sanitizeLine } from "../../lib/format.ts";
13
13
  import { GOAL_TOOL } from "../../lib/tool-names.ts";
14
14
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
15
15
  import { paint } from "../../lib/tui/tokens.ts";
16
16
  import { GLYPHS } from "../../lib/tui/glyphs.ts";
17
- import { formatCallText, formatGoalText, goalObjective, previewObjective } from "./format.ts";
17
+ import { formatCallText, formatGoalText } from "./format.ts";
18
18
  import type { GoalRuntime } from "./runtime.ts";
19
19
  import { GoalParams, GoalResult, normalizeGoal, type GoalArgs } from "./schema.ts";
20
20
  import { GoalResult as GoalResultView } from "./tui.ts";
@@ -22,9 +22,6 @@ import type { GoalAction, GoalDetails } from "./types.ts";
22
22
 
23
23
  export const TOOL_NAME = GOAL_TOOL;
24
24
 
25
- /** Longest objective shown in an unexpanded transcript result. */
26
- const RESULT_PREVIEW_WIDTH = 72;
27
-
28
25
  /** Best-effort action of a rejected call, for the error result's details. */
29
26
  function attemptedAction(args: Partial<GoalArgs>): GoalAction {
30
27
  if (args.status === "achieved") return "achieve";
@@ -32,14 +29,6 @@ function attemptedAction(args: Partial<GoalArgs>): GoalAction {
32
29
  return "set";
33
30
  }
34
31
 
35
- /** Themed body for the transcript result: the collapsed preview or the full objective. */
36
- function resultBody(goal: NonNullable<GoalDetails["goal"]>, expanded: boolean, theme: Theme): string {
37
- const text = expanded ? goal.objective : previewObjective(goal.objective, RESULT_PREVIEW_WIDTH);
38
- // Route both states through `goalObjective`, so an achieved goal is dimmed
39
- // whether it is collapsed or expanded.
40
- return goalObjective({ objective: text, status: goal.status }, theme);
41
- }
42
-
43
32
  /**
44
33
  * JSON-safe mirror of the goal for `structuredContent`.
45
34
  *
@@ -108,7 +97,7 @@ export function registerTools(pi: ExtensionAPI, runtime: GoalRuntime): void {
108
97
  return text;
109
98
  },
110
99
 
111
- renderResult(result, { expanded }, theme, context) {
100
+ renderResult(result, _options, theme, context) {
112
101
  const text = reuseText(context.lastComponent);
113
102
  const details = toolDetails<GoalDetails>(result.details);
114
103
  if (!details) {
@@ -125,12 +114,11 @@ export function registerTools(pi: ExtensionAPI, runtime: GoalRuntime): void {
125
114
  text.setText(paint(theme, "faint", `${GLYPHS.stopped} `) + paint(theme, "meta", "Cleared the goal"));
126
115
  return text;
127
116
  }
128
- const body = resultBody(details.goal, expanded, theme);
129
117
  const view =
130
118
  context.lastComponent instanceof GoalResultView
131
119
  ? context.lastComponent
132
- : new GoalResultView(details.goal, body, theme);
133
- view.update(details.goal, body, theme);
120
+ : new GoalResultView(details.goal, theme);
121
+ view.update(details.goal, theme);
134
122
  return view;
135
123
  },
136
124
  });