@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
@@ -69,8 +69,8 @@ export function registerSearchTool(pi: ExtensionAPI, deps: SearchDeps = {}): voi
69
69
  renderCall(args, theme, context) {
70
70
  const { query, maxResults } = args as WebSearchArgs;
71
71
  const text = reuseText(context?.lastComponent);
72
- let line = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "accent", oneLine(query ?? ""));
73
- if (maxResults) line += paint(theme, "faint", ` (${maxResults} results)`);
72
+ let line = theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) + paint(theme, "label", oneLine(query ?? ""));
73
+ if (maxResults) line += paint(theme, "faint", ` (max ${maxResults})`);
74
74
  text.setText(line);
75
75
  return text;
76
76
  },
@@ -96,7 +96,7 @@ export function registerSearchTool(pi: ExtensionAPI, deps: SearchDeps = {}): voi
96
96
  paint(theme, "faint", `via ${details.provider}${SEPARATORS.item}${count} result${count === 1 ? "" : "s"}`),
97
97
  ];
98
98
  if (details.answer)
99
- lines.push(`${paint(theme, "faint", "answer")} ${paint(theme, "meta", oneLine(details.answer, 160))}`);
99
+ lines.push(`${paint(theme, "faint", "Answer:")} ${paint(theme, "meta", oneLine(details.answer, 160))}`);
100
100
 
101
101
  const shown = options.expanded ? details.results : details.results.slice(0, 5);
102
102
  const indent = paint(theme, "faint", " ");
@@ -11,6 +11,12 @@ pi -e . # load the whole @gavin-hu/my-pi-agent pack
11
11
  pi install ./ # install the package
12
12
  ```
13
13
 
14
+ ## Tool rendering
15
+
16
+ How the `send_wechat` call and its result look in the transcript — the call line as a headline and the body indented under it:
17
+
18
+ <img src="../../assets/tools/send_wechat.png" alt="The send_wechat tool's call and result: the call line and an indented body with the message text and the sent confirmation" width="100%">
19
+
14
20
  ## What it does
15
21
 
16
22
  - **Inbound** — a long-poll loop receives WeChat text, images, and files and
@@ -14,6 +14,12 @@ pi install ./ # install the package
14
14
  pi -e . --worktree feature-auth # load the package and enter a worktree at startup
15
15
  ```
16
16
 
17
+ ## Tool rendering
18
+
19
+ How the `list_worktrees` call and its result look in the transcript — the call line as a headline and the body indented under it:
20
+
21
+ <img src="../../assets/tools/list_worktrees.png" alt="The list_worktrees tool's call and result, drawn by the generic fallback" width="100%">
22
+
17
23
  ## What it does
18
24
 
19
25
  - `enter_worktree` creates `<repo>/.pi/worktrees/<name>` on branch
package/lib/README.md CHANGED
@@ -20,11 +20,12 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
20
20
  | [`env.ts`](./env.ts) | Env-var contracts shared across extensions: the worktree environment (`ENV_ROOT`, `worktreeRoot`, `resolveEffectiveCwd`) used by worktree, file-browse, job, and rewind, plus `isExtensionEnabled` / `ENV_DISABLED_EXTENSIONS` for the package-wide disable list. |
21
21
  | [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters. |
22
22
  | [`events.ts`](./events.ts) | Inter-extension `pi.events` channels and payloads: `JOB_FINISHED_CHANNEL` and `JobFinishedEvent`, published by `job` and consumed by bytefly's notify capability. |
23
+ | [`notice.ts`](./notice.ts) | The shared transcript notice: `NOTICE_TYPE`, the `NoticeDetails` / `NoticeItem` shape, `noticeMessage` (the `display: true` message both send paths accept), `isNotice`, and `pruneStaleNotices` — posted by `job` and `watch`, rendered by bytefly. |
23
24
  | [`format.ts`](./format.ts) | Pure text/number formatting shared by renderers: `formatTokens`, `stripControlChars`, `sanitize`, `sanitizeLine` — the boundary for untrusted, single-line text — and `shortenPath`. |
24
25
  | [`interaction.ts`](./interaction.ts) | Turn-affinity prompt contract: the channel a remote adapter installs on `ctx.ui` under a string key, the `InteractionChannel` interface (`request` / `post` / `postFile`), and `remoteChannel` / `isRemoteTurn` / `supportsCustomUI` / `askHuman` (with an optional file/text preface) plus the shared “Other” label. |
25
26
  | [`git/`](./git/) | Shared git plumbing. [`git/runner.ts`](./git/runner.ts) is the `RunGit` seam (`createExecRunner` with timeout/env handling, `runGitOrThrow`, `GitError`); [`git/read.ts`](./git/read.ts) holds the read helpers (`repoRoot`, `repoRootFor`, `gitDir`, `revParse`, `hasCommits`, `currentBranch`); [`git/index.ts`](./git/index.ts) is the barrel. |
26
27
  | [`highlight.ts`](./highlight.ts) | First-party, escape-first syntax tokenizer (`detectLanguage`, `fenceLanguage`, `highlightLines`, `HighlightLanguage`, `TokenClass`) for js/ts, json, css, html, shell, python, java, yaml, and sql; shared by `file-browse` and plan's code blocks. |
27
- | [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface: [`tui/tokens.ts`](./tui/tokens.ts) (semantic role -> theme color, `paint`), [`tui/glyphs.ts`](./tui/glyphs.ts) (`GLYPHS`, `SEPARATORS`, `STATUS_KEYS`, `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, `BODY_INDENT` / `GLYPH_GAP` — the vocabulary both sides of a status must agree on), [`tui/chrome.ts`](./tui/chrome.ts) (`screenHint`, `dockListFrame` (the docked-list frame), `viewportRows`, `FULL_SCREEN_OVERLAY`, `reuseText`, `editorTheme`), [`tui/rules.ts`](./tui/rules.ts) (`plainRule` / `titleRule` / `centeredRule`), [`tui/rows.ts`](./tui/rows.ts) (`listRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`), [`tui/columns.ts`](./tui/columns.ts) (`splitRow`, `fitCells`, `flowCells`, `padTo`, `clip`), [`tui/gauge.ts`](./tui/gauge.ts) (`computeGauge`, `gaugeRole`, `gaugeBar`), [`tui/line-pane.ts`](./tui/line-pane.ts) (`LinePane`, the scrollable log/match pane), [`tui/status-controller.ts`](./tui/status-controller.ts) (`createStatusController`, the footer chips `job` and `watch` share), and [`tui/cursor.ts`](./tui/cursor.ts) (scroll and navigation: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` (by index) and `IdCursor` (by stable id) state). |
28
+ | [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface: [`tui/tokens.ts`](./tui/tokens.ts) (semantic role -> theme color, `paint`), [`tui/glyphs.ts`](./tui/glyphs.ts) (`GLYPHS`, `SEPARATORS`, `STATUS_KEYS`, `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, `GLYPH_GAP` — the vocabulary both sides of a status must agree on), [`tui/chrome.ts`](./tui/chrome.ts) (`screenHint`, `dockListFrame` (the docked-list frame), `viewportRows`, `FULL_SCREEN_OVERLAY`, `reuseText`, `editorTheme`), [`tui/rules.ts`](./tui/rules.ts) (the rule glyph `RULE_GLYPH`, and `plainRule` / `titleRule` / `centeredRule`), [`tui/indent.ts`](./tui/indent.ts) (the transcript block indent: `BODY_INDENT`, an optional rail prefix that a wrapped call line is kept out of, and `bodyLines` / `indentBlockLines` / `indentedLines`), [`tui/rows.ts`](./tui/rows.ts) (`listRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`), [`tui/columns.ts`](./tui/columns.ts) (`splitRow`, `fitCells`, `flowCells`, `padTo`, `clip`), [`tui/gauge.ts`](./tui/gauge.ts) (`computeGauge`, `gaugeRole`, `gaugeBar`), [`tui/line-pane.ts`](./tui/line-pane.ts) (`LinePane`, the scrollable log/match pane), [`tui/status-controller.ts`](./tui/status-controller.ts) (`createStatusController`, the footer chips `job` and `watch` share), and [`tui/cursor.ts`](./tui/cursor.ts) (scroll and navigation: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` (by index) and `IdCursor` (by stable id) state). |
28
29
  | [`path.ts`](./path.ts) | `isInside` (string containment) plus `realPathOfNearest` / `isInsideReal` (symlink-aware): the containment predicates the file-browse, worktree, and doc guards share. |
29
30
  | [`pi-invocation.ts`](./pi-invocation.ts) | `getPiInvocation`: how to launch a nested `pi` process, shared by `subagent` and bytefly's doctor surface. |
30
31
  | [`policy.ts`](./policy.ts) | `createReadOnlyPolicy`: default-deny tool classification from `readOnlyHint` + explicit allow/deny. |
package/lib/format.ts CHANGED
@@ -17,6 +17,23 @@ export function formatTokens(count: number): string {
17
17
  return `${Math.round(count / 1000000)}M`;
18
18
  }
19
19
 
20
+ /**
21
+ * An integer grouped with thousands separators (`999`, `8,000`, `42,478`).
22
+ * Hand-rolled rather than `toLocaleString`, so a count is identical under any
23
+ * locale and ICU build.
24
+ */
25
+ export function formatCount(count: number): string {
26
+ if (!Number.isFinite(count)) return "0";
27
+ const sign = count < 0 ? "-" : "";
28
+ const digits = Math.abs(Math.trunc(count)).toString();
29
+ let out = "";
30
+ for (let i = 0; i < digits.length; i++) {
31
+ if (i > 0 && (digits.length - i) % 3 === 0) out += ",";
32
+ out += digits[i];
33
+ }
34
+ return sign + out;
35
+ }
36
+
20
37
  /**
21
38
  * Replace terminal control characters (including ESC and the C1 block) with
22
39
  * spaces, keeping `\n` and `\t` for callers that render multi-line text.
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}