@oh-my-pi/pi-tui 18.3.0 → 18.3.1

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 (53) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/THIRD-PARTY-NOTICES.txt +2 -2
  3. package/dist/types/chat/read-tool-group.d.ts +3 -3
  4. package/dist/types/chat/user-message.d.ts +4 -1
  5. package/dist/types/components/editor.d.ts +0 -2
  6. package/dist/types/components/input.d.ts +16 -0
  7. package/dist/types/overlays/btw-history-panel.d.ts +4 -1
  8. package/dist/types/overlays/settings-defs.d.ts +5 -0
  9. package/dist/types/prompt/composer.d.ts +12 -1
  10. package/dist/types/prompt/custom-editor.d.ts +5 -26
  11. package/dist/types/space-hold.d.ts +45 -0
  12. package/dist/types/status-line/host.d.ts +2 -0
  13. package/dist/types/tools/cfg-render.d.ts +37 -0
  14. package/dist/types/tools/cfg-url.d.ts +16 -0
  15. package/dist/types/tools/find.d.ts +2 -2
  16. package/dist/types/tools/output-meta.d.ts +2 -0
  17. package/dist/types/tools/proc-render.d.ts +1 -8
  18. package/dist/types/tools/read.d.ts +14 -3
  19. package/dist/types/tools/result-card.d.ts +20 -0
  20. package/dist/types/tools/url-scheme-host.d.ts +29 -0
  21. package/dist/types/tools/write.d.ts +2 -0
  22. package/dist/types/tools/xdev.d.ts +9 -1
  23. package/package.json +9 -9
  24. package/src/chat/chat-transcript-builder.ts +5 -1
  25. package/src/chat/read-tool-group.ts +8 -9
  26. package/src/chat/tool-execution.ts +7 -5
  27. package/src/chat/user-message.ts +16 -6
  28. package/src/components/editor.ts +2 -4
  29. package/src/components/input.ts +103 -30
  30. package/src/components/markdown.ts +18 -6
  31. package/src/overlays/annotation-overlay.ts +0 -1
  32. package/src/overlays/btw-history-panel.ts +4 -0
  33. package/src/overlays/settings-defs.ts +6 -0
  34. package/src/overlays/settings-selector.ts +4 -3
  35. package/src/prompt/composer.ts +83 -56
  36. package/src/prompt/custom-editor.ts +15 -137
  37. package/src/prompt/tips.txt +1 -0
  38. package/src/space-hold.ts +143 -0
  39. package/src/status-line/component.ts +6 -0
  40. package/src/status-line/host.ts +2 -0
  41. package/src/status-line/segments.ts +4 -0
  42. package/src/tools/cfg-render.ts +117 -0
  43. package/src/tools/cfg-url.ts +30 -0
  44. package/src/tools/find.ts +7 -6
  45. package/src/tools/output-meta.ts +2 -0
  46. package/src/tools/proc-render.ts +2 -26
  47. package/src/tools/read.ts +109 -68
  48. package/src/tools/result-card.ts +36 -0
  49. package/src/tools/url-scheme-host.ts +42 -0
  50. package/src/tools/write.ts +109 -66
  51. package/src/tools/xdev.ts +9 -1
  52. package/dist/types/chat/read-target.d.ts +0 -4
  53. package/src/chat/read-target.ts +0 -7
package/src/tools/read.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { SummaryResult } from "@oh-my-pi/pi-natives";
2
2
  import { formatNumberedLine } from "./hashline-format";
3
3
  import { LINE_RANGE_CHUNK_SOURCE, parseLineRanges } from "./line-ranges";
4
+ import * as os from "node:os";
4
5
  import * as path from "node:path";
6
+ import { parseArchivePathCandidates } from "@oh-my-pi/pi-utils/ar";
5
7
  import type { Component } from "../tui";
6
8
  import { Text } from "../components/text";
7
9
  import type { RenderResultOptions, ToolActivityContext, ToolActivitySummary, ToolRenderer } from "./renderer";
@@ -17,6 +19,9 @@ import { formatBytes, sanitizeDisplayLines, shortenPath, wrapBrackets } from "..
17
19
  import type { OutputMeta } from "./output-meta";
18
20
  import type { TruncationResult } from "./streaming-output";
19
21
  import { renderProcRead, type ProcReadDetails } from "./proc-render";
22
+ import { renderCfgRead, type CfgReadDetails } from "./cfg-render";
23
+ import type { CardToolResult } from "./result-card";
24
+ import { type InternalUrlSchemeSpec, internalUrlSchemeSpec, splitUrlScheme } from "./url-scheme-host";
20
25
 
21
26
  /** Read result metadata retains truncation statistics, not a second copy of the body. */
22
27
  export type ReadTruncationStats = Omit<TruncationResult, "content">;
@@ -25,6 +30,7 @@ export type ReadTruncationStats = Omit<TruncationResult, "content">;
25
30
  export interface ReadToolDetails {
26
31
  kind?: "file" | "url";
27
32
  proc?: ProcReadDetails;
33
+ cfg?: CfgReadDetails;
28
34
  /** Filesystem hyperlink target resolved by the executing tool. */
29
35
  displayTarget?: string;
30
36
  truncation?: ReadTruncationStats;
@@ -77,37 +83,6 @@ const INTERNAL_URL_SELECTOR_PART_RE = new RegExp(
77
83
  String.raw`^(?:raw|conflicts|img|${RANGE_LIST_SRC}|-\d+(?:[-+]\d+)?)$`,
78
84
  "i",
79
85
  );
80
- // Schemes whose host grammar is identifier-shaped, so any trailing
81
- // `:<selector-chunk>` is unambiguously a read-tool selector. `mcp://` is
82
- // excluded because mcp resource URIs may legitimately contain colons. `ssh://`
83
- // is included despite an optional `:port`; `splitInternalUrlSel` skips the peel
84
- // for an `ssh://host:port` that has no `/path`, so the port colon is never
85
- // mistaken for a selector (a real ssh selector trails the `/path`, e.g.
86
- // `ssh://h/f:1-5`).
87
- const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
88
- agent: true,
89
- artifact: true,
90
- issue: true,
91
- history: true,
92
- local: true,
93
- memory: true,
94
- omp: true,
95
- pr: true,
96
- proc: true,
97
- rule: true,
98
- security: true,
99
- skill: true,
100
- ssh: true,
101
- vault: true,
102
- };
103
- // Schemes whose resource URIs are server-defined and may legitimately end
104
- // with selector-shaped tails (e.g. `:raw`, `:conflicts`, `:1-50`, `/:raw`).
105
- // `McpProtocolHandler` resolves by exact URI match (`r.uri === uri`), so
106
- // peeling syntactically can make valid resources unreachable. Keep these
107
- // schemes opaque; selector support for them needs a resolver-aware path that
108
- // tries the exact URI before interpreting any suffix as a read selector.
109
- const OPAQUE_RESOURCE_SCHEMES: ReadonlySet<string> = new Set(["mcp"]);
110
- const INTERNAL_URL_SCHEME_RE = /^([a-z][a-z0-9+.-]*):\/\//i;
111
86
  /** Split a filesystem path from its trailing read selector. */
112
87
  export function splitPathAndSel(rawPath: string): { path: string; sel?: string } {
113
88
  const colon = rawPath.lastIndexOf(":");
@@ -151,30 +126,28 @@ export function splitPathAndSel(rawPath: string): { path: string; sel?: string }
151
126
  * This function iteratively peels selector-shaped chunks (well-formed plus
152
127
  * common malformed shapes like `:-N-M`) so the rest of the read tool can pass a
153
128
  * clean URL to the protocol handler and surface selector errors via parseSel
154
- * instead of as misleading "host invalid" errors from the handler. Schemes
155
- * whose resource URIs may legitimately contain colons (`mcp://`) are skipped.
129
+ * instead of as misleading "host invalid" errors from the handler.
130
+ *
131
+ * Only schemes whose spec declares `lines` selectors peel; server-defined URIs
132
+ * (`opaque`), selector-free schemes, and unregistered schemes pass through
133
+ * verbatim. `specOf` defaults to the installed scheme host, so nothing peels
134
+ * before a host is installed.
156
135
  *
157
136
  * Falls back to the input unchanged when nothing matches.
158
137
  */
159
-
160
- export function splitInternalUrlSel(rawPath: string): { path: string; sel?: string } {
161
- const schemeMatch = rawPath.match(INTERNAL_URL_SCHEME_RE);
162
- if (!schemeMatch) return { path: rawPath };
163
- const scheme = schemeMatch[1].toLowerCase();
164
- // Opaque schemes (mcp://, etc.) carry server-defined resource URIs that may
165
- // legitimately end in selector-shaped tails. Forward verbatim — see
166
- // OPAQUE_RESOURCE_SCHEMES.
167
- if (OPAQUE_RESOURCE_SCHEMES.has(scheme)) return { path: rawPath };
168
- if (!INTERNAL_SCHEMES_WITH_SELECTORS[scheme]) return { path: rawPath };
169
-
170
- const schemeEnd = schemeMatch[0].length;
171
- // ssh:// authority carries an optional `:port`; with no `/path` after the
172
- // authority, a trailing `:NNNN` is the port, not a read selector
173
- // (e.g. ssh://host:2222). Other schemes' authority-trailing selectors
174
- // (artifact://5:1-50) still peel, so this guard is ssh-specific.
175
- if (scheme === "ssh" && rawPath.indexOf("/", schemeEnd) === -1) {
176
- return { path: rawPath };
177
- }
138
+ export function splitInternalUrlSel(
139
+ rawPath: string,
140
+ specOf: (scheme: string) => InternalUrlSchemeSpec | undefined = internalUrlSchemeSpec,
141
+ ): { path: string; sel?: string } {
142
+ const url = splitUrlScheme(rawPath);
143
+ if (!url) return { path: rawPath };
144
+ const spec = specOf(url.scheme);
145
+ if (spec?.selectors !== "lines") return { path: rawPath };
146
+ // With no `/path` after the authority, a trailing `:N` is the port
147
+ // (ssh://host:2222), not a read selector.
148
+ if (spec.portAuthority && !url.rest.includes("/")) return { path: rawPath };
149
+
150
+ const schemeEnd = rawPath.length - url.rest.length;
178
151
  let path = rawPath;
179
152
  const chunks: string[] = [];
180
153
  while (true) {
@@ -218,10 +191,77 @@ export interface ReadRenderArgs {
218
191
  raw?: boolean;
219
192
  }
220
193
 
221
- const INTERNAL_URL_LIKE_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
194
+ /** Transcript card for reads of a scheme with its own UI (process table, settings tree). */
195
+ interface ReadUrlCard {
196
+ /** Activity label, e.g. `Process · web`. */
197
+ readonly label: string;
198
+ /** Activity detail when the URL names no target. */
199
+ readonly rootDetail: string;
200
+ /** Result details field whose presence identifies this card. */
201
+ readonly detailsKey: "proc" | "cfg";
202
+ /** Pending call card when `result` is undefined, else the finished result card. */
203
+ render(
204
+ url: string,
205
+ target: string,
206
+ result: CardToolResult | undefined,
207
+ details: ReadToolDetails | undefined,
208
+ options: RenderResultOptions,
209
+ uiTheme: Theme,
210
+ ): Component;
211
+ }
212
+
213
+ /** Read cards keyed by URL scheme. */
214
+ const READ_URL_CARDS: Record<string, ReadUrlCard> = {
215
+ proc: {
216
+ label: "Process",
217
+ rootDetail: "jobs & services",
218
+ detailsKey: "proc",
219
+ render: (_url, target, result, details, options, uiTheme) =>
220
+ renderProcRead(target, result, details?.proc, options, uiTheme),
221
+ },
222
+ cfg: {
223
+ label: "Config",
224
+ rootDetail: "all settings",
225
+ detailsKey: "cfg",
226
+ render: (url, _target, result, details, options, uiTheme) =>
227
+ renderCfgRead(splitInternalUrlSel(url).path, result, details?.cfg, options, uiTheme),
228
+ },
229
+ };
230
+
231
+ /**
232
+ * Card that renders a read of `rawPath` with the text after `scheme://` as its target.
233
+ * Result details identify the card before the URL scheme does.
234
+ */
235
+ function readUrlCard(rawPath: string, details?: ReadToolDetails): { card: ReadUrlCard; target: string } | undefined {
236
+ const url = splitUrlScheme(rawPath);
237
+ const target = url?.rest ?? "";
238
+ if (details) {
239
+ for (const scheme in READ_URL_CARDS) {
240
+ const card = READ_URL_CARDS[scheme];
241
+ if (details[card.detailsKey] !== undefined) return { card, target };
242
+ }
243
+ }
244
+ if (!url || !Object.hasOwn(READ_URL_CARDS, url.scheme)) return undefined;
245
+ return { card: READ_URL_CARDS[url.scheme], target };
246
+ }
247
+
248
+ // A scheme-less host followed by a slash can be a web target. Do not force a
249
+ // file: link onto it; explicit relative paths (./host/path) remain filesystem paths.
250
+ const BARE_WEB_HOST_RE = /^(?:(?:[a-z][a-z0-9-]*|\[[0-9a-f:]+\])(?::\d+)|(?:[a-z0-9-]+\.)+[a-z0-9-]+(?::\d+)?)\//i;
251
+
252
+ /** Local file a pending read/write input points at, before the tool resolves it:
253
+ * expands `~` and drops archive-member / SQLite-row selectors so the link opens
254
+ * the containing file. */
255
+ export function pendingFileLinkPath(inputPath: string): string {
256
+ const expanded = inputPath.replace(/^~(?=$|[\\/])/, os.homedir());
257
+ if (!expanded.includes(":")) return path.resolve(expanded);
258
+ const archive = parseArchivePathCandidates(expanded).find(candidate => candidate.archivePath !== expanded);
259
+ const sqlite = expanded.match(/^(.+\.(?:sqlite3?|db3?))(?=[:?])/i);
260
+ return path.resolve(archive?.archivePath ?? sqlite?.[1] ?? expanded);
261
+ }
222
262
 
223
263
  function splitReadRenderPath(rawPath: string): { path: string; sel?: string } {
224
- if (INTERNAL_URL_LIKE_RE.test(rawPath)) {
264
+ if (splitUrlScheme(rawPath)) {
225
265
  const internal = splitInternalUrlSel(rawPath);
226
266
  if (internal.sel) return internal;
227
267
  }
@@ -259,8 +299,14 @@ function formatReadPathLink(
259
299
  const plainDisplayPath = options.suffixResolution
260
300
  ? shortenPath(options.suffixResolution.to)
261
301
  : shortenPath(basePath || options.resolvedPath || options.fallbackLabel || rawPath);
262
- const absoluteInputPath = path.isAbsolute(basePath) ? basePath : undefined;
263
- const target = options.resolvedPath ?? options.sourcePath ?? absoluteInputPath;
302
+ // Calls render before the tool has resolved a filesystem target. Preserve
303
+ // protocol resources as plain text, but resolve direct relative file paths
304
+ // so terminals receive an explicit file: link rather than guessing HTTPS.
305
+ const inputPath =
306
+ basePath && !splitUrlScheme(basePath) && !BARE_WEB_HOST_RE.test(basePath)
307
+ ? pendingFileLinkPath(basePath)
308
+ : undefined;
309
+ const target = options.resolvedPath ?? options.sourcePath ?? inputPath;
264
310
  const line = firstReadSelectorLine(split.sel) ?? options.offset;
265
311
  const linkOptions = line !== undefined ? { line } : undefined;
266
312
  const linkedPath = target ? fileHyperlink(target, plainDisplayPath, linkOptions) : plainDisplayPath;
@@ -273,15 +319,15 @@ export const readToolRenderer = {
273
319
  const input = args as ReadRenderArgs | undefined;
274
320
  const rawPath =
275
321
  typeof input?.file_path === "string" ? input.file_path : typeof input?.path === "string" ? input.path : "";
276
- if (/^proc:\/\//i.test(rawPath))
277
- return { label: "Process", detail: rawPath.slice("proc://".length) || "jobs & services" };
322
+ const routed = readUrlCard(rawPath);
323
+ if (routed) return { label: routed.card.label, detail: routed.target || routed.card.rootDetail };
278
324
  return { label: "Read", detail: shortenPath(rawPath) };
279
325
  },
280
326
  renderCall(args: ReadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
281
327
  const rawPath =
282
328
  typeof args.file_path === "string" ? args.file_path : typeof args.path === "string" ? args.path : "";
283
- if (/^proc:\/\//i.test(rawPath))
284
- return renderProcRead(rawPath.slice("proc://".length), undefined, undefined, _options, uiTheme);
329
+ const routed = readUrlCard(rawPath);
330
+ if (routed) return routed.card.render(rawPath, routed.target, undefined, undefined, _options, uiTheme);
285
331
  if (isReadableUrlPath(rawPath)) {
286
332
  return renderReadUrlCall({ path: rawPath, raw: args.raw }, _options, uiTheme);
287
333
  }
@@ -309,14 +355,9 @@ export const readToolRenderer = {
309
355
  const urlDetails = result.details as ReadUrlToolDetails | undefined;
310
356
  const baseRawPathForKind =
311
357
  typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
312
- if (/^proc:\/\//i.test(baseRawPathForKind))
313
- return renderProcRead(
314
- baseRawPathForKind.slice("proc://".length),
315
- result,
316
- result.details?.proc,
317
- options,
318
- uiTheme,
319
- );
358
+ const routed = readUrlCard(baseRawPathForKind, result.details);
359
+ if (routed)
360
+ return routed.card.render(baseRawPathForKind, routed.target, result, result.details, options, uiTheme);
320
361
  if (urlDetails?.kind === "url" || isReadableUrlPath(baseRawPathForKind)) {
321
362
  return renderReadUrlResult(
322
363
  result as {
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Line-based status cards shared by the internal-URL renderers (`proc://`,
3
+ * `agent://`, `cfg://`): a header plus sanitized, width-truncated body lines.
4
+ */
5
+ import type { Component } from "../tui";
6
+ import { createCachedComponent, Ellipsis, truncateToWidth } from "../render";
7
+ import { replaceTabs } from "../render/render-utils";
8
+ import type { RenderResultOptions } from "./renderer";
9
+
10
+ /** Tool result fields the card renderers read. */
11
+ export interface CardToolResult {
12
+ content: Array<{ type: string; text?: string }>;
13
+ isError?: boolean;
14
+ }
15
+
16
+ /** First text block of a tool result, or `""`. */
17
+ export function firstText(result: CardToolResult): string {
18
+ return result.content.find(item => item.type === "text")?.text ?? "";
19
+ }
20
+
21
+ /** Sanitize one display line: tabs become spaces, carriage returns are dropped. */
22
+ export function safe(value: string): string {
23
+ return replaceTabs(value).replace(/\r/g, "");
24
+ }
25
+
26
+ /** Padded card whose lines are sanitized and truncated, re-rendered when the expanded state flips. */
27
+ export function card(lines: (width: number, expanded: boolean) => string[], options: RenderResultOptions): Component {
28
+ return createCachedComponent(
29
+ () => Boolean(options.expanded),
30
+ (width, expanded) =>
31
+ lines(width, expanded)
32
+ .flatMap(line => line.split("\n"))
33
+ .map(line => truncateToWidth(safe(line), width, Ellipsis.Unicode)),
34
+ { paddingX: 1 },
35
+ );
36
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Scheme facts supplied by the internal URL host. pi-tui cannot import the
3
+ * coding-agent router, so the router installs this bridge when it loads;
4
+ * selector peeling and transcript renderers consult it instead of hardcoding
5
+ * scheme names.
6
+ */
7
+
8
+ /** Renderer-relevant subset of a registered scheme's declared spec. */
9
+ export interface InternalUrlSchemeSpec {
10
+ /** `lines`: a trailing `:<selector>` chain is a read selector; `none`/`opaque`: never peel. */
11
+ readonly selectors: "lines" | "none" | "opaque";
12
+ /** A trailing `:N` with no path after the authority is a port, not a selector (ssh://host:2222). */
13
+ readonly portAuthority?: boolean;
14
+ /** Read cards collapse into the compact read group like plain files instead of expanding. */
15
+ readonly compactTranscript?: boolean;
16
+ }
17
+
18
+ /** Scheme registry capabilities installed by the internal URL host. */
19
+ export interface InternalUrlSchemeHost {
20
+ /** Declared spec of a registered scheme (case-insensitive); undefined when unregistered. */
21
+ spec(scheme: string): InternalUrlSchemeSpec | undefined;
22
+ }
23
+
24
+ let schemeHost: InternalUrlSchemeHost | undefined;
25
+
26
+ /** Install the scheme registry. Until one is installed, no URL counts as an internal scheme. */
27
+ export function setInternalUrlSchemeHost(host: InternalUrlSchemeHost): void {
28
+ schemeHost = host;
29
+ }
30
+
31
+ /** Declared spec of `scheme`; undefined when it is unregistered or no host is installed. */
32
+ export function internalUrlSchemeSpec(scheme: string): InternalUrlSchemeSpec | undefined {
33
+ return schemeHost?.spec(scheme);
34
+ }
35
+
36
+ const URL_SCHEME_RE = /^([a-z][a-z0-9+.-]*):\/\//i;
37
+
38
+ /** Split a hierarchical `scheme://rest` URL into its lowercased scheme and the text after `://`. */
39
+ export function splitUrlScheme(input: string): { scheme: string; rest: string } | undefined {
40
+ const match = URL_SCHEME_RE.exec(input);
41
+ return match ? { scheme: match[1].toLowerCase(), rest: input.slice(match[0].length) } : undefined;
42
+ }
@@ -23,9 +23,12 @@ import {
23
23
  } from "../render/render-utils";
24
24
  import type { CoordinationDetails } from "./wait";
25
25
  import { renderAgentWrite, renderProcWrite, type ProcWriteAction, type ProcWriteDetails } from "./proc-render";
26
+ import { renderCfgWrite, type CfgWriteDetails } from "./cfg-render";
26
27
  import type { FileDiagnosticsResult } from "./lsp";
27
28
  import type { OutputMeta } from "./output-meta";
29
+ import type { CardToolResult } from "./result-card";
28
30
  import type { RenderResultOptions, ToolActivityContext, ToolActivitySummary, ToolRenderer } from "./renderer";
31
+ import { splitUrlScheme } from "./url-scheme-host";
29
32
  import { couldBecomeXdUrl, parseXdUrl } from "./xd-url";
30
33
  import {
31
34
  renderXdevCall,
@@ -36,6 +39,7 @@ import {
36
39
  } from "./xdev";
37
40
  import { isResolutionDeviceName, renderResolutionDeviceCall } from "./resolve";
38
41
  import { REPORT_ISSUE_DEVICE_NAME, renderReportIssueDeviceCall } from "./report-tool-issue";
42
+ import { pendingFileLinkPath } from "./read";
39
43
 
40
44
  /** Details returned by the write tool for transcript rendering. */
41
45
  export interface WriteToolDetails {
@@ -50,6 +54,7 @@ export interface WriteToolDetails {
50
54
  xdev?: XdevRenderDispatch;
51
55
  message?: CoordinationDetails;
52
56
  proc?: ProcWriteDetails;
57
+ cfg?: CfgWriteDetails;
53
58
  }
54
59
 
55
60
  interface WriteRenderArgs {
@@ -318,13 +323,99 @@ export interface WriteRenderContext {
318
323
  resolveXdevMounted?: (name: string) => XdevMountedRenderer | undefined;
319
324
  }
320
325
 
321
- function procWriteTarget(path: string): { id: string; action: ProcWriteAction } {
322
- const target = path.slice("proc://".length);
326
+ /** Process id and operation named by the text after `proc://`. */
327
+ function procWriteTarget(target: string): { id: string; action: ProcWriteAction } {
323
328
  if (target.endsWith("/kill")) return { id: target.slice(0, -5), action: "kill" };
324
329
  if (target.endsWith("/mode")) return { id: target.slice(0, -5), action: "mode" };
325
330
  return { id: target, action: "stdin" };
326
331
  }
327
332
 
333
+ /** Transcript card for writes to a scheme with its own UI (peer message, process control, setting change). */
334
+ interface WriteUrlCard {
335
+ /** Result details field whose presence identifies this card. */
336
+ readonly detailsKey: "message" | "proc" | "cfg";
337
+ /** Compact activity line for the text after `scheme://`. */
338
+ activity(target: string): ToolActivitySummary;
339
+ /** Pending call card when `result` is undefined, else the finished result card. */
340
+ render(
341
+ url: string,
342
+ target: string,
343
+ content: unknown,
344
+ result: CardToolResult | undefined,
345
+ details: WriteToolDetails | undefined,
346
+ options: RenderResultOptions,
347
+ uiTheme: Theme,
348
+ ): Component;
349
+ }
350
+
351
+ /** Write cards keyed by URL scheme. */
352
+ const WRITE_URL_CARDS: Record<string, WriteUrlCard> = {
353
+ agent: {
354
+ detailsKey: "message",
355
+ activity: target => ({ label: "Message", detail: target === "all" ? "broadcast" : target }),
356
+ render: (_url, target, content, result, details, options, uiTheme) =>
357
+ renderAgentWrite(
358
+ target,
359
+ typeof content === "string" ? content : "",
360
+ result,
361
+ details?.message,
362
+ options,
363
+ uiTheme,
364
+ ),
365
+ },
366
+ proc: {
367
+ detailsKey: "proc",
368
+ activity: target => {
369
+ const { id, action } = procWriteTarget(target);
370
+ return { label: "Process", detail: `${action} ${shortenPath(id)}` };
371
+ },
372
+ render: (_url, target, content, result, details, options, uiTheme) => {
373
+ const { id, action } = procWriteTarget(target);
374
+ return renderProcWrite(
375
+ id,
376
+ action,
377
+ typeof content === "string" ? content : undefined,
378
+ result,
379
+ details?.proc,
380
+ options,
381
+ uiTheme,
382
+ );
383
+ },
384
+ },
385
+ cfg: {
386
+ detailsKey: "cfg",
387
+ activity: target => ({ label: "Config", detail: target }),
388
+ render: (url, _target, content, result, details, options, uiTheme) =>
389
+ renderCfgWrite(url, typeof content === "string" ? content : undefined, result, details?.cfg, options, uiTheme),
390
+ },
391
+ };
392
+
393
+ /**
394
+ * Card that renders a write to `rawPath` with the text after `scheme://` as its target.
395
+ * Result details identify the card before the URL scheme does.
396
+ */
397
+ function writeUrlCard(rawPath: string, details?: WriteToolDetails): { card: WriteUrlCard; target: string } | undefined {
398
+ const url = splitUrlScheme(rawPath);
399
+ const target = url?.rest ?? "";
400
+ if (details) {
401
+ for (const scheme in WRITE_URL_CARDS) {
402
+ const card = WRITE_URL_CARDS[scheme];
403
+ if (details[card.detailsKey] !== undefined) return { card, target };
404
+ }
405
+ }
406
+ if (!url || !Object.hasOwn(WRITE_URL_CARDS, url.scheme)) return undefined;
407
+ return { card: WRITE_URL_CARDS[url.scheme], target };
408
+ }
409
+
410
+ /** Whether a streaming lowercased path is, or could still become, a URL with its own write card. */
411
+ function couldBecomeWriteCardUrl(lowerPath: string): boolean {
412
+ for (const scheme in WRITE_URL_CARDS) {
413
+ const prefix = `${scheme}://`;
414
+ if (prefix.startsWith(lowerPath) || lowerPath.startsWith(prefix)) return true;
415
+ }
416
+ return false;
417
+ }
418
+
328
419
  /** Render file writes and delegated tool-device calls. */
329
420
  export const writeToolRenderer = {
330
421
  /** Compact one-line activity: device writes read as the mounted tool (`LSP · references foo`), file writes as `Write · <path>`. */
@@ -337,16 +428,8 @@ export const writeToolRenderer = {
337
428
  ? writeArgs.path
338
429
  : "";
339
430
  if (!rawPath) return { label: "Write" };
340
- if (/^agent:\/\//i.test(rawPath)) {
341
- return {
342
- label: "Message",
343
- detail: rawPath.slice("agent://".length) === "all" ? "broadcast" : rawPath.slice("agent://".length),
344
- };
345
- }
346
- if (/^proc:\/\//i.test(rawPath)) {
347
- const { id, action } = procWriteTarget(rawPath);
348
- return { label: "Process", detail: `${action} ${shortenPath(id)}` };
349
- }
431
+ const routed = writeUrlCard(rawPath);
432
+ if (routed) return routed.card.activity(routed.target);
350
433
  const xdev = parseXdUrl(rawPath);
351
434
  if (xdev?.name) {
352
435
  const resolveMounted = (context.renderContext as WriteRenderContext | undefined)?.resolveXdevMounted;
@@ -370,36 +453,10 @@ export const writeToolRenderer = {
370
453
  if (args.path === undefined && args.file_path === undefined) return undefined;
371
454
  const pathSettled = args.content !== undefined || options.argsComplete === true;
372
455
  const hasStringPath = typeof args.file_path === "string" || typeof args.path === "string";
373
- if (
374
- hasStringPath &&
375
- !pathSettled &&
376
- ("agent://".startsWith(rawPath.toLowerCase()) ||
377
- "proc://".startsWith(rawPath.toLowerCase()) ||
378
- /^(?:agent|proc):\/\//i.test(rawPath))
379
- )
380
- return undefined;
381
- if (/^agent:\/\//i.test(rawPath)) {
382
- return renderAgentWrite(
383
- rawPath.slice("agent://".length),
384
- typeof args.content === "string" ? args.content : "",
385
- undefined,
386
- undefined,
387
- options,
388
- uiTheme,
389
- );
390
- }
391
- if (/^proc:\/\//i.test(rawPath)) {
392
- const { id, action } = procWriteTarget(rawPath);
393
- return renderProcWrite(
394
- id,
395
- action,
396
- typeof args.content === "string" ? args.content : undefined,
397
- undefined,
398
- undefined,
399
- options,
400
- uiTheme,
401
- );
402
- }
456
+ if (hasStringPath && !pathSettled && couldBecomeWriteCardUrl(rawPath.toLowerCase())) return undefined;
457
+ const routed = writeUrlCard(rawPath);
458
+ if (routed)
459
+ return routed.card.render(rawPath, routed.target, args.content, undefined, undefined, options, uiTheme);
403
460
  if (rawPath && couldBecomeXdUrl(rawPath)) {
404
461
  const xdev = parseXdUrl(rawPath);
405
462
  // The path string is settled once the content field started streaming.
@@ -412,7 +469,11 @@ export const writeToolRenderer = {
412
469
  const filePath = shortenPath(rawPath);
413
470
  const lang = rawPath ? (getLanguageFromPath(rawPath) ?? "text") : "text";
414
471
  const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
415
- const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
472
+ const styledPath = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
473
+ // The result has not resolved its target yet. Link the containing file
474
+ // rather than an archive member or database row selector.
475
+ const pathDisplay =
476
+ filePath && args.content !== undefined ? fileHyperlink(pendingFileLinkPath(rawPath), styledPath) : styledPath;
416
477
  // No status icon on the head row: it's the head of the framed block, and
417
478
  // native-scrollback commits are prefix-only — an animated glyph would pin
418
479
  // the commit boundary at the top, and the pending hourglass just adds
@@ -465,29 +526,11 @@ export const writeToolRenderer = {
465
526
  uiTheme: Theme,
466
527
  args?: WriteRenderArgs,
467
528
  ): Component {
468
- const messagePath = typeof args?.path === "string" ? args.path : args?.file_path;
469
- if (typeof messagePath === "string" && /^agent:\/\//i.test(messagePath)) {
470
- return renderAgentWrite(
471
- messagePath.slice("agent://".length),
472
- typeof args?.content === "string" ? args.content : "",
473
- result,
474
- result.details?.message,
475
- options,
476
- uiTheme,
477
- );
478
- }
479
- if (typeof messagePath === "string" && /^proc:\/\//i.test(messagePath)) {
480
- const { id, action } = procWriteTarget(messagePath);
481
- return renderProcWrite(
482
- id,
483
- action,
484
- typeof args?.content === "string" ? args.content : undefined,
485
- result,
486
- result.details?.proc,
487
- options,
488
- uiTheme,
489
- );
490
- }
529
+ const cardPath =
530
+ typeof args?.path === "string" ? args.path : typeof args?.file_path === "string" ? args.file_path : "";
531
+ const routed = writeUrlCard(cardPath, result.details);
532
+ if (routed)
533
+ return routed.card.render(cardPath, routed.target, args?.content, result, result.details, options, uiTheme);
491
534
  // xd:// dispatch results render as the mounted tool's own result.
492
535
  const xdev = result.details?.xdev;
493
536
  if (xdev) {
package/src/tools/xdev.ts CHANGED
@@ -15,10 +15,18 @@ export interface XdevMountedRenderer {
15
15
  mergeCallAndResult?: boolean;
16
16
  }
17
17
 
18
- /** Mounted tool presentation state supplied by the host's canonical tool map. */
18
+ /** Tool presentation state supplied by the host's canonical tool map. */
19
19
  export interface XdevMountedState {
20
20
  readonly mountedNames: ReadonlySet<string>;
21
21
  readonly tools: ReadonlyMap<string, XdevMountedRenderer>;
22
+ /**
23
+ * Canonical renderer for a device name, supplied by the host with the same
24
+ * predicate as dispatch (`resolveXdevTool`: mounted devices plus active
25
+ * top-level tools, which the `write` transport also accepts). The TUI keeps
26
+ * no predicate copy of its own — a second copy is what dropped a top-level
27
+ * dispatch to the generic card.
28
+ */
29
+ readonly resolve?: (name: string) => XdevMountedRenderer | undefined;
22
30
  }
23
31
 
24
32
  function isComponent(value: unknown): value is Component {
@@ -1,4 +0,0 @@
1
- /** Host-owned internal protocol classifier used by compact read cards. */
2
- export declare let internalReadTargetPredicate: ((target: string) => boolean) | undefined;
3
- /** Supply protocol classification without loading the host router in a renderer. */
4
- export declare function setInternalReadTargetPredicate(predicate: (target: string) => boolean): void;
@@ -1,7 +0,0 @@
1
- /** Host-owned internal protocol classifier used by compact read cards. */
2
- export let internalReadTargetPredicate: ((target: string) => boolean) | undefined;
3
-
4
- /** Supply protocol classification without loading the host router in a renderer. */
5
- export function setInternalReadTargetPredicate(predicate: (target: string) => boolean): void {
6
- internalReadTargetPredicate = predicate;
7
- }