@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
package/lib/notice.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The shared transcript notice: one custom-message type that any extension in
3
+ * the package can post, plus the model-facing content and the renderer-facing
4
+ * details it carries.
5
+ *
6
+ * Pi renders a custom message only when it was sent with `display: true`, and
7
+ * it resolves a renderer by the exact `customType` — there is no wildcard — so
8
+ * the type and the shape behind it are one contract the producing extension and
9
+ * bytefly's renderer must agree on. Keep both here, never re-typed on either
10
+ * side.
11
+ *
12
+ * `content` is what the model reads; `details` is never sent to the model and
13
+ * is the renderer's whole input, so a producer states the facts once and the
14
+ * renderer decides the look. Value-only: constants and pure functions, no
15
+ * state.
16
+ */
17
+
18
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
19
+
20
+ /** The custom-message type every transcript notice is sent under. */
21
+ export const NOTICE_TYPE = "my-pi-agent/notice";
22
+
23
+ /** How a notice, or one of its rows, reads at a glance. */
24
+ export type NoticeSeverity = "info" | "ok" | "warn" | "bad";
25
+
26
+ /** One row under a notice's headline. */
27
+ export interface NoticeItem {
28
+ /** The row's text, e.g. `j1 build`. Sanitized by the renderer. */
29
+ label: string;
30
+ /** Short trailing detail, e.g. `exit 1`. Sanitized by the renderer. */
31
+ detail?: string;
32
+ /** The row's severity; falls back to the notice's own. */
33
+ severity?: NoticeSeverity;
34
+ }
35
+
36
+ /** Everything the renderer draws, and nothing the model sees. */
37
+ export interface NoticeDetails {
38
+ /** Who raised it, e.g. `job` or `watch`. */
39
+ source: string;
40
+ /** One-line headline. */
41
+ title: string;
42
+ /** The headline's severity; a row may override it. */
43
+ severity?: NoticeSeverity;
44
+ /** Rows under the headline, in order. */
45
+ items?: readonly NoticeItem[];
46
+ }
47
+
48
+ export interface NoticeInput {
49
+ /** Model-facing text; the notice is also shown when non-empty. */
50
+ content: string;
51
+ /** Renderer-facing structure. */
52
+ details: NoticeDetails;
53
+ }
54
+
55
+ /** The message shape both send paths accept (`pi.sendMessage`, `before_agent_start`). */
56
+ export interface NoticeMessage {
57
+ customType: string;
58
+ content: string;
59
+ display: true;
60
+ details: NoticeDetails;
61
+ }
62
+
63
+ /** A conversation message narrowed to a notice. */
64
+ export type NoticeEntry = AgentMessage & { customType: string; details?: NoticeDetails };
65
+
66
+ /** Build a visible notice message: shared type, `display: true`, the given content and details. */
67
+ export function noticeMessage(input: NoticeInput): NoticeMessage {
68
+ return { customType: NOTICE_TYPE, content: input.content, display: true, details: input.details };
69
+ }
70
+
71
+ /** Whether a conversation message is a notice. */
72
+ export function isNotice(message: AgentMessage): message is NoticeEntry {
73
+ return message.role === "custom" && (message as { customType?: string }).customType === NOTICE_TYPE;
74
+ }
75
+
76
+ /**
77
+ * Keep only the newest notice of `source` in a transcript, so stale injected
78
+ * context does not accumulate. Notices of other sources and every non-notice
79
+ * are left alone. Returns `undefined` when nothing is stale, so a `context`
80
+ * handler can leave the messages untouched.
81
+ */
82
+ export function pruneStaleNotices(messages: readonly AgentMessage[], source: string): AgentMessage[] | undefined {
83
+ const ofSource = messages.filter((message) => isNotice(message) && message.details?.source === source);
84
+ if (ofSource.length <= 1) return undefined;
85
+ const keep = ofSource[ofSource.length - 1];
86
+ return messages.filter((message) => !(isNotice(message) && message.details?.source === source && message !== keep));
87
+ }
package/lib/tui/chrome.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
20
20
  import { Text, truncateToWidth, visibleWidth, type EditorTheme, type OverlayOptions } from "@earendil-works/pi-tui";
21
21
  import { SEPARATORS } from "./glyphs.ts";
22
+ import { BODY_INDENT } from "./indent.ts";
22
23
 
23
24
  /**
24
25
  * `ctx.ui.custom` options that give a screen the whole terminal.
@@ -65,23 +66,26 @@ export function viewportRows(source: ViewportRowsSource | undefined, options: Vi
65
66
  export type ScreenHint = string | { text: string; color?: ThemeColor };
66
67
 
67
68
  /**
68
- * A two-space-indented key-hint footer, joined with ` · `. Plain hints are dim;
69
+ * A key-hint footer indented by `indent` columns (default `BODY_INDENT`, which
70
+ * hangs it under a screen's title rule), joined with ` · `. Plain hints are dim;
69
71
  * an object hint uses its color (dim when omitted). Hints are added while they
70
72
  * fit, so a narrow terminal drops whole keys instead of truncating one in half.
71
73
  * The first hint is always kept; the result is clipped to `width`.
72
74
  */
73
- export function screenHint(theme: Theme, width: number, hints: ScreenHint[]): string {
75
+ export function screenHint(theme: Theme, width: number, hints: ScreenHint[], indent = BODY_INDENT): string {
74
76
  const target = Math.max(1, width);
77
+ // Keep a column for the hint itself, however deep the caller indents.
78
+ const lead = " ".repeat(Math.max(0, Math.min(Math.floor(indent), target - 1)));
75
79
  const render = (hint: ScreenHint): string =>
76
80
  typeof hint === "string" ? theme.fg("dim", hint) : theme.fg(hint.color ?? "dim", hint.text);
77
81
  const separator = theme.fg("dim", SEPARATORS.item);
78
82
  const kept: ScreenHint[] = [];
79
83
  for (const hint of hints) {
80
84
  const candidate = [...kept, hint].map(render).join(separator);
81
- if (kept.length > 0 && visibleWidth(` ${candidate}`) > target) break;
85
+ if (kept.length > 0 && visibleWidth(`${lead}${candidate}`) > target) break;
82
86
  kept.push(hint);
83
87
  }
84
- return truncateToWidth(` ${kept.map(render).join(separator)}`, target);
88
+ return truncateToWidth(`${lead}${kept.map(render).join(separator)}`, target);
85
89
  }
86
90
 
87
91
  /**
package/lib/tui/cursor.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  import type { Theme } from "@earendil-works/pi-coding-agent";
20
20
  import { Key, matchesKey, truncateToWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
21
21
  import { viewportRows, type ViewportRowsSource } from "./chrome.ts";
22
+ import { GLYPHS } from "./glyphs.ts";
22
23
 
23
24
  /**
24
25
  * Rows that fit for a list of `itemCount`, reserving one extra line when the
@@ -75,7 +76,7 @@ export function wheelDelta(event: TuiMouseEvent): number | undefined {
75
76
 
76
77
  /** The `❯ ` selection marker (accent) or a same-width blank. */
77
78
  export function selectionMarker(theme: Theme, selected: boolean): string {
78
- return selected ? theme.fg("accent", "❯ ") : " ";
79
+ return selected ? theme.fg("accent", `${GLYPHS.prompt} `) : " ";
79
80
  }
80
81
 
81
82
  /**
package/lib/tui/glyphs.ts CHANGED
@@ -10,14 +10,14 @@
10
10
  import { keyText, type Theme } from "@earendil-works/pi-coding-agent";
11
11
 
12
12
  /**
13
- * Leading glyphs for status chips and footer segments.
13
+ * Leading glyphs for status chips, footer segments, and transcript rails.
14
14
  *
15
- * Every glyph is a single terminal column in every profile: narrow or neutral
16
- * `East_Asian_Width`, never ambiguous, wide, or fullwidth, and never emoji. A
17
- * CJK-width profile renders an ambiguous glyph two columns wide, which drifts a
18
- * chip, a rail, or a footer row; `visibleWidth` cannot catch that (it counts an
19
- * ambiguous character as one column), so `lib/ui.test.ts` checks the width class
20
- * directly. Add a new glyph only after checking it against `EastAsianWidth.txt`.
15
+ * A glyph is a single terminal column: never wide, fullwidth, or emoji, which
16
+ * are two columns in every profile. East-Asian *ambiguous* glyphs are allowed —
17
+ * a CJK-width profile draws those two columns, so one costs a column there, but
18
+ * the vocabulary is no longer limited to narrow/neutral characters. Add a glyph
19
+ * only after checking its `East_Asian_Width` against `EastAsianWidth.txt`;
20
+ * `lib/tui/glyphs.test.ts` bans wide, fullwidth, and emoji.
21
21
  */
22
22
  export const GLYPHS = {
23
23
  branch: "⎇",
@@ -34,6 +34,7 @@ export const GLYPHS = {
34
34
  gaugeFull: "▰",
35
35
  gaugeEmpty: "▱",
36
36
  turnRule: "╌",
37
+ divider: "╎",
37
38
  check: "✓",
38
39
  warn: "!",
39
40
  cross: "✕",
@@ -47,18 +48,16 @@ export const GLYPHS = {
47
48
  boxOff: "◻",
48
49
  prev: "‹",
49
50
  next: "›",
51
+ prompt: "❯",
50
52
  } as const;
51
53
 
52
- /** Columns before a transcript rail body glyph, shared by `goal` and `todo`. */
53
- export const BODY_INDENT = 2;
54
-
55
54
  /** Columns between a rail body glyph and its text. */
56
55
  export const GLYPH_GAP = 1;
57
56
 
58
57
  /** Padding used to join (`item`) or group (`group`) UI segments. */
59
58
  export const SEPARATORS = {
60
59
  item: " · ",
61
- group: " │ ",
60
+ group: ` ${GLYPHS.divider} `,
62
61
  } as const;
63
62
 
64
63
  /** `ctx.ui.setStatus` keys; one per extension that publishes a chip. */
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The transcript indent: how a non-prose block sits under its headline.
3
+ *
4
+ * A tool block, a shared notice, and a subagent's quoted output all hang from one
5
+ * left edge. A block may lead with a headline (a tool's own call line) at the
6
+ * outer indent; the body sits `BODY_INDENT` further in, directly under it with no
7
+ * separator line, which is what keeps a tool block compact. The body's first
8
+ * indent column may instead carry a rail glyph, passed in pre-painted by the
9
+ * caller so this module stays value-only.
10
+ *
11
+ * Value-only: pure functions and constants, no theme.
12
+ */
13
+
14
+ import { type Component, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
+
16
+ /** Columns the body sits in beyond a block's outer indent. */
17
+ export const BODY_INDENT = 2;
18
+
19
+ /** How an indented block is placed. */
20
+ export interface IndentOptions {
21
+ /** Columns the body is indented; a headline sits `BODY_INDENT` to its left. */
22
+ indent?: number;
23
+ /**
24
+ * A pre-painted, one-column rail glyph for the body's first indent column; the
25
+ * rest of the indent stays spaces. Ignored below `BODY_INDENT`, where there is
26
+ * no column to spare. Default: no rail.
27
+ */
28
+ rail?: string;
29
+ /**
30
+ * Start the rail after the headline's continuation: keep the leading run of
31
+ * non-blank body lines plain-indented, up to the first blank line. Set when the
32
+ * headline degenerated to the bare subject name (a tool's call line whose path
33
+ * moved onto the body), so the rail begins with the result instead of the
34
+ * wrapped call. Ignored without a rail. Default false.
35
+ */
36
+ railAfterCall?: boolean;
37
+ /** Drop leading lines with no visible content. Default false. */
38
+ trimLeading?: boolean;
39
+ /** Drop trailing lines with no visible content. Default true. */
40
+ trimTrailing?: boolean;
41
+ /**
42
+ * Lead with the first non-blank line as the block's headline, at
43
+ * `indent - BODY_INDENT`, with the body immediately after it. Default false.
44
+ */
45
+ headline?: boolean;
46
+ /**
47
+ * Suffix appended when a body line is clipped. Default empty: a body is clipped
48
+ * silently. The headline always clips with `…`. A notice sets `…` so a
49
+ * truncated row reads as truncated.
50
+ */
51
+ bodyClip?: string;
52
+ }
53
+
54
+ /**
55
+ * A line with no visible text. Tested after stripping terminal sequences, so a
56
+ * line that is only spaces carrying a background (a `Box`'s own padding) counts
57
+ * as blank rather than as content.
58
+ */
59
+ function isBlank(line: string): boolean {
60
+ return stripTerminalSequences(line).trim().length === 0;
61
+ }
62
+
63
+ /** An image-protocol line must pass through unprefixed, or the sequence corrupts. */
64
+ const IMAGE_LINE = /^\u001b(?:P|_G|\]1337;)/;
65
+
66
+ /** The body indent, clamped so the prefix always leaves at least one column. */
67
+ function clampIndent(indent: number, width: number): number {
68
+ return Math.max(0, Math.min(Math.floor(indent), Math.max(0, width - 1)));
69
+ }
70
+
71
+ /**
72
+ * The body prefix: the rail glyph in the indent's first body column, or plain
73
+ * spaces. The glyph must be one visible column, so the prefix is exactly `indent`
74
+ * columns wide; the gap after it is the remaining `BODY_INDENT - 1`.
75
+ */
76
+ function bodyPrefix(indent: number, rail: string | undefined): string {
77
+ if (!rail || indent < BODY_INDENT) return " ".repeat(indent);
78
+ const base = Math.max(0, indent - BODY_INDENT);
79
+ return `${" ".repeat(base)}${rail}${" ".repeat(indent - base - 1)}`;
80
+ }
81
+
82
+ /**
83
+ * Indent each line of an already-rendered block by `indent` columns, clipping
84
+ * each to `width`. The block should be built at `width - indent` so nothing is
85
+ * lost to the prefix; the clip is only a safety net. A line carrying an image
86
+ * protocol sequence is passed through untouched, and trailing lines with no
87
+ * visible content are dropped (by default). When `headline` is set, the first
88
+ * non-blank line leads at `indent - BODY_INDENT` and the body follows directly —
89
+ * no separator line — and its own leading blanks are dropped so a tool's spacing
90
+ * never leaves a gap under the headline. With `rail` set, the body's first indent
91
+ * column carries the glyph instead of a space; `railAfterCall` keeps the call's
92
+ * continuation lines plain, so the rail starts with the result.
93
+ */
94
+ export function indentBlockLines(body: readonly string[], width: number, options: IndentOptions = {}): string[] {
95
+ const w = Math.max(1, width);
96
+ const indent = clampIndent(options.indent ?? 0, w);
97
+ const prefix = bodyPrefix(indent, options.rail);
98
+ const plainPrefix = " ".repeat(indent);
99
+
100
+ // The headline is the first line with something to say; it leads at the base.
101
+ let headline: string | undefined;
102
+ let rest: readonly string[] = body;
103
+ if (options.headline) {
104
+ const index = body.findIndex((line) => !IMAGE_LINE.test(line) && !isBlank(line));
105
+ if (index >= 0) {
106
+ headline = body[index];
107
+ rest = body.slice(index + 1);
108
+ }
109
+ }
110
+
111
+ // A call whose subject moved onto the body keeps those lines unrailed, so the
112
+ // rail starts with the result rather than under a bare tool name.
113
+ let railStart = 0;
114
+ if (options.rail && options.railAfterCall) {
115
+ while (railStart < rest.length && !isBlank(rest[railStart] ?? "")) railStart++;
116
+ }
117
+
118
+ const lines: string[] = [];
119
+ const blank: boolean[] = [];
120
+ for (const [index, line] of rest.entries()) {
121
+ if (IMAGE_LINE.test(line)) {
122
+ lines.push(line);
123
+ blank.push(false);
124
+ continue;
125
+ }
126
+ lines.push(truncateToWidth(`${index < railStart ? plainPrefix : prefix}${line}`, w, options.bodyClip ?? ""));
127
+ blank.push(isBlank(line));
128
+ }
129
+ if (options.trimLeading) {
130
+ while (blank.length > 0 && blank[0]) {
131
+ lines.shift();
132
+ blank.shift();
133
+ }
134
+ }
135
+ if (options.trimTrailing !== false) {
136
+ while (blank.length > 0 && blank[blank.length - 1]) {
137
+ lines.pop();
138
+ blank.pop();
139
+ }
140
+ }
141
+ if (headline !== undefined) {
142
+ const base = Math.max(0, indent - BODY_INDENT);
143
+ lines.unshift(truncateToWidth(`${" ".repeat(base)}${headline}`, w, "…"));
144
+ }
145
+ return lines;
146
+ }
147
+
148
+ /**
149
+ * Render `inner` at the width the indent leaves, so nothing is lost to the
150
+ * prefix. An absent `inner` renders nothing, so a tool with no output adds no
151
+ * blank block. The lines are not indented yet; a caller that has to inspect the
152
+ * block before placing it passes them to `indentBlockLines`.
153
+ */
154
+ export function bodyLines(inner: Component | undefined, width: number, indent = 0): string[] {
155
+ if (!inner) return [];
156
+ const w = Math.max(1, width);
157
+ const prefixWidth = visibleWidth(bodyPrefix(clampIndent(indent, w), undefined));
158
+ return inner.render(Math.max(1, w - prefixWidth));
159
+ }
160
+
161
+ /** Render `inner` at the width the indent leaves, then indent it. */
162
+ export function indentedLines(inner: Component | undefined, width: number, options: IndentOptions = {}): string[] {
163
+ const w = Math.max(1, width);
164
+ const indent = clampIndent(options.indent ?? 0, w);
165
+ return indentBlockLines(bodyLines(inner, w, indent), w, { ...options, indent });
166
+ }
package/lib/tui/index.ts CHANGED
@@ -7,7 +7,8 @@
7
7
  * `chrome.ts` (screen frame, hints, viewport, overlay), `cursor.ts` (list
8
8
  * scroll and navigation), `columns.ts` (width math), `rules.ts` (the rule
9
9
  * grammar), `rows.ts` (list rows, checkboxes, chips, empty states), `gauge.ts`
10
- * (the context gauge), and `line-pane.ts` (the scrollable log/match pane).
10
+ * (the context gauge), `indent.ts` (the transcript block indent), and
11
+ * `line-pane.ts` (the scrollable log/match pane).
11
12
  *
12
13
  * Value-only, like every `lib/` module: pure functions, constants, and classes,
13
14
  * with no module-level state.
@@ -19,6 +20,7 @@ export * from "./cursor.ts";
19
20
  export * from "./gauge.ts";
20
21
  export * from "./glyphs.ts";
21
22
  export * from "./line-pane.ts";
23
+ export * from "./indent.ts";
22
24
  export * from "./rows.ts";
23
25
  export * from "./rules.ts";
24
26
  export * from "./status-controller.ts";
package/lib/tui/rows.ts CHANGED
@@ -15,13 +15,20 @@
15
15
  import type { Theme } from "@earendil-works/pi-coding-agent";
16
16
  import { clip, padTo, splitRow } from "./columns.ts";
17
17
  import { selectionMarker } from "./cursor.ts";
18
- import { BODY_INDENT, GLYPHS } from "./glyphs.ts";
18
+ import { GLYPHS } from "./glyphs.ts";
19
+ import { BODY_INDENT } from "./indent.ts";
19
20
  import { paint, type TuiRole } from "./tokens.ts";
20
21
 
21
22
  /** One selectable list row. */
22
23
  export interface ListRow {
23
24
  /** Draw the `❯` marker in the accent role. A blank marker column is reserved either way. */
24
25
  selected?: boolean;
26
+ /**
27
+ * Reserve the `❯ ` marker column. Default true, so a column of rows stays
28
+ * aligned; a non-interactive row (a transcript result) sets false to drop the
29
+ * columns it can never use.
30
+ */
31
+ marker?: boolean;
25
32
  /**
26
33
  * A pre-themed cell between the marker and the glyph, e.g. a `checkbox`.
27
34
  * Reserved by the caller (pass a blank when the row has none) so a column of
@@ -45,13 +52,14 @@ export interface ListRow {
45
52
  * One list row: an optional indent, the selection marker, an optional glyph,
46
53
  * the label, and right-aligned trailing meta.
47
54
  *
48
- * The marker column is always reserved, so selected and unselected rows align.
49
- * The trailing meta is the anchor when the terminal is narrow (see
50
- * `splitRow`), so a value at the end of the row survives.
55
+ * The marker column is reserved by default, so selected and unselected rows
56
+ * align; `marker: false` drops it for a non-interactive row. The trailing meta
57
+ * is the anchor when the terminal is narrow (see `splitRow`), so a value at the
58
+ * end of the row survives.
51
59
  */
52
60
  export function listRow(theme: Theme, width: number, row: ListRow): string {
53
61
  const indent = " ".repeat(Math.max(0, Math.floor(row.indent ?? 0)));
54
- const marker = selectionMarker(theme, row.selected === true);
62
+ const marker = row.marker === false ? "" : selectionMarker(theme, row.selected === true);
55
63
  const leading = row.leading ? `${row.leading} ` : "";
56
64
  const glyph = row.glyph ? `${paint(theme, row.glyphRole ?? "info", row.glyph)} ` : "";
57
65
  const label = paint(theme, row.labelRole ?? "label", row.label);
@@ -59,6 +67,32 @@ export function listRow(theme: Theme, width: number, row: ListRow): string {
59
67
  return splitRow(`${indent}${marker}${leading}${glyph}${label}`, trailing, width);
60
68
  }
61
69
 
70
+ /** A status row (a job, a watcher): the marker, a status glyph, a label, and right-aligned meta. */
71
+ export interface StatusRow {
72
+ selected?: boolean;
73
+ /** Reserve the marker column. Default true. */
74
+ marker?: boolean;
75
+ glyph: string;
76
+ glyphRole: TuiRole;
77
+ label: string;
78
+ trailing: string;
79
+ /** Role for the trailing meta. Default `faint`. */
80
+ trailingRole?: TuiRole;
81
+ }
82
+
83
+ /** One row of a live status list, on the shared `listRow` grammar. */
84
+ export function statusRow(theme: Theme, width: number, row: StatusRow): string {
85
+ return listRow(theme, width, {
86
+ selected: row.selected === true,
87
+ marker: row.marker !== false,
88
+ glyph: row.glyph,
89
+ glyphRole: row.glyphRole,
90
+ label: row.label,
91
+ trailing: row.trailing,
92
+ trailingRole: row.trailingRole ?? "faint",
93
+ });
94
+ }
95
+
62
96
  /** The on/off checkbox glyph, in one role per state. */
63
97
  export function checkbox(theme: Theme, on: boolean, roles: { on?: TuiRole; off?: TuiRole } = {}): string {
64
98
  return on ? paint(theme, roles.on ?? "ok", GLYPHS.boxOn) : paint(theme, roles.off ?? "faint", GLYPHS.boxOff);
package/lib/tui/rules.ts CHANGED
@@ -33,6 +33,12 @@ export interface CenteredRuleStyle extends RuleStyle {
33
33
  pad?: number;
34
34
  }
35
35
 
36
+ /**
37
+ * The solid rule fill: the glyph a divider repeats. One constant names it, so
38
+ * the header rule, a panel title, and the user-message nameplate cannot drift.
39
+ */
40
+ export const RULE_GLYPH = "─";
41
+
36
42
  /** A usable origin: terminal arithmetic starts from one column, never zero. */
37
43
  function widthOf(width: number): number {
38
44
  return Math.max(1, Math.floor(width));
@@ -41,7 +47,7 @@ function widthOf(width: number): number {
41
47
  /** A full-width divider in one color. */
42
48
  export function plainRule(theme: Theme, width: number, style: RuleStyle = {}): string {
43
49
  const w = widthOf(width);
44
- return paint(theme, style.fillRole ?? "rule", (style.glyph ?? "─").repeat(w));
50
+ return paint(theme, style.fillRole ?? "rule", (style.glyph ?? RULE_GLYPH).repeat(w));
45
51
  }
46
52
 
47
53
  /** The label-first shape a screen or panel title uses. */
@@ -63,14 +69,14 @@ function labelRule(
63
69
  if (!text) return plainRule(theme, w, style);
64
70
  const labelWidth = visibleWidth(text);
65
71
  if (labelWidth + 1 >= w) return plainRule(theme, w, style);
66
- const fill = (style.glyph ?? "─").repeat(w - labelWidth - 1);
72
+ const fill = (style.glyph ?? RULE_GLYPH).repeat(w - labelWidth - 1);
67
73
  return paint(theme, style.labelRole ?? labelRole, text) + " " + paint(theme, style.fillRole ?? fillRole, fill);
68
74
  }
69
75
 
70
76
  /** A centered rule: fill, label, and the rest of the fill. */
71
77
  export function centeredRule(theme: Theme, width: number, label: string, style: CenteredRuleStyle = {}): string {
72
78
  const w = widthOf(width);
73
- const glyph = style.glyph ?? "─";
79
+ const glyph = style.glyph ?? RULE_GLYPH;
74
80
  const fillRole = style.fillRole ?? "ruleStrong";
75
81
  if (!label.trim()) return paint(theme, fillRole, glyph.repeat(w));
76
82
  const pad = " ".repeat(Math.max(0, Math.floor(style.pad ?? 1)));
package/lib/tui/tokens.ts CHANGED
@@ -56,3 +56,17 @@ export const TUI_TOKENS: Readonly<Record<TuiRole, ThemeColor>> = {
56
56
  export function paint(theme: Theme, role: TuiRole, text: string): string {
57
57
  return theme.fg(TUI_TOKENS[role], text);
58
58
  }
59
+
60
+ /**
61
+ * The opening sequence for a role, without its reset. A frame that must repaint
62
+ * a run in place — mid-line, where the surrounding text already carries the
63
+ * closing reset — opens with this instead of wrapping a whole string.
64
+ *
65
+ * Derived from `paint` with a sentinel, so it tracks the theme's own escape
66
+ * shape rather than assuming one.
67
+ */
68
+ export function paintPrefix(theme: Theme, role: TuiRole): string {
69
+ const marker = "\u0000";
70
+ const wrapped = paint(theme, role, marker);
71
+ return wrapped.slice(0, wrapped.indexOf(marker));
72
+ }
package/lib/web/base.ts CHANGED
@@ -8,7 +8,8 @@
8
8
 
9
9
  // Every focusable element gets the same ring; components must not re-declare
10
10
  // their own, so the keyboard affordance cannot disappear surface by surface.
11
- export const WEB_BASE_CSS = `*{box-sizing:border-box}
11
+ export const WEB_BASE_CSS = `html,body{margin:0}
12
+ *{box-sizing:border-box}
12
13
  [hidden]{display:none!important}
13
14
  a{color:var(--accent);text-decoration:none}
14
15
  a:hover{text-decoration:underline}
@@ -117,7 +117,9 @@ th[aria-sort="descending"] .bf-sort::after{content:" \\25BC";font-size:9px;color
117
117
  .bf-md .md-check{color:var(--success)}
118
118
  .bf-md table{margin:0;border-collapse:collapse;table-layout:auto;width:auto;font-size:var(--fs-sm)}
119
119
  .bf-md .md-table-wrap{overflow-x:auto;margin:0 0 1em}
120
- .bf-md th,.bf-md td{border:1px solid var(--border);padding:6px 10px;text-align:left;vertical-align:top}
120
+ .bf-md th,.bf-md td{border:1px solid var(--border);padding:6px 10px;text-align:left;vertical-align:top;overflow-wrap:break-word}
121
+ .bf-md th{white-space:nowrap}
122
+ .bf-md th code,.bf-md td code{white-space:nowrap}
121
123
  .bf-md thead th{background:var(--surface);font-weight:var(--fw-medium)}
122
124
  .bf-md .md-left{text-align:left}
123
125
  .bf-md .md-center{text-align:center}
package/lib/web/layout.ts CHANGED
@@ -11,15 +11,19 @@
11
11
  *
12
12
  * Only the shared 860px collapse lives here; the plan-specific rules stay in
13
13
  * `extensions/plan/web-page.ts`.
14
+ *
15
+ * The shell is a definite full-height column so the panes scroll inside it; the
16
+ * 860px breakpoint drops that back to normal page scroll for narrow screens.
14
17
  */
15
18
 
16
- export const WEB_LAYOUT_CSS = `.bf-app{display:flex;flex-direction:column;min-height:100vh;background:var(--canvas);color:var(--text);font:var(--fs-base)/var(--lh-normal) var(--font-ui)}
19
+ export const WEB_LAYOUT_CSS = `.bf-app{display:flex;flex-direction:column;height:100vh;min-height:0;overflow:hidden;background:var(--canvas);color:var(--text);font:var(--fs-base)/var(--lh-normal) var(--font-ui)}
17
20
  .bf-app__header{position:sticky;top:0;z-index:2;flex:0 0 auto;background:var(--surface);border-bottom:1px solid var(--border)}
18
21
  .bf-app__body{flex:1 1 auto;min-height:0;display:grid;grid-template-columns:260px minmax(0,1fr)}
19
22
  .bf-app__sidebar{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;background:var(--surface);border-right:1px solid var(--border)}
20
23
  .bf-app__main{min-width:0;min-height:0;display:flex;flex-direction:column;overflow:auto;padding:var(--space-5) var(--space-6)}
21
24
  .bf-app__footer{flex:0 0 auto;border-top:1px solid var(--border);color:var(--muted);font-size:var(--fs-sm);padding:var(--space-3) var(--space-6)}
22
25
  @media (max-width:860px){
26
+ .bf-app{height:auto;min-height:100vh;overflow:visible}
23
27
  .bf-app__body{flex:none;grid-template-columns:1fr}
24
28
  .bf-app__sidebar{display:block;position:static;max-height:none;overflow:visible;border-right:0;border-bottom:1px solid var(--border)}
25
29
  .bf-app__main{overflow:visible}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavin-hu/my-pi-agent",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Personal Pi customizations, packaged as a Pi package: worktree isolation and rewind snapshots, question, todo, goal, plan, subagent, job, watch, web-access, file-browse, file-open, document, memory, wechat, and bytefly.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -90,7 +90,7 @@
90
90
  "themes": [
91
91
  "./themes/*.json"
92
92
  ],
93
- "image": "https://raw.githubusercontent.com/gavin-hu/my-pi-agent/main/assets/bytefly-chrome.png"
93
+ "image": "https://raw.githubusercontent.com/gavin-hu/my-pi-agent/main/assets/chrome.png"
94
94
  },
95
95
  "scripts": {
96
96
  "test": "bun test --parallel=4 --timeout 30000",
@@ -102,7 +102,11 @@
102
102
  "check": "bun run format:check && bun run typecheck && bun run test",
103
103
  "check:serial": "bun run format:check && bun run typecheck && bun run test:serial",
104
104
  "release:notes": "bun run scripts/release-notes.ts",
105
- "config:migrate": "bun run scripts/migrate-config.ts"
105
+ "config:migrate": "bun run scripts/migrate-config.ts",
106
+ "screenshot:plan": "bun run scripts/plan-review-shot.ts",
107
+ "screenshot:tools": "bun run scripts/tool-renderings.ts gallery assets/tool-renderings.png",
108
+ "screenshot:tool-pngs": "bun run scripts/tool-renderings.ts tools assets/tools",
109
+ "screenshots": "bun run scripts/screenshots.ts"
106
110
  },
107
111
  "dependencies": {
108
112
  "exceljs": "^4.4.0",
@@ -1,12 +1,12 @@
1
1
  {
2
- "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
2
+ "$schema": "https://pi.dev/schemas/theme.schema.json",
3
3
  "name": "bytefly-dark",
4
4
  "appearance": "dark",
5
5
  "vars": {
6
6
  "canvas": "#0d1117",
7
7
  "canvasSubtle": "#161b22",
8
8
  "border": "#6e7681",
9
- "borderMuted": "#484f58",
9
+ "borderMuted": "#5a626b",
10
10
  "text": "#c9d1d9",
11
11
  "muted": "#8b949e",
12
12
  "dim": "#6e7681",
@@ -17,7 +17,6 @@
17
17
  "purple": "#bc8cff",
18
18
  "pink": "#f778ba",
19
19
  "selectionBg": "#17273a",
20
- "userBg": "#1e2a44",
21
20
  "customBg": "#26233a",
22
21
  "successBg": "#12271a",
23
22
  "errorBg": "#3d1a21",
@@ -39,10 +38,10 @@
39
38
  "thinkingText": "muted",
40
39
  "selectedBg": "selectionBg",
41
40
  "scrollbarTrack": "borderMuted",
42
- "scrollbarThumb": "border",
41
+ "scrollbarThumb": "muted",
43
42
  "searchMatchBg": "searchBg",
44
43
  "searchMatchText": "text",
45
- "userMessageBg": "userBg",
44
+ "userMessageBg": "",
46
45
  "userMessageText": "text",
47
46
  "customMessageBg": "customBg",
48
47
  "customMessageText": "muted",
@@ -50,14 +49,14 @@
50
49
  "toolPendingBg": "canvasSubtle",
51
50
  "toolSuccessBg": "successBg",
52
51
  "toolErrorBg": "errorBg",
53
- "toolTitle": "text",
52
+ "toolTitle": "blue",
54
53
  "toolOutput": "muted",
55
- "mdHeading": "yellow",
54
+ "mdHeading": "blue",
56
55
  "mdLink": "blue",
57
56
  "mdLinkUrl": "muted",
58
57
  "mdCode": "pink",
59
58
  "mdCodeBlock": "text",
60
- "mdCodeBlockBorder": "border",
59
+ "mdCodeBlockBorder": "muted",
61
60
  "mdQuote": "muted",
62
61
  "mdQuoteBorder": "border",
63
62
  "mdHr": "border",