@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.
- package/README.md +10 -7
- package/README.zh-CN.md +8 -5
- package/extensions/bytefly/README.md +98 -116
- package/extensions/bytefly/notice/component.ts +6 -4
- package/extensions/bytefly/notice/config.ts +16 -2
- package/extensions/bytefly/panel/format.ts +3 -8
- package/extensions/bytefly/panel/join.ts +1 -6
- package/extensions/bytefly/panel/settings.ts +74 -377
- package/extensions/bytefly/panel/tui/entries.ts +16 -14
- package/extensions/bytefly/panel/tui/extensions-view.ts +3 -0
- package/extensions/bytefly/panel/tui/frame.ts +2 -2
- package/extensions/bytefly/panel/tui/settings-view.ts +8 -12
- package/extensions/bytefly/panel/tui.ts +4 -6
- package/extensions/bytefly/picker/resources.ts +7 -3
- package/extensions/bytefly/working/register.ts +2 -8
- package/extensions/check/tools.ts +2 -11
- package/extensions/context/README.md +162 -0
- package/extensions/context/collect.ts +53 -0
- package/extensions/context/commands.ts +100 -0
- package/extensions/context/format.ts +82 -0
- package/extensions/context/index.ts +16 -0
- package/extensions/context/report.ts +319 -0
- package/extensions/context/tui.ts +268 -0
- package/extensions/context/types.ts +122 -0
- package/extensions/file-browse/README.md +38 -18
- package/extensions/file-browse/config.ts +8 -0
- package/extensions/file-browse/html.ts +2 -2
- package/extensions/file-browse/index.ts +17 -2
- package/extensions/file-browse/live-client.ts +13 -13
- package/extensions/file-browse/live.ts +44 -10
- package/extensions/file-browse/markdown.ts +3 -10
- package/extensions/file-browse/raw-html.ts +3 -10
- package/extensions/file-browse/server.ts +13 -2
- package/extensions/file-open/README.md +1 -2
- package/extensions/{document → file-read}/README.md +72 -56
- package/extensions/{document → file-read}/config.ts +12 -9
- package/extensions/{document → file-read}/extract/doc.ts +2 -2
- package/extensions/{document → file-read}/extract/docx.ts +3 -3
- package/extensions/{document → file-read}/extract/file.ts +9 -9
- package/extensions/{document → file-read}/extract/odt.ts +3 -3
- package/extensions/{document → file-read}/extract/pdf.ts +3 -3
- package/extensions/{document → file-read}/extract/rtf.ts +3 -3
- package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
- package/extensions/{document → file-read}/formats.ts +11 -11
- package/extensions/file-read/index.ts +17 -0
- package/extensions/{document → file-read}/paging.ts +15 -13
- package/extensions/{document → file-read}/render.ts +17 -13
- package/extensions/{document → file-read}/schema.ts +7 -7
- package/extensions/{document → file-read}/tool.ts +36 -28
- package/extensions/goal/tools.ts +10 -14
- package/extensions/job/commands.ts +32 -46
- package/extensions/job/runtime.ts +6 -8
- package/extensions/job/tools.ts +7 -26
- package/extensions/memory/commands.ts +30 -44
- package/extensions/memory/tools.ts +6 -14
- package/extensions/plan/tools/enter.ts +6 -7
- package/extensions/plan/tools/exit.ts +6 -7
- package/extensions/plan/tools/steps.ts +6 -7
- package/extensions/plan/tools/write.ts +8 -9
- package/extensions/plan/web.ts +21 -12
- package/extensions/question/tools.ts +5 -4
- package/extensions/subagent/background.ts +1 -7
- package/extensions/todo/tools.ts +10 -14
- package/extensions/watch/README.md +5 -5
- package/extensions/watch/commands.ts +33 -47
- package/extensions/watch/runtime.ts +5 -7
- package/extensions/watch/tools.ts +7 -18
- package/extensions/web-access/README.md +3 -3
- package/extensions/web-access/search/search.ts +9 -18
- package/extensions/wechat/timer.ts +9 -30
- package/extensions/wechat/tools.ts +4 -7
- package/lib/README.md +9 -6
- package/lib/escape.ts +15 -2
- package/lib/pdf.ts +2 -2
- package/lib/requirements.ts +2 -2
- package/lib/screen-session.ts +85 -0
- package/lib/timer.ts +61 -0
- package/lib/tui/README.md +3 -3
- package/lib/tui/columns.ts +33 -8
- package/lib/tui/rows.ts +15 -6
- package/lib/tui/rules.ts +4 -8
- package/lib/tui/tool.ts +55 -2
- package/package.json +8 -4
- package/skills/changelog/SKILL.md +136 -0
- package/extensions/document/index.ts +0 -17
- /package/extensions/{document → file-read}/deadline.ts +0 -0
- /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)
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
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
|
-
|
|
12
|
-
export type ArmTimer = (ms: number, fn: () => void) => () => void;
|
|
12
|
+
import { abortableSleep, type ArmTimer } from "../../lib/timer.ts";
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 {
|
|
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" ?
|
|
88
|
-
|
|
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(
|
|
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
|
|
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 `
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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 `
|
|
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; `
|
|
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
|
*
|
package/lib/requirements.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
155
|
-
ordering, and glyphs — never literal column padding. An extension's
|
|
156
|
-
|
|
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`).
|
package/lib/tui/columns.ts
CHANGED
|
@@ -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
|
-
/**
|
|
19
|
-
|
|
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,
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
|
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`),
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
3
|
-
*
|
|
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
|
|