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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/README.md +35 -8
  2. package/README.zh-CN.md +33 -8
  3. package/assets/bytefly.schema.json +17 -1
  4. package/assets/chrome.png +0 -0
  5. package/assets/file-browse.png +0 -0
  6. package/assets/job.png +0 -0
  7. package/assets/plan-review.png +0 -0
  8. package/assets/question.png +0 -0
  9. package/assets/rewind.png +0 -0
  10. package/assets/todo.png +0 -0
  11. package/assets/tool-renderings.png +0 -0
  12. package/assets/tools/ask_user_question.png +0 -0
  13. package/assets/tools/bash.png +0 -0
  14. package/assets/tools/edit.png +0 -0
  15. package/assets/tools/enter_plan_mode.png +0 -0
  16. package/assets/tools/enter_worktree.png +0 -0
  17. package/assets/tools/exit_plan_mode.png +0 -0
  18. package/assets/tools/exit_worktree.png +0 -0
  19. package/assets/tools/find.png +0 -0
  20. package/assets/tools/goal.png +0 -0
  21. package/assets/tools/grep.png +0 -0
  22. package/assets/tools/job.png +0 -0
  23. package/assets/tools/list_worktrees.png +0 -0
  24. package/assets/tools/ls.png +0 -0
  25. package/assets/tools/memory.png +0 -0
  26. package/assets/tools/notice.png +0 -0
  27. package/assets/tools/open_path.png +0 -0
  28. package/assets/tools/prune_worktrees.png +0 -0
  29. package/assets/tools/read.png +0 -0
  30. package/assets/tools/read_doc.png +0 -0
  31. package/assets/tools/run_doctor.png +0 -0
  32. package/assets/tools/send_wechat.png +0 -0
  33. package/assets/tools/set_plan_steps.png +0 -0
  34. package/assets/tools/subagent.png +0 -0
  35. package/assets/tools/todo.png +0 -0
  36. package/assets/tools/turn-rule.png +0 -0
  37. package/assets/tools/user-message.png +0 -0
  38. package/assets/tools/watch.png +0 -0
  39. package/assets/tools/web_fetch.png +0 -0
  40. package/assets/tools/web_search.png +0 -0
  41. package/assets/tools/write.png +0 -0
  42. package/assets/tools/write_plan.png +0 -0
  43. package/extensions/bytefly/README.md +306 -68
  44. package/extensions/bytefly/block/component.ts +282 -0
  45. package/extensions/bytefly/block/config.ts +24 -0
  46. package/extensions/bytefly/block/fallback.ts +120 -0
  47. package/extensions/bytefly/block/register.ts +164 -0
  48. package/extensions/bytefly/chrome.ts +97 -0
  49. package/extensions/bytefly/doctor/format.ts +36 -10
  50. package/extensions/bytefly/doctor/tools.ts +10 -6
  51. package/extensions/bytefly/footer/config.ts +1 -1
  52. package/extensions/bytefly/footer/render/format.ts +5 -18
  53. package/extensions/bytefly/footer/render/layout.ts +2 -1
  54. package/extensions/bytefly/footer/render/segments.ts +51 -78
  55. package/extensions/bytefly/header/component.ts +49 -21
  56. package/extensions/bytefly/header/config.ts +28 -21
  57. package/extensions/bytefly/header/register.ts +6 -1
  58. package/extensions/bytefly/index.ts +32 -12
  59. package/extensions/bytefly/message/config.ts +15 -0
  60. package/extensions/bytefly/message/format.ts +42 -0
  61. package/extensions/bytefly/message/register.ts +15 -0
  62. package/extensions/bytefly/notice/component.ts +107 -0
  63. package/extensions/bytefly/notice/config.ts +41 -0
  64. package/extensions/bytefly/notice/register.ts +27 -0
  65. package/extensions/bytefly/pad.ts +42 -0
  66. package/extensions/bytefly/panel/commands.ts +69 -35
  67. package/extensions/bytefly/panel/format.ts +8 -8
  68. package/extensions/bytefly/panel/tui.ts +45 -70
  69. package/extensions/bytefly/separator/component.ts +2 -1
  70. package/extensions/bytefly/separator/config.ts +16 -8
  71. package/extensions/bytefly/separator/format.ts +18 -38
  72. package/extensions/bytefly/separator/register.ts +3 -2
  73. package/extensions/bytefly/separator/types.ts +0 -10
  74. package/extensions/bytefly/working/config.ts +20 -0
  75. package/extensions/bytefly/working/format.ts +25 -0
  76. package/extensions/bytefly/working/register.ts +88 -0
  77. package/extensions/document/README.md +12 -5
  78. package/extensions/document/paging.ts +10 -10
  79. package/extensions/document/render.ts +17 -16
  80. package/extensions/document/tool.ts +1 -1
  81. package/extensions/file-browse/README.md +54 -10
  82. package/extensions/file-browse/client.ts +110 -2
  83. package/extensions/file-browse/config.ts +12 -0
  84. package/extensions/file-browse/diff.ts +187 -0
  85. package/extensions/file-browse/git.ts +180 -8
  86. package/extensions/file-browse/html.ts +401 -25
  87. package/extensions/file-browse/index.ts +15 -4
  88. package/extensions/file-browse/markdown.ts +33 -0
  89. package/extensions/file-browse/router.ts +94 -2
  90. package/extensions/file-browse/server.ts +4 -1
  91. package/extensions/file-open/README.md +6 -0
  92. package/extensions/goal/README.md +16 -10
  93. package/extensions/goal/format.ts +13 -25
  94. package/extensions/goal/tools.ts +11 -22
  95. package/extensions/goal/tui.ts +12 -40
  96. package/extensions/job/README.md +22 -13
  97. package/extensions/job/format.ts +36 -13
  98. package/extensions/job/index.ts +26 -21
  99. package/extensions/job/tools.ts +15 -12
  100. package/extensions/job/tui.ts +148 -222
  101. package/extensions/job/ui.ts +24 -49
  102. package/extensions/memory/README.md +7 -1
  103. package/extensions/memory/format.ts +5 -3
  104. package/extensions/memory/tools.ts +4 -3
  105. package/extensions/memory/tui.ts +32 -36
  106. package/extensions/plan/README.md +86 -36
  107. package/extensions/plan/blocks.ts +44 -2
  108. package/extensions/plan/feedback.ts +20 -5
  109. package/extensions/plan/index.ts +1 -1
  110. package/extensions/plan/runtime.ts +3 -2
  111. package/extensions/plan/tools.ts +43 -27
  112. package/extensions/plan/tui.ts +21 -27
  113. package/extensions/plan/web-client.ts +386 -40
  114. package/extensions/plan/web-page.ts +138 -33
  115. package/extensions/plan/web-router.ts +46 -13
  116. package/extensions/plan/web.ts +63 -12
  117. package/extensions/question/README.md +14 -2
  118. package/extensions/question/answers.ts +25 -18
  119. package/extensions/question/tools.ts +22 -16
  120. package/extensions/question/tui.ts +132 -76
  121. package/extensions/rewind/README.md +3 -1
  122. package/extensions/rewind/format.ts +35 -2
  123. package/extensions/rewind/index.ts +3 -1
  124. package/extensions/rewind/rewind-tui.ts +66 -62
  125. package/extensions/rewind/snapshot-run.ts +1 -1
  126. package/extensions/rewind/status.ts +3 -2
  127. package/extensions/subagent/README.md +6 -0
  128. package/extensions/subagent/index.ts +1 -1
  129. package/extensions/subagent/render.ts +108 -66
  130. package/extensions/todo/README.md +20 -13
  131. package/extensions/todo/format.ts +47 -34
  132. package/extensions/todo/tools.ts +6 -4
  133. package/extensions/todo/tui.ts +46 -26
  134. package/extensions/watch/README.md +16 -8
  135. package/extensions/watch/format.ts +33 -13
  136. package/extensions/watch/index.ts +29 -24
  137. package/extensions/watch/tools.ts +16 -9
  138. package/extensions/watch/tui.ts +142 -196
  139. package/extensions/watch/ui.ts +24 -46
  140. package/extensions/web-access/README.md +16 -7
  141. package/extensions/web-access/fetch/tool.ts +12 -11
  142. package/extensions/web-access/search/tool.ts +16 -13
  143. package/extensions/wechat/README.md +8 -2
  144. package/extensions/wechat/index.ts +4 -3
  145. package/extensions/wechat/login-screen.ts +8 -5
  146. package/extensions/wechat/status-screen.ts +41 -22
  147. package/extensions/wechat/tools.ts +6 -5
  148. package/extensions/worktree/README.md +6 -0
  149. package/extensions/worktree/enter.ts +1 -1
  150. package/extensions/worktree/index.ts +1 -1
  151. package/extensions/worktree/runtime.ts +1 -1
  152. package/lib/README.md +11 -7
  153. package/lib/format.ts +17 -0
  154. package/lib/notice.ts +87 -0
  155. package/lib/requirements.ts +2 -2
  156. package/lib/{tui.ts → tui/chrome.ts} +17 -34
  157. package/lib/tui/columns.ts +96 -0
  158. package/lib/{list-cursor.ts → tui/cursor.ts} +58 -4
  159. package/lib/tui/gauge.ts +78 -0
  160. package/lib/{ui.ts → tui/glyphs.ts} +10 -11
  161. package/lib/tui/indent.ts +166 -0
  162. package/lib/tui/index.ts +27 -0
  163. package/lib/tui/line-pane.ts +173 -0
  164. package/lib/tui/rows.ts +143 -0
  165. package/lib/tui/rules.ts +94 -0
  166. package/lib/tui/status-controller.ts +107 -0
  167. package/lib/tui/tokens.ts +72 -0
  168. package/lib/web/base.ts +2 -1
  169. package/lib/web/components.ts +3 -1
  170. package/lib/web/layout.ts +5 -1
  171. package/package.json +7 -3
  172. package/themes/bytefly-dark.json +7 -8
  173. package/themes/bytefly-light.json +10 -11
  174. package/assets/bytefly-chrome.png +0 -0
@@ -18,7 +18,8 @@
18
18
 
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
- import { SEPARATORS } from "./ui.ts";
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.
@@ -36,21 +37,6 @@ export const FULL_SCREEN_OVERLAY: { overlay: true; overlayOptions: OverlayOption
36
37
  overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", margin: 0 },
37
38
  };
38
39
 
39
- /**
40
- * Top border with `label` centered-left, exactly `width` columns wide. When the
41
- * terminal is too narrow for the title and a border on each side, it degrades to
42
- * a plain rule rather than truncating the title into an ellipsis.
43
- */
44
- export function screenHeader(theme: Theme, width: number, label: string): string {
45
- const text = ` ${label} `;
46
- const prefix = "───";
47
- if (width < visibleWidth(prefix) + visibleWidth(text) + 1) {
48
- return theme.fg("borderMuted", "─".repeat(Math.max(0, width)));
49
- }
50
- const remaining = width - visibleWidth(prefix) - visibleWidth(text);
51
- return theme.fg("borderMuted", prefix) + theme.fg("accent", text) + theme.fg("borderMuted", "─".repeat(remaining));
52
- }
53
-
54
40
  /** A row count, or a live getter so a screen re-sizes with the terminal. */
55
41
  export type ViewportRowsSource = number | (() => number | undefined);
56
42
 
@@ -80,23 +66,26 @@ export function viewportRows(source: ViewportRowsSource | undefined, options: Vi
80
66
  export type ScreenHint = string | { text: string; color?: ThemeColor };
81
67
 
82
68
  /**
83
- * 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;
84
71
  * an object hint uses its color (dim when omitted). Hints are added while they
85
72
  * fit, so a narrow terminal drops whole keys instead of truncating one in half.
86
73
  * The first hint is always kept; the result is clipped to `width`.
87
74
  */
88
- export function screenHint(theme: Theme, width: number, hints: ScreenHint[]): string {
75
+ export function screenHint(theme: Theme, width: number, hints: ScreenHint[], indent = BODY_INDENT): string {
89
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)));
90
79
  const render = (hint: ScreenHint): string =>
91
80
  typeof hint === "string" ? theme.fg("dim", hint) : theme.fg(hint.color ?? "dim", hint.text);
92
81
  const separator = theme.fg("dim", SEPARATORS.item);
93
82
  const kept: ScreenHint[] = [];
94
83
  for (const hint of hints) {
95
84
  const candidate = [...kept, hint].map(render).join(separator);
96
- if (kept.length > 0 && visibleWidth(` ${candidate}`) > target) break;
85
+ if (kept.length > 0 && visibleWidth(`${lead}${candidate}`) > target) break;
97
86
  kept.push(hint);
98
87
  }
99
- return truncateToWidth(` ${kept.map(render).join(separator)}`, target);
88
+ return truncateToWidth(`${lead}${kept.map(render).join(separator)}`, target);
100
89
  }
101
90
 
102
91
  /**
@@ -105,12 +94,8 @@ export function screenHint(theme: Theme, width: number, hints: ScreenHint[]): st
105
94
  * the frame only places them.
106
95
  */
107
96
  export interface DockListFrame {
108
- /** Semantic theme for the header rule and the hint row. */
109
- theme: Theme;
110
- /** Body width in columns. */
111
- width: number;
112
- /** The header label, e.g. `Extensions`. */
113
- title: string;
97
+ /** The pre-rendered header line, usually `titleRule(theme, width, title)`. */
98
+ header: string;
114
99
  /** A summary line under the header; a blank line follows it. */
115
100
  summary?: string;
116
101
  /** The list rows, or the screen's single empty-state line. */
@@ -124,18 +109,16 @@ export interface DockListFrame {
124
109
  }
125
110
 
126
111
  /**
127
- * Assemble a docked list screen: the `─` header rule, an optional summary and
128
- * its blank line, the body, an optional range row, optional detail rows, and
112
+ * Assemble a docked list screen: the caller's header rule, an optional summary
113
+ * and its blank line, the body, an optional range row, optional detail rows, and
129
114
  * the blank-line-padded key-hint footer.
130
115
  *
131
- * This is the convention the bytefly panel (`/bytefly`, and the `/extensions`
132
- * and `/doctor` aliases) shares with `/jobs` and `/todos`. `/rewind` (a blank
133
- * after the header and no blank before the hint) places its blanks differently,
134
- * so it stays hand-rolled rather than changing its output to fit this helper.
116
+ * This is the convention every docked list screen shares — the bytefly panel,
117
+ * `/jobs`, `/watch`, `/todos`, `/memory`, and `/rewind`. The header line is built
118
+ * by the caller (usually `titleRule`), so the frame never picks a rule style.
135
119
  */
136
120
  export function dockListFrame(frame: DockListFrame): string[] {
137
- const width = Math.max(1, frame.width);
138
- const lines: string[] = [screenHeader(frame.theme, width, frame.title)];
121
+ const lines: string[] = [frame.header];
139
122
  if (frame.summary !== undefined) lines.push(frame.summary, "");
140
123
  lines.push(...frame.body);
141
124
  if (frame.range !== undefined) lines.push(frame.range);
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Width arithmetic for the terminal: justify, fit, pad, and clip lines.
3
+ *
4
+ * Every function works in terminal columns via `visibleWidth` /
5
+ * `truncateToWidth`, never string length, and every result fits the width it
6
+ * was given. Themed strings pass through unchanged except where a cell has to
7
+ * be clipped, so a color survives every cell that fits.
8
+ *
9
+ * Value-only: pure functions over their arguments.
10
+ */
11
+
12
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
+
14
+ /** A usable origin: terminal arithmetic starts from one column, never zero. */
15
+ function columns(width: number): number {
16
+ return Math.max(1, Math.floor(width));
17
+ }
18
+
19
+ /** Clip a possibly-themed string to `width` columns, marking a cut with `ellipsis`. */
20
+ export function clip(text: string, width: number, ellipsis = "…"): string {
21
+ return truncateToWidth(text, columns(width), ellipsis);
22
+ }
23
+
24
+ /** Pad `text` on the right to exactly `width` columns. Never truncates. */
25
+ export function padTo(text: string, width: number): string {
26
+ const missing = columns(width) - visibleWidth(text);
27
+ return missing > 0 ? text + " ".repeat(missing) : text;
28
+ }
29
+
30
+ /**
31
+ * Left/right justify on one line: the left cell sits at the margin, the right
32
+ * cell at the edge, and at least `minGap` columns separate them.
33
+ *
34
+ * The right cell is the anchor — it is clipped only after the left cell has
35
+ * been clipped away — so a value at the end of a line (a model name, a metric)
36
+ * survives a tight terminal instead of vanishing. An empty right cell means a
37
+ * plain left-aligned line with no trailing spaces.
38
+ */
39
+ export function splitRow(left: string, right: string, width: number, minGap = 1): string {
40
+ const w = columns(width);
41
+ if (!right) return clip(left, w);
42
+ const gap = Math.max(0, Math.floor(minGap));
43
+ const rightCell = clip(right, w);
44
+ const rightWidth = visibleWidth(rightCell);
45
+ const leftBudget = w - rightWidth - gap;
46
+ if (leftBudget <= 0) return rightCell;
47
+ return padTo(clip(left, leftBudget), w - rightWidth) + rightCell;
48
+ }
49
+
50
+ /**
51
+ * Join as many whole cells as fit, dropping trailing cells rather than
52
+ * clipping one in half. The first cell is always kept, so an over-wide line
53
+ * degrades to a clipped first cell instead of an empty one.
54
+ */
55
+ export function fitCells(cells: readonly string[], width: number, separator = " "): string {
56
+ const w = columns(width);
57
+ const kept: string[] = [];
58
+ for (const cell of cells) {
59
+ const candidate = [...kept, cell].join(separator);
60
+ if (kept.length > 0 && visibleWidth(candidate) > w) break;
61
+ kept.push(cell);
62
+ }
63
+ return clip(kept.join(separator), w);
64
+ }
65
+
66
+ /**
67
+ * Lay atomic, already-themed cells onto lines no wider than `width`.
68
+ *
69
+ * Cells are never split: one that would not fit moves to the next line, and one
70
+ * wider than the whole width is clipped. Use this for a row of independent
71
+ * tokens (tabs, tags) where `fitCells`' single-line drop would lose one; unlike
72
+ * {@link fitCells} it may return several lines, and always returns one.
73
+ */
74
+ export function flowCells(cells: readonly string[], width: number): string[] {
75
+ const w = columns(width);
76
+ const lines: string[] = [];
77
+ let current = "";
78
+ let currentWidth = 0;
79
+ for (const cell of cells) {
80
+ let text = cell;
81
+ let cellWidth = visibleWidth(cell);
82
+ if (cellWidth > w) {
83
+ text = truncateToWidth(cell, w);
84
+ cellWidth = visibleWidth(text);
85
+ }
86
+ if (currentWidth > 0 && currentWidth + cellWidth > w) {
87
+ lines.push(current);
88
+ current = "";
89
+ currentWidth = 0;
90
+ }
91
+ current += text;
92
+ currentWidth += cellWidth;
93
+ }
94
+ lines.push(current);
95
+ return lines;
96
+ }
@@ -3,18 +3,23 @@
3
3
  * it (`/rewind`, `/todos`, `/jobs`).
4
4
  *
5
5
  * Each screen keeps its own layout, rows, and selection model — rewind uses an
6
- * index, jobs use a stable id, todos have no cursor at all — but
6
+ * index, jobs and watchers use a stable id, todos have no cursor at all — but
7
7
  * they all need the same arithmetic: how many rows fit, how to keep the focus
8
8
  * in view, which keys navigate, and how to render the "showing X–Y of N" row.
9
9
  * That shared part lives here so a fix or a rebinding happens once.
10
10
  *
11
- * No runtime dependencies beyond `lib/tui.ts` and `@earendil-works/pi-tui`; the
11
+ * `ListCursor` tracks an index, which is right for a list whose shape is the
12
+ * screen's own. `IdCursor` tracks a stable id, which is right for a list that
13
+ * is re-sorted from live state on every render.
14
+ *
15
+ * No runtime dependencies beyond `lib/tui/chrome.ts` and `@earendil-works/pi-tui`; the
12
16
  * theme is passed in and there is no cross-extension state.
13
17
  */
14
18
 
15
19
  import type { Theme } from "@earendil-works/pi-coding-agent";
16
20
  import { Key, matchesKey, truncateToWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
17
- import { viewportRows, type ViewportRowsSource } from "./tui.ts";
21
+ import { viewportRows, type ViewportRowsSource } from "./chrome.ts";
22
+ import { GLYPHS } from "./glyphs.ts";
18
23
 
19
24
  /**
20
25
  * Rows that fit for a list of `itemCount`, reserving one extra line when the
@@ -71,7 +76,7 @@ export function wheelDelta(event: TuiMouseEvent): number | undefined {
71
76
 
72
77
  /** The `❯ ` selection marker (accent) or a same-width blank. */
73
78
  export function selectionMarker(theme: Theme, selected: boolean): string {
74
- return selected ? theme.fg("accent", "❯ ") : " ";
79
+ return selected ? theme.fg("accent", `${GLYPHS.prompt} `) : " ";
75
80
  }
76
81
 
77
82
  /**
@@ -147,6 +152,55 @@ export class ListCursor {
147
152
  }
148
153
  }
149
154
 
155
+ /**
156
+ * Cursor and scroll for a list whose order changes under it.
157
+ *
158
+ * A live list (jobs, watchers) is re-read and re-sorted on every render, so an
159
+ * index would silently point at a different row. This cursor remembers the id
160
+ * of the focused row and re-derives its index against the current items, falling
161
+ * back to the first row when that id is gone. It is pure state: the screen owns
162
+ * `requestRender`, and assigns `visible` from {@link fitRows} each render.
163
+ */
164
+ export class IdCursor {
165
+ /** Id of the focused row, or `undefined` before the first paint. */
166
+ id: string | undefined;
167
+ /** First visible row. */
168
+ scroll = 0;
169
+ /** Rows shown at once; the screen sets this before use. */
170
+ visible = 1;
171
+
172
+ /** Index of the focused row, or `0` when its id is gone or the list is empty. */
173
+ indexOf<T>(items: readonly T[], key: (item: T) => string): number {
174
+ if (items.length === 0) return 0;
175
+ const index = this.id === undefined ? -1 : items.findIndex((item) => key(item) === this.id);
176
+ return index >= 0 ? index : 0;
177
+ }
178
+
179
+ /** Re-anchor the id against the current items; never repaints. */
180
+ clamp<T>(items: readonly T[], key: (item: T) => string): void {
181
+ if (items.length === 0) {
182
+ this.id = undefined;
183
+ this.scroll = 0;
184
+ return;
185
+ }
186
+ this.id = key(items[this.indexOf(items, key)]);
187
+ this.scroll = clampScroll(this.scroll, items.length, this.visible);
188
+ }
189
+
190
+ /** Focus the row at `index` (clamped) and scroll it into view. */
191
+ select<T>(items: readonly T[], key: (item: T) => string, index: number): void {
192
+ if (items.length === 0) return;
193
+ const clamped = Math.min(Math.max(0, index), items.length - 1);
194
+ this.id = key(items[clamped]);
195
+ this.ensureVisible(clamped);
196
+ }
197
+
198
+ /** Scroll `index` into view without moving the focus. */
199
+ ensureVisible(index: number): void {
200
+ this.scroll = Math.max(0, keepVisible(this.scroll, index, this.visible));
201
+ }
202
+ }
203
+
150
204
  /**
151
205
  * Apply a standard navigation key to `cursor`, reporting whether it handled it.
152
206
  *
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The gauge: filled and empty blocks, and the tone they carry.
3
+ *
4
+ * One implementation for every bar in the package — the footer's context
5
+ * meter, a plan's scroll position — so the block glyphs, the floor, and the
6
+ * tone thresholds cannot drift between surfaces.
7
+ *
8
+ * Value-only: pure functions, theme passed in.
9
+ */
10
+
11
+ import type { Theme } from "@earendil-works/pi-coding-agent";
12
+ import { GLYPHS } from "./glyphs.ts";
13
+ import { paint, type TuiRole } from "./tokens.ts";
14
+
15
+ /** The percentages at which a gauge changes tone. */
16
+ export interface GaugeThresholds {
17
+ /** Above this (exclusive), the gauge warns. */
18
+ warn: number;
19
+ /** Above this (exclusive), the gauge is in error. */
20
+ danger: number;
21
+ }
22
+
23
+ /** The package default: warn past 70%, error past 90%. */
24
+ export const DEFAULT_GAUGE_THRESHOLDS: GaugeThresholds = { warn: 70, danger: 90 };
25
+
26
+ /**
27
+ * Filled and empty block counts for a percentage across `blocks` cells.
28
+ *
29
+ * The fill floors so the bar reads full only at 100%, and forces one block for
30
+ * any non-zero usage so a small percentage is still visible. A `null` percent
31
+ * (unknown) fills nothing, leaving the caller to decide whether to draw the
32
+ * empty track; a known `0` draws it.
33
+ */
34
+ export function computeGauge(percent: number | null, blocks: number): { filled: number; empty: number } {
35
+ if (blocks <= 0) return { filled: 0, empty: 0 };
36
+ if (percent === null) return { filled: 0, empty: blocks };
37
+ const clamped = Math.max(0, Math.min(100, percent));
38
+ const filled = Math.max(clamped > 0 ? 1 : 0, Math.floor((clamped / 100) * blocks));
39
+ return { filled, empty: blocks - filled };
40
+ }
41
+
42
+ /** The role a context percentage is drawn in. Neutral until it crosses a threshold. */
43
+ export function gaugeRole(percent: number | null, thresholds: GaugeThresholds = DEFAULT_GAUGE_THRESHOLDS): TuiRole {
44
+ if (percent === null) return "meta";
45
+ if (percent > thresholds.danger) return "bad";
46
+ if (percent > thresholds.warn) return "warn";
47
+ return "meta";
48
+ }
49
+
50
+ /** A gauge bar's content. */
51
+ export interface GaugeBarOptions {
52
+ /** The percentage, or `null` when it is unknown. */
53
+ percent: number | null;
54
+ /** Cells the track occupies. */
55
+ blocks: number;
56
+ /** Override the role instead of deriving it from the thresholds. */
57
+ role?: TuiRole;
58
+ /** Role for the empty track. Defaults to the filled role. */
59
+ emptyRole?: TuiRole;
60
+ thresholds?: GaugeThresholds;
61
+ }
62
+
63
+ /**
64
+ * The colored bar, or `""` when the percentage is unknown.
65
+ *
66
+ * Unknown and zero stay distinct: a known `0%` still draws the empty track so
67
+ * the column does not jump, while an unknown value draws nothing and leaves the
68
+ * caller to print a muted `?` instead.
69
+ */
70
+ export function gaugeBar(theme: Theme, options: GaugeBarOptions): string {
71
+ if (options.percent === null) return "";
72
+ const { filled, empty } = computeGauge(options.percent, options.blocks);
73
+ const role = options.role ?? gaugeRole(options.percent, options.thresholds);
74
+ const emptyRole = options.emptyRole ?? role;
75
+ const track = filled > 0 ? paint(theme, role, GLYPHS.gaugeFull.repeat(filled)) : "";
76
+ const rest = empty > 0 ? paint(theme, emptyRole, GLYPHS.gaugeEmpty.repeat(empty)) : "";
77
+ return track + rest;
78
+ }
@@ -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
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The Bytefly terminal design system: one kit for every interactive surface.
3
+ *
4
+ * Import from `lib/tui/index.ts` rather than a leaf module so the surface reads
5
+ * as one system. The leaves: `tokens.ts` (semantic role -> theme token),
6
+ * `glyphs.ts` (the shared glyph, separator, and status-key vocabulary),
7
+ * `chrome.ts` (screen frame, hints, viewport, overlay), `cursor.ts` (list
8
+ * scroll and navigation), `columns.ts` (width math), `rules.ts` (the rule
9
+ * grammar), `rows.ts` (list rows, checkboxes, chips, empty states), `gauge.ts`
10
+ * (the context gauge), `indent.ts` (the transcript block indent), and
11
+ * `line-pane.ts` (the scrollable log/match pane).
12
+ *
13
+ * Value-only, like every `lib/` module: pure functions, constants, and classes,
14
+ * with no module-level state.
15
+ */
16
+
17
+ export * from "./chrome.ts";
18
+ export * from "./columns.ts";
19
+ export * from "./cursor.ts";
20
+ export * from "./gauge.ts";
21
+ export * from "./glyphs.ts";
22
+ export * from "./line-pane.ts";
23
+ export * from "./indent.ts";
24
+ export * from "./rows.ts";
25
+ export * from "./rules.ts";
26
+ export * from "./status-controller.ts";
27
+ export * from "./tokens.ts";