@oh-my-pi/pi-tui 18.2.11 → 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 (101) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/THIRD-PARTY-NOTICES.txt +3 -3
  3. package/dist/types/apps/ps-data.d.ts +1 -1
  4. package/dist/types/apps/ps-top.d.ts +1 -1
  5. package/dist/types/chat/chat-transcript-builder.d.ts +1 -1
  6. package/dist/types/chat/read-tool-group.d.ts +3 -3
  7. package/dist/types/chat/user-message.d.ts +4 -1
  8. package/dist/types/components/editor.d.ts +0 -2
  9. package/dist/types/components/input.d.ts +16 -0
  10. package/dist/types/overlays/annotation-overlay.d.ts +30 -0
  11. package/dist/types/overlays/annotation-types.d.ts +84 -0
  12. package/dist/types/overlays/btw-history-panel.d.ts +4 -1
  13. package/dist/types/overlays/copy-selector.d.ts +30 -2
  14. package/dist/types/overlays/copy-targets.d.ts +2 -0
  15. package/dist/types/overlays/model-hub.d.ts +2 -2
  16. package/dist/types/overlays/oauth-selector.d.ts +3 -6
  17. package/dist/types/overlays/settings-defs.d.ts +5 -0
  18. package/dist/types/overlays/usage-dashboard.d.ts +2 -0
  19. package/dist/types/prompt/composer.d.ts +12 -1
  20. package/dist/types/prompt/custom-editor.d.ts +5 -26
  21. package/dist/types/prompt/image-source.d.ts +9 -8
  22. package/dist/types/space-hold.d.ts +45 -0
  23. package/dist/types/status-line/context-usage.d.ts +3 -1
  24. package/dist/types/status-line/host.d.ts +2 -0
  25. package/dist/types/theme/mermaid-cache.d.ts +4 -6
  26. package/dist/types/tools/bash.d.ts +7 -0
  27. package/dist/types/tools/cfg-render.d.ts +37 -0
  28. package/dist/types/tools/cfg-url.d.ts +16 -0
  29. package/dist/types/tools/daemon.d.ts +46 -0
  30. package/dist/types/tools/find.d.ts +2 -2
  31. package/dist/types/tools/irc.d.ts +30 -0
  32. package/dist/types/tools/line-ranges.d.ts +1 -1
  33. package/dist/types/tools/output-meta.d.ts +2 -0
  34. package/dist/types/tools/proc-render.d.ts +27 -0
  35. package/dist/types/tools/read.d.ts +18 -4
  36. package/dist/types/tools/result-card.d.ts +20 -0
  37. package/dist/types/tools/url-scheme-host.d.ts +29 -0
  38. package/dist/types/tools/wait.d.ts +131 -0
  39. package/dist/types/tools/write.d.ts +6 -0
  40. package/dist/types/tools/xd-url.d.ts +8 -0
  41. package/dist/types/tools/xdev.d.ts +9 -1
  42. package/dist/types/windows-altgr.d.ts +54 -0
  43. package/package.json +13 -10
  44. package/src/apps/debug/protocol-probe.ts +1 -1
  45. package/src/apps/ps-data.ts +1 -1
  46. package/src/apps/ps-top.ts +1 -1
  47. package/src/chat/chat-transcript-builder.ts +9 -5
  48. package/src/chat/read-tool-group.ts +8 -9
  49. package/src/chat/tool-execution.ts +15 -13
  50. package/src/chat/transcript-render-helpers.ts +3 -3
  51. package/src/chat/user-message.ts +16 -6
  52. package/src/components/editor.ts +2 -4
  53. package/src/components/input.ts +103 -30
  54. package/src/components/markdown.ts +19 -7
  55. package/src/desktop-notify.ts +1 -1
  56. package/src/overlays/annotation-overlay.ts +1144 -0
  57. package/src/overlays/annotation-types.ts +78 -0
  58. package/src/overlays/btw-history-panel.ts +4 -0
  59. package/src/overlays/copy-selector.ts +45 -19
  60. package/src/overlays/copy-targets.ts +3 -3
  61. package/src/overlays/model-hub.ts +5 -4
  62. package/src/overlays/oauth-selector.ts +8 -12
  63. package/src/overlays/plan-review-overlay.ts +213 -40
  64. package/src/overlays/settings-defs.ts +6 -0
  65. package/src/overlays/settings-selector.ts +4 -3
  66. package/src/overlays/usage-dashboard.ts +22 -1
  67. package/src/prompt/composer.ts +83 -56
  68. package/src/prompt/custom-editor.ts +15 -137
  69. package/src/prompt/image-source.ts +9 -8
  70. package/src/prompt/tips.txt +1 -0
  71. package/src/setup/scenes/sign-in.ts +1 -1
  72. package/src/space-hold.ts +143 -0
  73. package/src/status-line/component.ts +6 -0
  74. package/src/status-line/context-usage.ts +15 -3
  75. package/src/status-line/host.ts +2 -0
  76. package/src/status-line/segments.ts +4 -0
  77. package/src/terminal-capabilities.ts +2 -2
  78. package/src/terminal.ts +8 -1
  79. package/src/theme/mermaid-cache.ts +83 -25
  80. package/src/tools/bash.ts +14 -1
  81. package/src/tools/cfg-render.ts +117 -0
  82. package/src/tools/cfg-url.ts +30 -0
  83. package/src/tools/daemon.ts +51 -0
  84. package/src/tools/find.ts +7 -6
  85. package/src/tools/index.ts +2 -2
  86. package/src/tools/irc.ts +33 -0
  87. package/src/tools/line-ranges.ts +13 -3
  88. package/src/tools/output-meta.ts +2 -0
  89. package/src/tools/proc-render.ts +270 -0
  90. package/src/tools/read.ts +118 -56
  91. package/src/tools/result-card.ts +36 -0
  92. package/src/tools/url-scheme-host.ts +42 -0
  93. package/src/tools/wait.ts +595 -0
  94. package/src/tools/write.ts +120 -1
  95. package/src/tools/xd-url.ts +9 -0
  96. package/src/tools/xdev.ts +9 -1
  97. package/src/windows-altgr.ts +227 -0
  98. package/dist/types/chat/read-target.d.ts +0 -4
  99. package/dist/types/tools/hub.d.ts +0 -342
  100. package/src/chat/read-target.ts +0 -7
  101. package/src/tools/hub.ts +0 -1492
@@ -0,0 +1,270 @@
1
+ import type { Component } from "../tui";
2
+ import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../render";
3
+ import {
4
+ cappedHeadLines,
5
+ formatBadge,
6
+ formatDuration,
7
+ formatErrorDetail,
8
+ formatStatusIcon,
9
+ PREVIEW_LIMITS,
10
+ TRUNCATE_LENGTHS,
11
+ type ToolUIColor,
12
+ } from "../render/render-utils";
13
+ import type { Theme } from "../theme/theme";
14
+ import type { RenderResultOptions } from "./renderer";
15
+ import type { AgentActivitySnapshot, CoordinationDetails, JobSnapshot } from "./wait";
16
+ import type { IrcDeliveryReceipt } from "./irc";
17
+ import type { DaemonSnapshot } from "./daemon";
18
+ import { styleTerminalRow } from "./terminal-output";
19
+ import { card, type CardToolResult as ToolResult, firstText, safe } from "./result-card";
20
+
21
+ export interface ProcReadDetails {
22
+ jobs?: JobSnapshot[];
23
+ agents?: AgentActivitySnapshot[];
24
+ daemons?: DaemonSnapshot[];
25
+ job?: JobSnapshot;
26
+ daemon?: DaemonSnapshot;
27
+ log?: string;
28
+ terminalRows?: string[];
29
+ }
30
+
31
+ export type ProcWriteDetails =
32
+ | CoordinationDetails
33
+ | { action: "stop" | "stdin" | "mode"; daemon: DaemonSnapshot; input?: string; mode?: string };
34
+
35
+ /** Process operation selected by a write URL, independent of its content. */
36
+ export type ProcWriteAction = "stdin" | "mode" | "kill";
37
+
38
+ function preview(body: string, expanded: boolean, theme: Theme, tone: "dim" | "toolOutput" = "dim"): string[] {
39
+ if (!body.trim()) return [];
40
+ const limit = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.COLLAPSED_LINES;
41
+ const shown = cappedHeadLines(
42
+ body.split("\n").filter(line => line.trim()),
43
+ limit,
44
+ );
45
+ const quote = theme.fg("dim", theme.md.quoteBorder);
46
+ const lines = shown.lines.map(
47
+ line =>
48
+ ` ${quote} ${theme.fg(tone, truncateToWidth(safe(line.trim()), TRUNCATE_LENGTHS.LINE, Ellipsis.Unicode))}`,
49
+ );
50
+ if (shown.hidden) lines.push(` ${quote} ${theme.fg("dim", `… +${shown.hidden} more lines`)}`);
51
+ return lines;
52
+ }
53
+
54
+ function receiptColor(outcome: IrcDeliveryReceipt["outcome"]): ToolUIColor {
55
+ return outcome === "failed" ? "error" : outcome === "revived" ? "warning" : "success";
56
+ }
57
+
58
+ export function renderAgentWrite(
59
+ to: string,
60
+ body: string,
61
+ result: ToolResult | undefined,
62
+ details: CoordinationDetails | undefined,
63
+ options: RenderResultOptions,
64
+ theme: Theme,
65
+ ): Component {
66
+ return card((width, expanded) => {
67
+ const recipient = safe(details?.to || to || "…");
68
+ const title = `IRC ${theme.nav.selected} ${recipient}`;
69
+ const receipts = details?.receipts ?? [];
70
+ const delivered = receipts.filter(receipt => receipt.outcome !== "failed").length;
71
+ const failed = receipts.length - delivered;
72
+ const error = result?.isError || (failed > 0 && delivered === 0);
73
+ const meta: string[] = [];
74
+ if (recipient === "all") meta.push("broadcast");
75
+ if (receipts.length === 1) meta.push(theme.fg(receiptColor(receipts[0]!.outcome), receipts[0]!.outcome));
76
+ else if (receipts.length > 1) {
77
+ if (delivered) meta.push(theme.fg("success", `${delivered} delivered`));
78
+ if (failed) meta.push(theme.fg("error", `${failed} failed`));
79
+ }
80
+ const header = renderStatusLine(
81
+ result === undefined
82
+ ? { icon: "pending", title, meta }
83
+ : error
84
+ ? { icon: "error", title, meta }
85
+ : { iconOverride: theme.styledSymbol("tool.irc", "accent"), title, meta },
86
+ theme,
87
+ );
88
+ if (result?.isError && receipts.length === 0)
89
+ return [header, formatErrorDetail(firstText(result) || "Message delivery failed.", theme)];
90
+ const lines = [header, ...preview(body, expanded, theme)];
91
+ if (result && receipts.length === 0)
92
+ lines.push(theme.fg("muted", firstText(result) || "No live peers to broadcast to."));
93
+ if (receipts.length > 1 || failed > 0) {
94
+ lines.push(
95
+ ...renderTreeList(
96
+ {
97
+ items: receipts,
98
+ expanded,
99
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
100
+ itemType: "recipient",
101
+ renderItem: receipt =>
102
+ `${theme.fg("toolOutput", safe(receipt.to))} ${formatBadge(receipt.outcome, receiptColor(receipt.outcome), theme)}${receipt.error ? ` ${theme.fg("error", `${theme.format.dash} ${safe(receipt.error)}`)}` : ""}`,
103
+ },
104
+ theme,
105
+ ),
106
+ );
107
+ }
108
+ return lines.map(line => truncateToWidth(line, width, Ellipsis.Unicode));
109
+ }, options);
110
+ }
111
+
112
+ function daemonMeta(daemon: DaemonSnapshot, theme: Theme): string[] {
113
+ const stateColor =
114
+ daemon.state === "ready" || daemon.state === "running"
115
+ ? "success"
116
+ : daemon.state === "failed"
117
+ ? "error"
118
+ : "warning";
119
+ const meta = [theme.fg(stateColor, daemon.state)];
120
+ if (daemon.pid !== undefined) meta.push(`pid ${daemon.pid}`);
121
+ meta.push(
122
+ `${daemon.exitedAt === undefined ? "up" : "ran"} ${formatDuration(Math.max(0, (daemon.exitedAt ?? Date.now()) - daemon.startedAt))}`,
123
+ );
124
+ if (daemon.detached) meta.push("detached");
125
+ else if (daemon.persist) meta.push("persistent");
126
+ return meta;
127
+ }
128
+
129
+ function jobRow(job: JobSnapshot, theme: Theme): string {
130
+ const icon = formatStatusIcon(
131
+ job.status === "cancelled"
132
+ ? "aborted"
133
+ : job.status === "failed"
134
+ ? "error"
135
+ : job.status === "running"
136
+ ? "running"
137
+ : "done",
138
+ theme,
139
+ );
140
+ return `${icon} ${formatBadge(job.type, job.status === "failed" ? "error" : job.status === "cancelled" ? "warning" : "accent", theme)} ${theme.fg("toolOutput", safe(job.id))} ${theme.fg("dim", safe(job.label))} ${theme.fg("dim", formatDuration(job.durationMs))}`;
141
+ }
142
+
143
+ /** Render live and completed process writes with the URL-selected operation. */
144
+ export function renderProcWrite(
145
+ id: string,
146
+ action: ProcWriteAction,
147
+ content: string | undefined,
148
+ result: ToolResult | undefined,
149
+ details: ProcWriteDetails | undefined,
150
+ options: RenderResultOptions,
151
+ theme: Theme,
152
+ ): Component {
153
+ return card((_width, expanded) => {
154
+ const title = `Proc ${action} ${safe(id || "…")}`;
155
+ const daemon = details && "daemon" in details ? details.daemon : undefined;
156
+ const header = renderStatusLine(
157
+ {
158
+ icon:
159
+ result === undefined ? "pending" : result.isError ? "error" : action === "kill" ? "aborted" : "success",
160
+ title,
161
+ meta: daemon ? daemonMeta(daemon, theme) : action === "mode" && content ? [safe(content)] : [],
162
+ },
163
+ theme,
164
+ );
165
+ if (result?.isError) return [header, formatErrorDetail(firstText(result) || "Process operation failed.", theme)];
166
+ const lines = [header];
167
+ if (content && action === "stdin") lines.push(...preview(content, expanded, theme));
168
+ if (details && "op" in details && details.op === "cancel") {
169
+ const jobs = details.jobs ?? [];
170
+ const outcomes = details.cancelled ?? [];
171
+ lines.push(
172
+ ...renderTreeList(
173
+ {
174
+ items: outcomes,
175
+ expanded,
176
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
177
+ itemType: "job",
178
+ renderItem: outcome => {
179
+ const job = jobs.find(item => item.id === outcome.id);
180
+ return `${job ? `${jobRow(job, theme)} ` : `${theme.fg("toolOutput", safe(outcome.id))} `}${formatBadge(outcome.status, outcome.status === "cancelled" ? "warning" : "error", theme)}`;
181
+ },
182
+ },
183
+ theme,
184
+ ),
185
+ );
186
+ }
187
+ return lines;
188
+ }, options);
189
+ }
190
+
191
+ export function renderProcRead(
192
+ id: string,
193
+ result: ToolResult | undefined,
194
+ details: ProcReadDetails | undefined,
195
+ options: RenderResultOptions,
196
+ theme: Theme,
197
+ ): Component {
198
+ return card((_width, expanded) => {
199
+ const title = id ? `Proc ${safe(id)}` : "Proc jobs & services";
200
+ const daemon = details?.daemon;
201
+ const header = renderStatusLine(
202
+ {
203
+ icon: result === undefined ? "pending" : result.isError ? "error" : "info",
204
+ title,
205
+ meta: daemon ? daemonMeta(daemon, theme) : [],
206
+ },
207
+ theme,
208
+ );
209
+ if (result?.isError) return [header, formatErrorDetail(firstText(result) || "Process read failed.", theme)];
210
+ if (!result) return [header];
211
+ if (details?.job)
212
+ return [
213
+ header,
214
+ jobRow(details.job, theme),
215
+ ...preview(
216
+ details.log ?? details.job.errorText ?? details.job.resultText ?? "",
217
+ expanded,
218
+ theme,
219
+ "toolOutput",
220
+ ),
221
+ ];
222
+ if (daemon) {
223
+ const output = details.terminalRows ?? (details.log ?? "").split("\n").filter(Boolean);
224
+ const limit = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.COLLAPSED_LINES;
225
+ const visible = output
226
+ .slice(-limit)
227
+ .map(
228
+ line =>
229
+ ` ${styleTerminalRow(truncateToWidth(safe(line), TRUNCATE_LENGTHS.LINE, Ellipsis.Unicode), theme.fg("toolOutput", ""))}`,
230
+ );
231
+ if (output.length > limit) visible.unshift(theme.fg("dim", ` … ${output.length - limit} earlier lines`));
232
+ return [header, ...visible];
233
+ }
234
+ if (id && !details?.jobs && !details?.daemons && !details?.agents) {
235
+ return [header, ...preview(firstText(result), expanded, theme, "toolOutput")];
236
+ }
237
+ const jobs = details?.jobs ?? [];
238
+ const services = details?.daemons ?? [];
239
+ const agents = details?.agents ?? [];
240
+ const meta = [
241
+ `${jobs.length} jobs`,
242
+ `${services.length} services`,
243
+ ...(agents.length ? [`${agents.length} agents`] : []),
244
+ ];
245
+ const listHeader = renderStatusLine({ icon: "info", title, meta }, theme);
246
+ const items = [
247
+ ...jobs.map(job => ({ label: jobRow(job, theme) })),
248
+ ...services.map(service => ({
249
+ label: `${formatBadge("service", "accent", theme)} ${theme.fg("toolOutput", safe(service.name))} ${formatBadge(service.state, service.state === "failed" ? "error" : service.state === "ready" || service.state === "running" ? "success" : "warning", theme)} ${daemonMeta(service, theme).slice(1).join(theme.sep.dot)}`,
250
+ })),
251
+ ...agents.map(agent => ({
252
+ label: `${formatBadge("agent", agent.live ? "accent" : "warning", theme)} ${theme.fg("toolOutput", safe(agent.id))} ${theme.fg("dim", formatDuration(agent.ageMs))}`,
253
+ })),
254
+ ];
255
+ return [
256
+ listHeader,
257
+ ...renderTreeList(
258
+ {
259
+ items,
260
+ expanded,
261
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
262
+ itemType: "process",
263
+ renderItem: item => item.label,
264
+ },
265
+ theme,
266
+ ),
267
+ ...(items.length ? [] : [theme.fg("dim", "No background jobs or services.")]),
268
+ ];
269
+ }, options);
270
+ }
package/src/tools/read.ts CHANGED
@@ -1,10 +1,12 @@
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
- import type { RenderResultOptions, ToolRenderer } from "./renderer";
9
+ import type { RenderResultOptions, ToolActivityContext, ToolActivitySummary, ToolRenderer } from "./renderer";
8
10
  import { getLanguageFromPath } from "../lang-from-path";
9
11
  import type { Theme } from "../theme/theme";
10
12
  import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine } from "../render";
@@ -16,6 +18,10 @@ import { formatBytes, sanitizeDisplayLines, shortenPath, wrapBrackets } from "..
16
18
 
17
19
  import type { OutputMeta } from "./output-meta";
18
20
  import type { TruncationResult } from "./streaming-output";
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";
19
25
 
20
26
  /** Read result metadata retains truncation statistics, not a second copy of the body. */
21
27
  export type ReadTruncationStats = Omit<TruncationResult, "content">;
@@ -23,6 +29,8 @@ export type ReadTruncationStats = Omit<TruncationResult, "content">;
23
29
  /** Display metadata for file and URL reads. */
24
30
  export interface ReadToolDetails {
25
31
  kind?: "file" | "url";
32
+ proc?: ProcReadDetails;
33
+ cfg?: CfgReadDetails;
26
34
  /** Filesystem hyperlink target resolved by the executing tool. */
27
35
  displayTarget?: string;
28
36
  truncation?: ReadTruncationStats;
@@ -75,36 +83,6 @@ const INTERNAL_URL_SELECTOR_PART_RE = new RegExp(
75
83
  String.raw`^(?:raw|conflicts|img|${RANGE_LIST_SRC}|-\d+(?:[-+]\d+)?)$`,
76
84
  "i",
77
85
  );
78
- // Schemes whose host grammar is identifier-shaped, so any trailing
79
- // `:<selector-chunk>` is unambiguously a read-tool selector. `mcp://` is
80
- // excluded because mcp resource URIs may legitimately contain colons. `ssh://`
81
- // is included despite an optional `:port`; `splitInternalUrlSel` skips the peel
82
- // for an `ssh://host:port` that has no `/path`, so the port colon is never
83
- // mistaken for a selector (a real ssh selector trails the `/path`, e.g.
84
- // `ssh://h/f:1-5`).
85
- const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
86
- agent: true,
87
- artifact: true,
88
- issue: true,
89
- history: true,
90
- local: true,
91
- memory: true,
92
- omp: true,
93
- pr: true,
94
- rule: true,
95
- security: true,
96
- skill: true,
97
- ssh: true,
98
- vault: true,
99
- };
100
- // Schemes whose resource URIs are server-defined and may legitimately end
101
- // with selector-shaped tails (e.g. `:raw`, `:conflicts`, `:1-50`, `/:raw`).
102
- // `McpProtocolHandler` resolves by exact URI match (`r.uri === uri`), so
103
- // peeling syntactically can make valid resources unreachable. Keep these
104
- // schemes opaque; selector support for them needs a resolver-aware path that
105
- // tries the exact URI before interpreting any suffix as a read selector.
106
- const OPAQUE_RESOURCE_SCHEMES: ReadonlySet<string> = new Set(["mcp"]);
107
- const INTERNAL_URL_SCHEME_RE = /^([a-z][a-z0-9+.-]*):\/\//i;
108
86
  /** Split a filesystem path from its trailing read selector. */
109
87
  export function splitPathAndSel(rawPath: string): { path: string; sel?: string } {
110
88
  const colon = rawPath.lastIndexOf(":");
@@ -148,30 +126,28 @@ export function splitPathAndSel(rawPath: string): { path: string; sel?: string }
148
126
  * This function iteratively peels selector-shaped chunks (well-formed plus
149
127
  * common malformed shapes like `:-N-M`) so the rest of the read tool can pass a
150
128
  * clean URL to the protocol handler and surface selector errors via parseSel
151
- * instead of as misleading "host invalid" errors from the handler. Schemes
152
- * 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.
153
135
  *
154
136
  * Falls back to the input unchanged when nothing matches.
155
137
  */
156
-
157
- export function splitInternalUrlSel(rawPath: string): { path: string; sel?: string } {
158
- const schemeMatch = rawPath.match(INTERNAL_URL_SCHEME_RE);
159
- if (!schemeMatch) return { path: rawPath };
160
- const scheme = schemeMatch[1].toLowerCase();
161
- // Opaque schemes (mcp://, etc.) carry server-defined resource URIs that may
162
- // legitimately end in selector-shaped tails. Forward verbatim — see
163
- // OPAQUE_RESOURCE_SCHEMES.
164
- if (OPAQUE_RESOURCE_SCHEMES.has(scheme)) return { path: rawPath };
165
- if (!INTERNAL_SCHEMES_WITH_SELECTORS[scheme]) return { path: rawPath };
166
-
167
- const schemeEnd = schemeMatch[0].length;
168
- // ssh:// authority carries an optional `:port`; with no `/path` after the
169
- // authority, a trailing `:NNNN` is the port, not a read selector
170
- // (e.g. ssh://host:2222). Other schemes' authority-trailing selectors
171
- // (artifact://5:1-50) still peel, so this guard is ssh-specific.
172
- if (scheme === "ssh" && rawPath.indexOf("/", schemeEnd) === -1) {
173
- return { path: rawPath };
174
- }
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;
175
151
  let path = rawPath;
176
152
  const chunks: string[] = [];
177
153
  while (true) {
@@ -215,10 +191,77 @@ export interface ReadRenderArgs {
215
191
  raw?: boolean;
216
192
  }
217
193
 
218
- 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
+ }
219
262
 
220
263
  function splitReadRenderPath(rawPath: string): { path: string; sel?: string } {
221
- if (INTERNAL_URL_LIKE_RE.test(rawPath)) {
264
+ if (splitUrlScheme(rawPath)) {
222
265
  const internal = splitInternalUrlSel(rawPath);
223
266
  if (internal.sel) return internal;
224
267
  }
@@ -256,8 +299,14 @@ function formatReadPathLink(
256
299
  const plainDisplayPath = options.suffixResolution
257
300
  ? shortenPath(options.suffixResolution.to)
258
301
  : shortenPath(basePath || options.resolvedPath || options.fallbackLabel || rawPath);
259
- const absoluteInputPath = path.isAbsolute(basePath) ? basePath : undefined;
260
- 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;
261
310
  const line = firstReadSelectorLine(split.sel) ?? options.offset;
262
311
  const linkOptions = line !== undefined ? { line } : undefined;
263
312
  const linkedPath = target ? fileHyperlink(target, plainDisplayPath, linkOptions) : plainDisplayPath;
@@ -266,9 +315,19 @@ function formatReadPathLink(
266
315
 
267
316
  /** Render file, image, and URL reads in the transcript. */
268
317
  export const readToolRenderer = {
318
+ activitySummary(args: unknown, _context: ToolActivityContext): ToolActivitySummary {
319
+ const input = args as ReadRenderArgs | undefined;
320
+ const rawPath =
321
+ typeof input?.file_path === "string" ? input.file_path : typeof input?.path === "string" ? input.path : "";
322
+ const routed = readUrlCard(rawPath);
323
+ if (routed) return { label: routed.card.label, detail: routed.target || routed.card.rootDetail };
324
+ return { label: "Read", detail: shortenPath(rawPath) };
325
+ },
269
326
  renderCall(args: ReadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
270
327
  const rawPath =
271
328
  typeof args.file_path === "string" ? args.file_path : typeof args.path === "string" ? args.path : "";
329
+ const routed = readUrlCard(rawPath);
330
+ if (routed) return routed.card.render(rawPath, routed.target, undefined, undefined, _options, uiTheme);
272
331
  if (isReadableUrlPath(rawPath)) {
273
332
  return renderReadUrlCall({ path: rawPath, raw: args.raw }, _options, uiTheme);
274
333
  }
@@ -296,6 +355,9 @@ export const readToolRenderer = {
296
355
  const urlDetails = result.details as ReadUrlToolDetails | undefined;
297
356
  const baseRawPathForKind =
298
357
  typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
358
+ const routed = readUrlCard(baseRawPathForKind, result.details);
359
+ if (routed)
360
+ return routed.card.render(baseRawPathForKind, routed.target, result, result.details, options, uiTheme);
299
361
  if (urlDetails?.kind === "url" || isReadableUrlPath(baseRawPathForKind)) {
300
362
  return renderReadUrlResult(
301
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
+ }