@gavin-hu/my-pi-agent 0.15.1 → 0.16.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 (87) hide show
  1. package/README.md +10 -7
  2. package/README.zh-CN.md +8 -5
  3. package/extensions/bytefly/README.md +98 -116
  4. package/extensions/bytefly/notice/component.ts +6 -4
  5. package/extensions/bytefly/notice/config.ts +16 -2
  6. package/extensions/bytefly/panel/format.ts +3 -8
  7. package/extensions/bytefly/panel/join.ts +1 -6
  8. package/extensions/bytefly/panel/settings.ts +74 -377
  9. package/extensions/bytefly/panel/tui/entries.ts +16 -14
  10. package/extensions/bytefly/panel/tui/extensions-view.ts +3 -0
  11. package/extensions/bytefly/panel/tui/frame.ts +2 -2
  12. package/extensions/bytefly/panel/tui/settings-view.ts +8 -12
  13. package/extensions/bytefly/panel/tui.ts +4 -6
  14. package/extensions/bytefly/picker/resources.ts +7 -3
  15. package/extensions/bytefly/working/register.ts +2 -8
  16. package/extensions/check/tools.ts +2 -11
  17. package/extensions/context/README.md +162 -0
  18. package/extensions/context/collect.ts +53 -0
  19. package/extensions/context/commands.ts +100 -0
  20. package/extensions/context/format.ts +82 -0
  21. package/extensions/context/index.ts +16 -0
  22. package/extensions/context/report.ts +319 -0
  23. package/extensions/context/tui.ts +268 -0
  24. package/extensions/context/types.ts +122 -0
  25. package/extensions/file-browse/README.md +38 -18
  26. package/extensions/file-browse/config.ts +8 -0
  27. package/extensions/file-browse/html.ts +2 -2
  28. package/extensions/file-browse/index.ts +17 -2
  29. package/extensions/file-browse/live-client.ts +13 -13
  30. package/extensions/file-browse/live.ts +44 -10
  31. package/extensions/file-browse/markdown.ts +3 -10
  32. package/extensions/file-browse/raw-html.ts +3 -10
  33. package/extensions/file-browse/server.ts +13 -2
  34. package/extensions/file-open/README.md +1 -2
  35. package/extensions/{document → file-read}/README.md +72 -56
  36. package/extensions/{document → file-read}/config.ts +12 -9
  37. package/extensions/{document → file-read}/extract/doc.ts +2 -2
  38. package/extensions/{document → file-read}/extract/docx.ts +3 -3
  39. package/extensions/{document → file-read}/extract/file.ts +9 -9
  40. package/extensions/{document → file-read}/extract/odt.ts +3 -3
  41. package/extensions/{document → file-read}/extract/pdf.ts +3 -3
  42. package/extensions/{document → file-read}/extract/rtf.ts +3 -3
  43. package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
  44. package/extensions/{document → file-read}/formats.ts +11 -11
  45. package/extensions/file-read/index.ts +17 -0
  46. package/extensions/{document → file-read}/paging.ts +15 -13
  47. package/extensions/{document → file-read}/render.ts +17 -13
  48. package/extensions/{document → file-read}/schema.ts +7 -7
  49. package/extensions/{document → file-read}/tool.ts +36 -28
  50. package/extensions/goal/tools.ts +10 -14
  51. package/extensions/job/commands.ts +32 -46
  52. package/extensions/job/runtime.ts +6 -8
  53. package/extensions/job/tools.ts +7 -26
  54. package/extensions/memory/commands.ts +30 -44
  55. package/extensions/memory/tools.ts +6 -14
  56. package/extensions/plan/tools/enter.ts +6 -7
  57. package/extensions/plan/tools/exit.ts +6 -7
  58. package/extensions/plan/tools/steps.ts +6 -7
  59. package/extensions/plan/tools/write.ts +8 -9
  60. package/extensions/plan/web.ts +21 -12
  61. package/extensions/question/tools.ts +5 -4
  62. package/extensions/subagent/background.ts +1 -7
  63. package/extensions/todo/tools.ts +10 -14
  64. package/extensions/watch/README.md +5 -5
  65. package/extensions/watch/commands.ts +33 -47
  66. package/extensions/watch/runtime.ts +5 -7
  67. package/extensions/watch/tools.ts +7 -18
  68. package/extensions/web-access/README.md +3 -3
  69. package/extensions/web-access/search/search.ts +9 -18
  70. package/extensions/wechat/timer.ts +9 -30
  71. package/extensions/wechat/tools.ts +4 -7
  72. package/lib/README.md +9 -6
  73. package/lib/escape.ts +15 -2
  74. package/lib/pdf.ts +2 -2
  75. package/lib/requirements.ts +2 -2
  76. package/lib/screen-session.ts +85 -0
  77. package/lib/timer.ts +61 -0
  78. package/lib/tui/README.md +3 -3
  79. package/lib/tui/columns.ts +33 -8
  80. package/lib/tui/rows.ts +15 -6
  81. package/lib/tui/rules.ts +4 -8
  82. package/lib/tui/tool.ts +55 -2
  83. package/package.json +8 -4
  84. package/skills/changelog/SKILL.md +136 -0
  85. package/extensions/document/index.ts +0 -17
  86. /package/extensions/{document → file-read}/deadline.ts +0 -0
  87. /package/extensions/{document → file-read}/extract/vendor.d.ts +0 -0
@@ -3,20 +3,18 @@
3
3
  *
4
4
  * The bridge and the channel need a cancellable one-shot timer that a test can
5
5
  * drive without waiting (the delayed acknowledgement, the typing keep-alive, the
6
- * prompt timeout); login needs an abortable sleep between polls. The defaults use
7
- * a real `setTimeout`, `unref`'d for the one-shot so a pending timer never holds
8
- * the process open.
6
+ * prompt timeout), and login needs an abortable sleep between polls; both seams
7
+ * and their production defaults are in [`lib/timer.ts`](../../lib/timer.ts),
8
+ * because job, watch, file-browse, plan, and web-access arm the same two. This
9
+ * module adds the per-key map those timers are held in.
9
10
  */
10
11
 
11
- /** Arm a one-shot timer; the returned function cancels it. */
12
- export type ArmTimer = (ms: number, fn: () => void) => () => void;
12
+ import { abortableSleep, type ArmTimer } from "../../lib/timer.ts";
13
13
 
14
- /** The production timer: `setTimeout`, detached from the event loop. */
15
- export const defaultTimer: ArmTimer = (ms, fn) => {
16
- const timer = setTimeout(fn, ms);
17
- (timer as { unref?: () => void }).unref?.();
18
- return () => clearTimeout(timer);
19
- };
14
+ export { type ArmTimer, defaultTimer } from "../../lib/timer.ts";
15
+
16
+ /** The shared abortable sleep, under the name the bridge and login already use. */
17
+ export const defaultSleep = abortableSleep;
20
18
 
21
19
  /**
22
20
  * A set of one-shot timers keyed by a caller's id (a peer, a watcher).
@@ -74,22 +72,3 @@ export function createTimerMap(arm: ArmTimer): TimerMap {
74
72
  };
75
73
  return map;
76
74
  }
77
-
78
- /** An abortable sleep: resolves after `ms`, rejects when `signal` aborts. */
79
- export function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
80
- return new Promise((resolve, reject) => {
81
- if (signal?.aborted) {
82
- reject(new Error("aborted"));
83
- return;
84
- }
85
- const timer = setTimeout(() => {
86
- signal?.removeEventListener("abort", onAbort);
87
- resolve();
88
- }, ms);
89
- const onAbort = () => {
90
- clearTimeout(timer);
91
- reject(new Error("aborted"));
92
- };
93
- signal?.addEventListener("abort", onAbort, { once: true });
94
- });
95
- }
@@ -10,9 +10,8 @@
10
10
  import type { JsonValue } from "@earendil-works/pi-ai";
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { Type, type Static } from "typebox";
13
- import { sanitizeLine } from "../../lib/format.ts";
14
13
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
15
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
14
+ import { callLine, errorLine, rejectionLine } from "../../lib/tui/tool.ts";
16
15
  import { paint } from "../../lib/tui/tokens.ts";
17
16
  import type { Bridge } from "./bridge.ts";
18
17
 
@@ -82,11 +81,9 @@ export function registerTools(pi: ExtensionAPI, deps: ToolDeps): void {
82
81
  },
83
82
 
84
83
  renderCall(args, theme, context) {
85
- const text = reuseCallText(context.lastComponent);
86
84
  const body = (args as { text?: unknown }).text;
87
- const label = context.argsComplete && typeof body === "string" ? sanitizeLine(body) : "…";
88
- text.setText(toolCallTitle(theme, `${TOOL_NAME} `) + paint(theme, "meta", label));
89
- return text;
85
+ const label = context.argsComplete && typeof body === "string" ? body : "…";
86
+ return callLine(theme, context.lastComponent, TOOL_NAME, label);
90
87
  },
91
88
 
92
89
  renderResult(result, options, theme, context) {
@@ -97,7 +94,7 @@ export function registerTools(pi: ExtensionAPI, deps: ToolDeps): void {
97
94
  return text;
98
95
  }
99
96
  if (details?.error) {
100
- text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
97
+ text.setText(errorLine(theme, details.error));
101
98
  return text;
102
99
  }
103
100
  // Pi's rejection result carries `{}`; show its sanitized message.
package/lib/README.md CHANGED
@@ -20,26 +20,28 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
20
20
  | [`browser.ts`](./browser.ts) | Cross-platform default-browser opener shared by `file-browse` and `plan`: pure `browserCommand(url, platform)` plus best-effort `openInBrowser(url, { platform, spawn })` with an injectable spawn. |
21
21
  | [`config.ts`](./config.ts) | Config loading, two ways: `loadConfigFile` for a whole file, and the shared `bytefly.json` (`SHARED_CONFIG_FILE`) via `configSection` / `configValueAt` / `loadConfigSection` (global, then project), plus the atomic `writeConfigFile` / `writeConfigSection` / `writeConfigValue` / `writeConfigValueAt` / `writeConfigValuesAt` / `unsetConfigValueAt` (the leaf writers preserve siblings and metadata, `writeConfigValuesAt` applies several leaves in one document pass, and the last removes one and prunes what it emptied). Includes `clampInteger` / `clampNumber` / `cleanString` / `optionalString` / `asBoolean` / `stringList` / `asRecord` / `readJson` / `readJsonResult`. |
22
22
  | [`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. |
23
- | [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters. |
23
+ | [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters, plus `decodeCodePoint`, the other half of the same boundary — one numeric character reference as text, with a surrogate or out-of-range value decoded to U+FFFD. |
24
24
  | [`events.ts`](./events.ts) | Inter-extension `pi.events` channels and payloads: `JOB_FINISHED_CHANNEL` / `JobFinishedEvent` (published by `job`, consumed by bytefly's notify capability), `SUBAGENT_FINISHED_CHANNEL` / `SubagentFinishedEvent` (published by `subagent`, consumed by bytefly's footer roll-up), and `WEB_SEARCH_PROVIDER_CHANNEL` / `WebSearchProviderEvent` (published by `web-access` at session start, consumed by bytefly's doctor so it can report the resolved provider without importing the extension — the key itself is never in the payload). |
25
25
  | [`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. |
26
26
  | [`report.ts`](./report.ts) | The pending-report coordinator: `createPendingReporter` with `wake` / `pending` / `markReported` / `buildNotice` / `source` / `onWoke`, returning the lifecycle handlers that report at the next turn, raise at most one triggered turn per wake window, and prune only their own notices. Shared by `job` and `watch`. |
27
27
  | [`errors.ts`](./errors.ts) | `messageOf`: the one boundary that turns an unknown thrown value into a user-facing line (an `Error` yields its message, anything else is stringified). |
28
28
  | [`format.ts`](./format.ts) | Pure text/number formatting shared by renderers: `formatTokens`, `formatCount`, `formatDuration` (`450ms`, `3.2s`, `1m05s`, `2h03m`), `boundedTail` (the end of a joined tail with a `truncated` flag), `stripControlChars`, `sanitize`, `sanitizeLine` — the boundary for untrusted, single-line text — `lineOf` (a value read as one sanitized line), `sanitizeUntrustedLine` / `sanitizeUntrustedText` with `MAX_OUTPUT_LINE` for **program output** (OSC/CSI escapes removed whole, `\r` progress resolved, clipped), and `shortenPath`. |
29
+ | [`timer.ts`](./timer.ts) | The three timer seams and their production defaults, each cancellable and each built so a pending timer never holds the process open: `ArmTimer` / `defaultTimer` (one shot), `RepeatTimer` / `defaultInterval` (repeating), and `abortableSleep` (resolve after a delay, reject `Error("aborted")` when a signal wins). Shared by `wechat`'s bridge and prompt channel, plan's review page, `job`'s and `watch`'s repaint clocks, `bytefly`'s working tick, the browse live channel, and the search throttle — so a test drives a deadline instead of waiting on one. |
29
30
  | [`lines.ts`](./lines.ts) | `LineSplitter`, the byte-safe splitter for a stream of program output: buffers bytes and decodes only at a newline, so a UTF-8 character split across two reads survives, and caps an over-long line instead of dropping it. Shared by `job`, `watch`, `check`, and `subagent`. |
30
31
  | [`pool.ts`](./pool.ts) | `mapConcurrent`: one async read per entry with at most `limit` calls in flight and results in input order, so a directory listing or a status sweep fans out without an unbounded burst or a per-entry round trip. Shared by `file-browse` and `worktree`. |
31
32
  | [`interaction.ts`](./interaction.ts) | Turn-affinity prompt contract: the channel a remote transport marks on the UI context it owns (`INTERACTION_TURN_KEY`), the `InteractionChannel` interface (`request` / `post` / `postFile`), and `remoteChannel` / `isRemoteTurn` / `supportsCustomUI` / `askHuman` (with an optional file, fallback text, and digest preface) plus the shared “Other” label. |
32
33
  | [`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`, `gitCommonDir`, `revParse`, `revParseMany` for several revisions in one call, `hasCommits`, `currentBranch`); [`git/index.ts`](./git/index.ts) is the barrel. |
33
34
  | [`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. |
34
35
  | [`http/`](./http/) | The loopback HTTP layer shared by `file-browse` and `plan`: [`http/headers.ts`](./http/headers.ts) (the page policy `PAGE_CSP` and the `pageHeaders` / `scriptHeaders` / `jsonHeaders` sets, plus `eventHeaders` for the one long-lived stream), [`http/response.ts`](./http/response.ts) (`ServeResponse` — a buffered body, a `filePath` stream, or an SSE `stream` — plus `htmlResponse` / `jsonResponse` / `jsonError` / `textResponse`), [`http/router.ts`](./http/router.ts) (`parseRequestUrl`, and `isLocalHostHeader` with the `required` mode each server chooses), [`http/server.ts`](./http/server.ts) (`bindLoopback` on `127.0.0.1` with the accept-error listener, `respond` for HEAD / streamed file / body, `openEventStream` for the head and reconnect hint of an SSE response, and the idempotent `createCloser`), and the [`http/index.ts`](./http/index.ts) barrel. |
35
- | [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface, whose conventions are its own doc, [`tui/README.md`](./tui/README.md). Modules: [`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) with `detailBlockRows` (the rows its detail block costs), `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`, `statusRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`, `byRunningFirst` for a live list's order), [`tui/columns.ts`](./tui/columns.ts) (`splitRow`, `fitCells`, `flowCells`, `padTo`, `clip`, `clipMiddle`, `labelLine` / `clipLabel` with `LABEL_WIDTH`), [`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`, `watch`, and `subagent` share, with the attached-context `notify` and a `detach` that clears its keys), [`tui/dock-screen.ts`](./tui/dock-screen.ts) (`DockListScreen`, the two-mode docked list + detail-pane screen with navigation, wheel, range, and confirm), [`tui/confirm.ts`](./tui/confirm.ts) (`ConfirmState`, the y/N layer behind every destructive screen action), [`tui/tool.ts`](./tui/tool.ts) (`toolCallTitle`, the width-safe `ToolCallText` / `reuseCallText`, sanitized `errorLine` / `rejectionLine`, `moreHint` / `overflowHint` / `earlierHint`, the `TailResult` transcript block, `syncElapsedTimer`, `elapsedSuffix` — the tool-transcript kit), [`tui/render-cache.ts`](./tui/render-cache.ts) (`RenderCache`: the per-instance line memo a component that derives its lines from another component's output renders through, so a repeat frame is one comparison), 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). |
36
+ | [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface, whose conventions are its own doc, [`tui/README.md`](./tui/README.md). Modules: [`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) with `detailBlockRows` (the rows its detail block costs), `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` with its `align` option for a value held beside its label, `statusRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`, `byRunningFirst` for a live list's order), [`tui/columns.ts`](./tui/columns.ts) (`splitRow` for two cells at the two edges, `inlineRow` for two cells held together, `fitCells`, `flowCells`, `padTo`, `clip`, `clipMiddle`, `labelLine` / `clipLabel` with `LABEL_WIDTH`), [`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`, `watch`, and `subagent` share, with the attached-context `notify` and a `detach` that clears its keys), [`tui/dock-screen.ts`](./tui/dock-screen.ts) (`DockListScreen`, the two-mode docked list + detail-pane screen with navigation, wheel, range, and confirm), [`tui/confirm.ts`](./tui/confirm.ts) (`ConfirmState`, the y/N layer behind every destructive screen action), [`tui/tool.ts`](./tui/tool.ts) (`toolCallTitle`, the width-safe `ToolCallText` / `reuseCallText`, `callLine` — the one headline every renderer draws — `detailsOrLine` (a result's details, or the one `errorLine` / `rejectionLine` a failure shows), sanitized `errorLine` / `rejectionLine`, `moreHint` / `overflowHint` / `earlierHint`, the `TailResult` transcript block, `syncElapsedTimer`, `elapsedSuffix` — the tool-transcript kit), [`tui/render-cache.ts`](./tui/render-cache.ts) (`RenderCache`: the per-instance line memo a component that derives its lines from another component's output renders through, so a repeat frame is one comparison), 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). |
36
37
  | [`path.ts`](./path.ts) | `isInside` (string containment) plus `realPathOfNearest` / `isInsideReal` (symlink-aware): the containment predicates the file-browse, worktree, doc, and plan guards share, and the canonical-path helper the worktree registry keys records by. |
37
- | [`pdf.ts`](./pdf.ts) | The optional `unpdf` seam shared by `web-access` and `document`: `pdfGuidance`, `PdfUnavailableError`, `pdfTextFromResult`, and the lazy `loadPdfExtractor` / `extractPdfText` (an injected extractor wins), so a missing package and a merged-pages result read the same way through either surface. |
38
+ | [`pdf.ts`](./pdf.ts) | The optional `unpdf` seam shared by `web-access` and `file-read`: `pdfGuidance`, `PdfUnavailableError`, `pdfTextFromResult`, and the lazy `loadPdfExtractor` / `extractPdfText` (an injected extractor wins), so a missing package and a merged-pages result read the same way through either surface. |
38
39
  | [`pi-invocation.ts`](./pi-invocation.ts) | `getPiInvocation`: how to launch a nested `pi` process, shared by `subagent` and bytefly's doctor surface. |
39
40
  | [`policy.ts`](./policy.ts) | `createReadOnlyPolicy`: default-deny tool classification from `readOnlyHint` + explicit allow/deny. |
40
41
  | [`plan-state.ts`](./plan-state.ts) | The plan-mode flag replayed from the session branch: `PLAN_MODE_ENTRY_TYPE` and `isPlanModeEnabled(entries)`, so `rewind` can refuse a write while planning without importing the plan extension. |
41
42
  | [`plan-entry.ts`](./plan-entry.ts) | Plan-mode entry shared by the extension that owns the mode and the one that offers it: `READ_ONLY_SUMMARY` and `PLAN_CONSENT` (one wording for the read-only guarantee), the `PLAN_ENTER_REQUEST` channel with its `PlanEnterRequest` payload, and `requestPlanMode(pi, ctx)` — a request/response over `pi.events` that resolves `false` instead of hanging when `plan` is not loaded, so a command context (which has no `executeTool`) can still enter the mode after asking the user once. |
42
43
  | [`rails.ts`](./rails.ts) | Above-editor widget ordering (`goal` → `todo`) and dock-screen suppression, carried on `pi.events`. |
44
+ | [`screen-session.ts`](./screen-session.ts) | `withPolledScreen`: mount one `ctx.ui.custom` dock screen — rails suppressed for its lifetime, an optional repaint tick through an injected `RepeatTimer`, and one teardown — handing the mount the host's `tui`, `theme`, `requestRender`, `close`, and `viewportRows`. Shared by `/jobs`, `/watch`, and `/memory`, whose rails window and timer window were the same one. |
43
45
  | [`requirements.ts`](./requirements.ts) | Per-extension runtime prerequisite table plus a pure `probeExtension(seams)` over injected seams (exec, config reads, optional imports, the published search provider, env, and a refresh-only reachability probe): bytefly's doctor surface reports on it and the extensions that own a dependency format their errors from it. A check kind is either a prerequisite (`binary`, `module`, `state`, `invocation`, `notify`, `provider`) or a `safety` switch whose off value trades a protection away, read from `bytefly.json` global-then-project. |
44
46
  | [`shell.ts`](./shell.ts) | Shell escape rule shared by the worktree isolation guard and plan mode's read-only git guard: `SHELL_ESCAPABLE`, `isShellEscapable`, `hasShellEscape`. |
45
47
  | [`spawn.ts`](./spawn.ts) | The process seam for extensions that run a shell command: `SpawnedProcess`, `SpawnFn`, `KillTreeFn`, the pure `spawnOptions` / `killPlan`, `applyKillPlan` over injected seams, `isPidAlive`, and the `defaultSpawn` / `defaultKillTree` that execute them (process group on POSIX, `taskkill /T` on Windows). Shared by `job`, `watch`, `check`, `worktree`, and `wechat`. |
@@ -62,9 +64,10 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
62
64
  one extension probes on another — live in `tool-names.ts`.
63
65
  - **A helper that exists here must be imported, not redefined.**
64
66
  `test/shared-helpers.test.ts` fails a top-level extension declaration whose
65
- name is also exported by `lib/`, and a body copied from `lib/` or from another
66
- extension. A deliberate wrapper goes on that test's reasoned exception list; a
67
- copy belongs here with one co-located test.
67
+ name is also exported by `lib/`, and a body copied from `lib/` or from any
68
+ other file — a second extension, a sibling module, or `lib/` itself. A
69
+ deliberate wrapper goes on that test's reasoned exception list; a copy belongs
70
+ here with its declared capability suite.
68
71
  - **A color is a role, not a token.** The rules and the primitives are in
69
72
  [`tui/README.md`](./tui/README.md#color-is-a-role): draw through
70
73
  [`tui/tokens.ts`](./tui/tokens.ts) (`paint(theme, role, text)`) so a theme swap
package/lib/escape.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  /**
2
- * HTML text escaping for the browse pages.
2
+ * HTML text escaping, and the inverse for one character reference.
3
3
  *
4
4
  * The single escape used by every page renderer and by the Markdown renderer:
5
5
  * model- and file-authored text becomes safe text nodes and double-quoted
6
6
  * attributes. Keeping it here (rather than in `html.ts`) lets `markdown.ts`
7
7
  * escape without importing the page renderer, so the module graph stays
8
- * acyclic.
8
+ * acyclic. `decodeCodePoint` is the other half of the same boundary and lives
9
+ * beside it for the same reason: the Markdown inline scan and the raw-HTML
10
+ * attribute decoder both resolve `&#N;` and `&#xN;` references, and one copy
11
+ * cannot drift from the other.
9
12
  */
10
13
 
11
14
  /** Escape text for an HTML text node or double-quoted attribute. */
@@ -25,3 +28,13 @@ export function escapeHtml(value: string): string {
25
28
  }
26
29
  });
27
30
  }
31
+
32
+ /**
33
+ * One numeric character reference as text: U+FFFD when it is a surrogate, zero,
34
+ * or out of range, so an entity cannot inject an unpaired half of a pair.
35
+ */
36
+ export function decodeCodePoint(value: number): string {
37
+ if (!Number.isFinite(value) || value <= 0 || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff))
38
+ return "\uFFFD";
39
+ return String.fromCodePoint(value);
40
+ }
package/lib/pdf.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * The optional `unpdf` seam: lazy load, module-shape handling, and text
3
- * extraction, shared by `web-access` and `document`.
3
+ * extraction, shared by `web-access` and `file-read`.
4
4
  *
5
5
  * `unpdf` bundles a server-friendly build of PDF.js, so it is imported lazily:
6
6
  * an extension registers on every session, and a parser should load only when a
7
7
  * read actually needs it. `web-access` treats the package as optional and
8
- * reports {@link pdfGuidance} when it is absent; `document` ships it as a
8
+ * reports {@link pdfGuidance} when it is absent; `file-read` ships it as a
9
9
  * dependency. Both get the same loader, the same merged-pages call, and the same
10
10
  * result unwrapping, so a PDF reads the same way through either surface.
11
11
  *
@@ -255,7 +255,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
255
255
  optional: [gitCheck("the branch chip and the diff view")],
256
256
  notes: ["The default-browser opener (`open` / `xdg-open` / `cmd`) is best-effort; the URL is printed either way."],
257
257
  },
258
- document: {},
258
+ "file-read": {},
259
259
  "file-open": {
260
260
  notes: [
261
261
  "The `/open` command and its notification need an interactive UI; the `open_path` tool works without one.",
@@ -266,6 +266,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
266
266
  memory: {
267
267
  optional: [gitCheck("project-scope memory under the repository root")],
268
268
  },
269
+ context: {},
269
270
  wechat: {
270
271
  optional: [
271
272
  {
@@ -301,7 +302,6 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
301
302
  ],
302
303
  },
303
304
  };
304
-
305
305
  // --- Probing ----------------------------------------------------------------
306
306
 
307
307
  /** Run a command and report its exit code; production backs this with `pi.exec`. */
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Mounting a dock screen: rails suppressed, an optional repaint tick, and one
3
+ * teardown.
4
+ *
5
+ * Pi mounts a `ctx.ui.custom` screen in the editor slot the above-editor rails
6
+ * share, so every screen wraps itself in `withRailsSuppressed`. Three of them —
7
+ * `/jobs`, `/watch`, and `/memory` — also poll while they are open (a log pane,
8
+ * a line pane, a store another process can edit), and each re-derived the same
9
+ * `let timer` / `stopTimer` / `setInterval(...).unref?.()` / `finally` skeleton
10
+ * around its own component. The rails lifetime and the timer lifetime are the
11
+ * same window, so they are one helper rather than two wrappers each caller
12
+ * stacks and unwinds by hand.
13
+ *
14
+ * The timer is a seam (`RepeatTimer`), so a test drives the tick instead of
15
+ * waiting for one, and it is armed after the mount returns: a component whose
16
+ * constructor throws leaves no timer behind.
17
+ *
18
+ * Value-only: the timer handle lives in the caller's closure.
19
+ */
20
+
21
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
22
+ import type { Component, TUI } from "@earendil-works/pi-tui";
23
+ import { withRailsSuppressed } from "./rails.ts";
24
+ import { defaultInterval, type RepeatTimer } from "./timer.ts";
25
+
26
+ /** What the host accepts back from a screen's mount. */
27
+ export type ScreenComponent = Component & { dispose?(): void };
28
+
29
+ /** What a mounted screen is given: the host's mount parameters, narrowed. */
30
+ export interface PolledScreen {
31
+ /** The host's component handle; a screen that needs an `Editor` builds it from this. */
32
+ readonly tui: TUI;
33
+ /** The theme the host handed the mount. */
34
+ readonly theme: Theme;
35
+ /** Ask the host to repaint. */
36
+ requestRender(): void;
37
+ /** Close the screen, as its own close key would. */
38
+ close(): void;
39
+ /** The terminal's height in rows, or `undefined` when the host cannot say. */
40
+ viewportRows(): number | undefined;
41
+ }
42
+
43
+ export interface PolledScreenOptions<Screen> {
44
+ /** How often `poll` runs while the screen is open, in ms. */
45
+ pollMs: number;
46
+ /** Run once per tick; `screen` is what `mount` returned. */
47
+ poll(screen: Screen, handles: PolledScreen): void;
48
+ /** Timer seam; defaults to an unref'd interval. */
49
+ timer?: RepeatTimer;
50
+ }
51
+
52
+ /**
53
+ * Show one polled dock screen and resolve when it closes.
54
+ *
55
+ * The caller still owns the `ctx.mode !== "tui"` branch, because the text
56
+ * fallback differs per command; everything from the mount to the teardown is
57
+ * here.
58
+ */
59
+ export async function withPolledScreen<Screen extends ScreenComponent>(
60
+ pi: Pick<ExtensionAPI, "events">,
61
+ ctx: Pick<ExtensionContext, "ui">,
62
+ mount: (handles: PolledScreen) => Screen,
63
+ options: PolledScreenOptions<Screen>,
64
+ ): Promise<void> {
65
+ const { pollMs, poll, timer = defaultInterval } = options;
66
+ let stop: (() => void) | undefined;
67
+ try {
68
+ await withRailsSuppressed(pi, () =>
69
+ ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
70
+ const handles: PolledScreen = {
71
+ tui,
72
+ theme,
73
+ requestRender: () => tui.requestRender(),
74
+ close: () => done(),
75
+ viewportRows: () => tui.terminal?.rows,
76
+ };
77
+ const screen = mount(handles);
78
+ stop = timer(pollMs, () => poll(screen, handles));
79
+ return screen;
80
+ }),
81
+ );
82
+ } finally {
83
+ stop?.();
84
+ }
85
+ }
package/lib/timer.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The timer seams: one shot, repeating, and a sleep that can be aborted.
3
+ *
4
+ * Extensions need the same three things, and each has the same two properties a
5
+ * test cares about: it is cancellable, and its production default never holds
6
+ * the process open. The WeChat bridge arms a one-shot per acknowledgement,
7
+ * typing keep-alive, and prompt timeout; plan's review page arms one for the
8
+ * closed-tab grace; `job` and `watch` repaint on a repeating one; the browse
9
+ * live channel needs both; `job`, `watch`, `check`, `subagent`, and `wechat`
10
+ * sleep between polls. Keeping the defaults here means one definition of "arm
11
+ * it, and let it be cancelled", and one place where the `unref` lives.
12
+ *
13
+ * Value-only: types and constant functions, no module state.
14
+ */
15
+
16
+ /** Arm a one-shot timer; the returned function cancels it. */
17
+ export type ArmTimer = (ms: number, fn: () => void) => () => void;
18
+
19
+ /** The production timer: `setTimeout`, detached from the event loop. */
20
+ export const defaultTimer: ArmTimer = (ms, fn) => {
21
+ const timer = setTimeout(fn, ms);
22
+ (timer as { unref?: () => void }).unref?.();
23
+ return () => clearTimeout(timer);
24
+ };
25
+
26
+ /** Arm a repeating timer; the returned function stops it. */
27
+ export type RepeatTimer = (ms: number, tick: () => void) => () => void;
28
+
29
+ /** The production ticker: `setInterval`, detached from the event loop. */
30
+ export const defaultInterval: RepeatTimer = (ms, tick) => {
31
+ const timer = setInterval(tick, ms);
32
+ (timer as { unref?: () => void }).unref?.();
33
+ return () => clearInterval(timer);
34
+ };
35
+
36
+ /**
37
+ * Resolve after `ms`, or reject when `signal` aborts first.
38
+ *
39
+ * The one rejection is `Error("aborted")` whatever the signal carries, so a
40
+ * caller that names its own abort error maps one case rather than inspecting a
41
+ * reason. The timer is cleared on both paths and never `unref`'d: a sleep exists
42
+ * to keep the caller waiting, so nothing else is pending to keep the process
43
+ * alive, and an unref'd one could be dropped mid-wait.
44
+ */
45
+ export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
46
+ return new Promise((resolve, reject) => {
47
+ if (signal?.aborted) {
48
+ reject(new Error("aborted"));
49
+ return;
50
+ }
51
+ const timer = setTimeout(() => {
52
+ signal?.removeEventListener("abort", onAbort);
53
+ resolve();
54
+ }, ms);
55
+ const onAbort = (): void => {
56
+ clearTimeout(timer);
57
+ reject(new Error("aborted"));
58
+ };
59
+ signal?.addEventListener("abort", onAbort, { once: true });
60
+ });
61
+ }
package/lib/tui/README.md CHANGED
@@ -151,6 +151,6 @@ and the first call throws at runtime.
151
151
 
152
152
  ## Tests
153
153
 
154
- A module here gets a sibling `<module>.test.ts` covering fields, width bounds,
155
- ordering, and glyphs — never literal column padding. An extension's screen test
156
- is the `tui.test.ts` beside its `tui.ts`.
154
+ A capability suite groups the modules it covers and asserts fields, width
155
+ bounds, ordering, and glyphs — never literal column padding. An extension's
156
+ screen coverage lives in its declared capability suite (for example `ui.test.ts`).
@@ -15,14 +15,17 @@ import { sanitizeLine, sanitizeUntrustedLine } from "../format.ts";
15
15
  /** The columns a label gets before it stops reading as a label. */
16
16
  export const LABEL_WIDTH = 48;
17
17
 
18
- /** A usable origin: terminal arithmetic starts from one column, never zero. */
19
- function columns(width: number): number {
18
+ /**
19
+ * A usable drawing width in columns: terminal arithmetic starts from one column,
20
+ * never zero, so a narrow or unknown width never produces an empty frame.
21
+ */
22
+ export function usableWidth(width: number): number {
20
23
  return Math.max(1, Math.floor(width));
21
24
  }
22
25
 
23
26
  /** Clip a possibly-themed string to `width` columns, marking a cut with `ellipsis`. */
24
27
  export function clip(text: string, width: number, ellipsis = "…"): string {
25
- return truncateToWidth(text, columns(width), ellipsis);
28
+ return truncateToWidth(text, usableWidth(width), ellipsis);
26
29
  }
27
30
 
28
31
  /**
@@ -34,7 +37,7 @@ export function clip(text: string, width: number, ellipsis = "…"): string {
34
37
  * A themed string keeps its colours on both the head and the tail.
35
38
  */
36
39
  export function clipMiddle(text: string, width: number, ellipsis = "…"): string {
37
- const w = columns(width);
40
+ const w = usableWidth(width);
38
41
  if (visibleWidth(text) <= w) return text;
39
42
  const ellipsisWidth = visibleWidth(ellipsis);
40
43
  const budget = w - ellipsisWidth;
@@ -69,7 +72,7 @@ export function clipLabel(text: string): string {
69
72
 
70
73
  /** Pad `text` on the right to exactly `width` columns. Never truncates. */
71
74
  export function padTo(text: string, width: number): string {
72
- const missing = columns(width) - visibleWidth(text);
75
+ const missing = usableWidth(width) - visibleWidth(text);
73
76
  return missing > 0 ? text + " ".repeat(missing) : text;
74
77
  }
75
78
 
@@ -83,7 +86,7 @@ export function padTo(text: string, width: number): string {
83
86
  * plain left-aligned line with no trailing spaces.
84
87
  */
85
88
  export function splitRow(left: string, right: string, width: number, minGap = 1): string {
86
- const w = columns(width);
89
+ const w = usableWidth(width);
87
90
  if (!right) return clip(left, w);
88
91
  const gap = Math.max(0, Math.floor(minGap));
89
92
  const rightCell = clip(right, w);
@@ -93,13 +96,35 @@ export function splitRow(left: string, right: string, width: number, minGap = 1)
93
96
  return padTo(clip(left, leftBudget), w - rightWidth) + rightCell;
94
97
  }
95
98
 
99
+ /**
100
+ * Left cell, then a gap, then the right cell, with the result ending after the
101
+ * right cell instead of filling the width.
102
+ *
103
+ * The sibling of {@link splitRow}: a label and its value read as one pair on a
104
+ * wide terminal instead of sitting at opposite edges, while a tight line still
105
+ * keeps the value — the left cell is clipped to make room, and the gap shrinks
106
+ * in whole columns before the right cell loses anything.
107
+ */
108
+ export function inlineRow(left: string, right: string, width: number, gap = 2): string {
109
+ const w = usableWidth(width);
110
+ if (!right) return clip(left, w);
111
+ const space = Math.max(0, Math.floor(gap));
112
+ const rightCell = clip(right, w);
113
+ const rightWidth = visibleWidth(rightCell);
114
+ const leftBudget = w - rightWidth - space;
115
+ if (leftBudget <= 0) return rightCell;
116
+ const leftCell = clip(left, leftBudget);
117
+ const separator = " ".repeat(Math.max(0, Math.min(space, w - visibleWidth(leftCell) - rightWidth)));
118
+ return `${leftCell}${separator}${rightCell}`;
119
+ }
120
+
96
121
  /**
97
122
  * Join as many whole cells as fit, dropping trailing cells rather than
98
123
  * clipping one in half. The first cell is always kept, so an over-wide line
99
124
  * degrades to a clipped first cell instead of an empty one.
100
125
  */
101
126
  export function fitCells(cells: readonly string[], width: number, separator = " "): string {
102
- const w = columns(width);
127
+ const w = usableWidth(width);
103
128
  const kept: string[] = [];
104
129
  for (const cell of cells) {
105
130
  const candidate = [...kept, cell].join(separator);
@@ -118,7 +143,7 @@ export function fitCells(cells: readonly string[], width: number, separator = "
118
143
  * {@link fitCells} it may return several lines, and always returns one.
119
144
  */
120
145
  export function flowCells(cells: readonly string[], width: number): string[] {
121
- const w = columns(width);
146
+ const w = usableWidth(width);
122
147
  const lines: string[] = [];
123
148
  let current = "";
124
149
  let currentWidth = 0;
package/lib/tui/rows.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import type { Theme } from "@earendil-works/pi-coding-agent";
16
- import { clip, padTo, splitRow } from "./columns.ts";
16
+ import { clip, inlineRow, padTo, splitRow } from "./columns.ts";
17
17
  import { selectionMarker } from "./cursor.ts";
18
18
  import { GLYPHS } from "./glyphs.ts";
19
19
  import { BODY_INDENT } from "./indent.ts";
@@ -41,21 +41,29 @@ export interface ListRow {
41
41
  /** The row's text. */
42
42
  label: string;
43
43
  labelRole?: TuiRole;
44
- /** Right-aligned meta (a count, a time, a state). */
44
+ /** Meta that closes the row: right-aligned, or beside the label when `align` is `inline`. */
45
45
  trailing?: string;
46
46
  trailingRole?: TuiRole;
47
+ /**
48
+ * Where the trailing cell sits. `end` (the default) pushes it to the right
49
+ * edge, which suits a column of counts. `inline` keeps it beside the label
50
+ * with a two-column gap, so a label and its value read as one pair on a wide
51
+ * terminal instead of sitting at opposite ends of the line.
52
+ */
53
+ align?: "end" | "inline";
47
54
  /** Extra columns before the marker, for a nested row. */
48
55
  indent?: number;
49
56
  }
50
57
 
51
58
  /**
52
59
  * One list row: an optional indent, the selection marker, an optional glyph,
53
- * the label, and right-aligned trailing meta.
60
+ * the label, and the trailing meta.
54
61
  *
55
62
  * The marker column is reserved by default, so selected and unselected rows
56
63
  * 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.
64
+ * is the anchor when the terminal is narrow (see `splitRow` and `inlineRow`),
65
+ * so a value at the end of the row survives either way; `align` decides whether
66
+ * it sits at the right edge or right after the label.
59
67
  */
60
68
  export function listRow(theme: Theme, width: number, row: ListRow): string {
61
69
  const indent = " ".repeat(Math.max(0, Math.floor(row.indent ?? 0)));
@@ -64,7 +72,8 @@ export function listRow(theme: Theme, width: number, row: ListRow): string {
64
72
  const glyph = row.glyph ? `${paint(theme, row.glyphRole ?? "info", row.glyph)} ` : "";
65
73
  const label = paint(theme, row.labelRole ?? "label", row.label);
66
74
  const trailing = row.trailing ? paint(theme, row.trailingRole ?? "meta", row.trailing) : "";
67
- return splitRow(`${indent}${marker}${leading}${glyph}${label}`, trailing, width);
75
+ const head = `${indent}${marker}${leading}${glyph}${label}`;
76
+ return row.align === "inline" ? inlineRow(head, trailing, width) : splitRow(head, trailing, width);
68
77
  }
69
78
 
70
79
  /** A status row (a job, a watcher): the marker, a status glyph, a label, and right-aligned meta. */
package/lib/tui/rules.ts CHANGED
@@ -15,6 +15,7 @@
15
15
 
16
16
  import type { Theme } from "@earendil-works/pi-coding-agent";
17
17
  import { visibleWidth } from "@earendil-works/pi-tui";
18
+ import { usableWidth } from "./columns.ts";
18
19
  import { paint, type TuiRole } from "./tokens.ts";
19
20
 
20
21
  /** The glyph and colors a rule is drawn with. */
@@ -39,14 +40,9 @@ export interface CenteredRuleStyle extends RuleStyle {
39
40
  */
40
41
  export const RULE_GLYPH = "─";
41
42
 
42
- /** A usable origin: terminal arithmetic starts from one column, never zero. */
43
- function widthOf(width: number): number {
44
- return Math.max(1, Math.floor(width));
45
- }
46
-
47
43
  /** A full-width divider in one color. */
48
44
  export function plainRule(theme: Theme, width: number, style: RuleStyle = {}): string {
49
- const w = widthOf(width);
45
+ const w = usableWidth(width);
50
46
  return paint(theme, style.fillRole ?? "rule", (style.glyph ?? RULE_GLYPH).repeat(w));
51
47
  }
52
48
 
@@ -64,7 +60,7 @@ function labelRule(
64
60
  fillRole: TuiRole,
65
61
  style: RuleStyle,
66
62
  ): string {
67
- const w = widthOf(width);
63
+ const w = usableWidth(width);
68
64
  const text = label.trim();
69
65
  if (!text) return plainRule(theme, w, style);
70
66
  const labelWidth = visibleWidth(text);
@@ -75,7 +71,7 @@ function labelRule(
75
71
 
76
72
  /** A centered rule: fill, label, and the rest of the fill. */
77
73
  export function centeredRule(theme: Theme, width: number, label: string, style: CenteredRuleStyle = {}): string {
78
- const w = widthOf(width);
74
+ const w = usableWidth(width);
79
75
  const glyph = style.glyph ?? RULE_GLYPH;
80
76
  const fillRole = style.fillRole ?? "ruleStrong";
81
77
  if (!label.trim()) return paint(theme, fillRole, glyph.repeat(w));
package/lib/tui/tool.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
- * The shared tool-transcript kit: a call title, a width-safe call component,
3
- * sanitized error lines, the expand hint, and the running timer.
2
+ * The shared tool-transcript kit: a call title, the call line a renderer draws
3
+ * with it, the failure line a result shows before its body, the expand hint, and
4
+ * the running timer.
4
5
  *
5
6
  * Every extension's `renderCall` / `renderResult` used to repeat the same four
6
7
  * idioms — a bold `toolTitle`, a raw `result.content[0]`, a hand-built
@@ -34,6 +35,58 @@ export function toolCallTitle(theme: Theme, name: string): string {
34
35
  return theme.fg("toolTitle", theme.bold(name));
35
36
  }
36
37
 
38
+ /**
39
+ * One call line: the bold tool name, then the subject in the meta role.
40
+ *
41
+ * The subject is flattened to one line here, because a call line is a label: a
42
+ * stored note, a task, or a path carrying a newline cannot wrap it into the body
43
+ * column, where it would read as a result. Pass the clip the subject deserves —
44
+ * `middle` for a path or a URL whose tail is the identity, `end` for prose.
45
+ *
46
+ * A subject drawn in another role (an argument value in `label`, a suffix in
47
+ * `faint`) stays hand-built at its call site; this is the plain headline.
48
+ */
49
+ export function callLine(
50
+ theme: Theme,
51
+ last: unknown,
52
+ name: string,
53
+ subject: string,
54
+ clip: CallClip = "end",
55
+ ): ToolCallText {
56
+ const text = reuseCallText(last, "", clip);
57
+ text.setText(toolCallTitle(theme, `${name} `) + paint(theme, "meta", sanitizeLine(subject)));
58
+ return text;
59
+ }
60
+
61
+ /**
62
+ * A result resolved to its details, or to the one line a failure shows.
63
+ */
64
+ export type DetailsOrLine<Details> = { line: string } | { details: Details };
65
+
66
+ /**
67
+ * Resolve a result to its details, or to the line to show instead.
68
+ *
69
+ * The failure is one branch whether the call carried no details at all (Pi's
70
+ * `{ details: {}, isError: true }` envelope) or the extension reported its own
71
+ * `details.error`, and it goes through `errorLine`: a multi-line message keeps
72
+ * its lines and `Error:` is prefixed exactly once, which a hand-built
73
+ * `paint(theme, "bad", \`Error: ${sanitizeLine(...)}\`)` does not.
74
+ */
75
+ export function detailsOrLine<Details extends object>(
76
+ theme: Theme,
77
+ result: ToolMessageResult,
78
+ expanded: boolean,
79
+ details: Details | undefined,
80
+ ): DetailsOrLine<Details> {
81
+ if (!details) return { line: rejectionLine(theme, result, expanded) };
82
+ // The one property this reads is the extension's own report field; a details
83
+ // block without it (a plan summary, a check report) simply has no failure.
84
+ if ("error" in details && typeof details.error === "string" && details.error !== "") {
85
+ return { line: errorLine(theme, details.error) };
86
+ }
87
+ return { details };
88
+ }
89
+
37
90
  /** How a call line gives up columns when the terminal is narrow. */
38
91
  export type CallClip = "end" | "middle";
39
92