@oh-my-pi/pi-tui 18.14.2 → 18.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +64 -50
  2. package/README.md +1 -1
  3. package/dist/types/app-keybindings.d.ts +5 -0
  4. package/dist/types/apps/session-picker.d.ts +5 -3
  5. package/dist/types/chat/bash-execution.d.ts +4 -1
  6. package/dist/types/chat/eval-execution.d.ts +2 -0
  7. package/dist/types/chat/execution-shared.d.ts +14 -8
  8. package/dist/types/chat/transcript-render-helpers.d.ts +8 -0
  9. package/dist/types/chrome/dynamic-border.d.ts +6 -0
  10. package/dist/types/chrome/transcript-status.d.ts +2 -2
  11. package/dist/types/components/editor.d.ts +15 -6
  12. package/dist/types/components/loader.d.ts +2 -0
  13. package/dist/types/keybinding-matchers.d.ts +7 -0
  14. package/dist/types/overlays/btw-history-panel.d.ts +13 -0
  15. package/dist/types/overlays/btw-history.d.ts +24 -0
  16. package/dist/types/overlays/btw-inject.d.ts +22 -0
  17. package/dist/types/overlays/btw-panel.d.ts +7 -0
  18. package/dist/types/overlays/hook-editor.d.ts +2 -0
  19. package/dist/types/overlays/jobs-panel.d.ts +4 -0
  20. package/dist/types/overlays/session-selector.d.ts +24 -3
  21. package/dist/types/render/render-utils.d.ts +21 -1
  22. package/dist/types/status-line/startup.d.ts +0 -15
  23. package/dist/types/terminal-capabilities.d.ts +1 -1
  24. package/dist/types/theme/symbols.d.ts +3 -1
  25. package/dist/types/theme/theme-class.d.ts +1 -0
  26. package/dist/types/tools/grouped-file-output.d.ts +76 -8
  27. package/dist/types/tools/hashline-format.d.ts +5 -0
  28. package/dist/types/tools/native-view.d.ts +2 -0
  29. package/dist/types/tools/output-meta.d.ts +27 -0
  30. package/dist/types/tools/read.d.ts +10 -0
  31. package/dist/types/tools/wait.d.ts +2 -0
  32. package/dist/types/utils.d.ts +4 -0
  33. package/package.json +9 -9
  34. package/src/app-keybindings.ts +5 -0
  35. package/src/apps/session-picker.ts +7 -3
  36. package/src/chat/bash-execution.ts +20 -9
  37. package/src/chat/chat-transcript-builder.ts +5 -2
  38. package/src/chat/eval-execution.ts +14 -7
  39. package/src/chat/execution-shared.ts +39 -18
  40. package/src/chat/read-tool-group.ts +56 -36
  41. package/src/chat/transcript-render-helpers.ts +52 -6
  42. package/src/chrome/dynamic-border.ts +28 -2
  43. package/src/chrome/transcript-status.ts +5 -3
  44. package/src/components/editor.ts +123 -24
  45. package/src/components/key-value-list.ts +9 -9
  46. package/src/components/loader.ts +7 -0
  47. package/src/components/section.ts +1 -9
  48. package/src/components/table.ts +1 -9
  49. package/src/keybinding-matchers.ts +13 -0
  50. package/src/overlays/btw-history-panel.ts +98 -14
  51. package/src/overlays/btw-history.ts +65 -0
  52. package/src/overlays/btw-inject.ts +91 -0
  53. package/src/overlays/btw-panel.ts +53 -12
  54. package/src/overlays/hook-editor.ts +6 -0
  55. package/src/overlays/jobs-panel.ts +45 -19
  56. package/src/overlays/model-browser.ts +65 -8
  57. package/src/overlays/session-selector.ts +131 -26
  58. package/src/overlays/usage-dashboard.ts +2 -2
  59. package/src/render/code-cell.ts +1 -1
  60. package/src/render/file-list.ts +4 -7
  61. package/src/render/render-utils.ts +36 -6
  62. package/src/setup/scenes/glyph.ts +1 -1
  63. package/src/status-line/startup.ts +36 -70
  64. package/src/terminal-capabilities.ts +5 -0
  65. package/src/theme/symbols.ts +16 -9
  66. package/src/theme/theme-class.ts +3 -0
  67. package/src/tmux.ts +1 -0
  68. package/src/tools/agent-tree.ts +13 -11
  69. package/src/tools/ask.ts +13 -6
  70. package/src/tools/ast-edit.ts +48 -93
  71. package/src/tools/ast-grep.ts +22 -66
  72. package/src/tools/bash.ts +7 -0
  73. package/src/tools/default-renderer.ts +1 -1
  74. package/src/tools/edit.ts +50 -43
  75. package/src/tools/fetch.ts +2 -1
  76. package/src/tools/find.ts +6 -3
  77. package/src/tools/github.ts +14 -46
  78. package/src/tools/glob.ts +2 -1
  79. package/src/tools/grep.ts +43 -287
  80. package/src/tools/grouped-file-output.ts +250 -64
  81. package/src/tools/hashline-format.ts +5 -0
  82. package/src/tools/json-tree.ts +1 -5
  83. package/src/tools/lsp.ts +53 -25
  84. package/src/tools/native-view.ts +11 -5
  85. package/src/tools/output-meta.ts +68 -4
  86. package/src/tools/proc-render.ts +3 -3
  87. package/src/tools/read.ts +14 -0
  88. package/src/tools/task.ts +34 -77
  89. package/src/tools/vibe.ts +4 -4
  90. package/src/tools/wait.ts +8 -5
  91. package/src/tools/write.ts +17 -27
  92. package/src/tools/yield.ts +1 -1
  93. package/src/utils.ts +10 -0
@@ -10,9 +10,15 @@
10
10
  import { Loader } from "../components/loader";
11
11
  import { Text } from "../components/text";
12
12
  import { Container, type TUI } from "../tui";
13
- import { getSymbolTheme, theme } from "../theme/theme";
13
+ import { theme } from "../theme/theme";
14
14
  import type { OutputArtifactError } from "../tools/streaming-output";
15
- import { formatArtifactErrorNotice, formatTruncationMetaNotice, type TruncationMeta } from "../tools/output-meta";
15
+ import {
16
+ formatArtifactErrorNotice,
17
+ formatMinimizedMeta,
18
+ formatTruncationMetaNotice,
19
+ type MinimizedMeta,
20
+ type TruncationMeta,
21
+ } from "../tools/output-meta";
16
22
  import { DynamicBorder } from "../chrome/dynamic-border";
17
23
  import { Ellipsis, truncateToWidth, visibleWidth } from "../utils";
18
24
  import { interruptKey } from "../chrome/keybinding-hints";
@@ -46,14 +52,15 @@ export type ExecutionColorKey = "dim" | "bashMode" | "pythonMode";
46
52
  /**
47
53
  * Build the spacer + top border + content container + bottom border scaffold
48
54
  * that bash and eval execution components share. The caller appends the
49
- * header (command vs `>>>` prompt) and the returned loader to
50
- * `contentContainer` so per-mode order is preserved.
55
+ * header (command vs `>>>` prompt) to `contentContainer` and keeps the run's
56
+ * state in `bottomBorder` ({@link executionStatusLabel}); the returned loader
57
+ * animates that label rather than taking a row of its own.
51
58
  */
52
59
  export function buildExecutionFrame(
53
60
  parent: Container,
54
61
  ui: TUI,
55
62
  colorKey: ExecutionColorKey,
56
- ): { contentContainer: Container; loader: Loader } {
63
+ ): { contentContainer: Container; loader: Loader; bottomBorder: DynamicBorder } {
57
64
  const borderColor = (str: string) => theme.fg(colorKey, str);
58
65
 
59
66
  parent.addChild(new DynamicBorder(borderColor));
@@ -66,23 +73,35 @@ export function buildExecutionFrame(
66
73
  spinner => theme.fg(colorKey, spinner),
67
74
  text => theme.fg("muted", text),
68
75
  `Running… (${interruptKey()} to cancel)`,
69
- getSymbolTheme().spinnerFrames,
76
+ // The status spinner, as on a running tool card's header.
77
+ theme.spinnerFrames,
70
78
  );
71
79
 
72
- parent.addChild(new DynamicBorder(borderColor));
73
- return { contentContainer, loader };
80
+ const bottomBorder = new DynamicBorder(borderColor);
81
+ bottomBorder.setLabel(() => loader.inlineText());
82
+ parent.addChild(bottomBorder);
83
+ return { contentContainer, loader, bottomBorder };
84
+ }
85
+
86
+ /** The bottom-border marker of a settled run: `exit N` for a failure, `cancelled` for a cancel. */
87
+ export function executionStatusLabel(
88
+ status: Exclude<ExecutionStatus, "running">,
89
+ exitCode: number | undefined,
90
+ ): string | undefined {
91
+ if (status === "cancelled") return theme.fg("warning", "cancelled");
92
+ if (status === "error") return theme.fg("error", `exit ${exitCode}`);
93
+ return undefined;
74
94
  }
75
95
 
76
96
  /**
77
- * Build the post-run status block (hidden-line hint, exit/cancel marker,
78
- * truncation notice). Returns undefined when there is nothing to display so
79
- * callers can skip appending a stray Text child.
97
+ * Build the post-run status block (hidden-line hint, truncation notice).
98
+ * Returns undefined when there is nothing to display so callers can skip
99
+ * appending a stray Text child.
80
100
  */
81
101
  export function buildStatusFooter(opts: {
82
- status: ExecutionStatus;
83
- exitCode: number | undefined;
84
102
  truncation: TruncationMeta | undefined;
85
103
  artifactError?: OutputArtifactError;
104
+ minimized?: MinimizedMeta;
86
105
  hiddenLineCount: number;
87
106
  /** Suppress the "… N more lines" hint (used when sixel passthrough renders the full output). */
88
107
  suppressHiddenCount?: boolean;
@@ -92,17 +111,15 @@ export function buildStatusFooter(opts: {
92
111
  if (opts.hiddenLineCount > 0 && !opts.suppressHiddenCount) {
93
112
  parts.push(theme.fg("dim", `… ${opts.hiddenLineCount} more lines (${expandKeyHint()} to expand)`));
94
113
  }
95
- if (opts.status === "cancelled") {
96
- parts.push(theme.fg("warning", "(cancelled)"));
97
- } else if (opts.status === "error") {
98
- parts.push(theme.fg("error", `(exit ${opts.exitCode})`));
99
- }
100
114
  if (opts.truncation) {
101
115
  parts.push(theme.fg("warning", formatTruncationMetaNotice(opts.truncation)));
102
116
  }
103
117
  if (opts.artifactError) {
104
118
  parts.push(theme.fg("warning", formatArtifactErrorNotice(opts.artifactError)));
105
119
  }
120
+ if (opts.minimized) {
121
+ parts.push(theme.fg("dim", `Minimized: ${formatMinimizedMeta(opts.minimized)}`));
122
+ }
106
123
 
107
124
  if (parts.length === 0) return undefined;
108
125
  return new Text(`\n${parts.join("\n")}`, 1, 0);
@@ -133,6 +150,7 @@ export interface ExecutionCardInput {
133
150
  readonly exitCode: number | undefined;
134
151
  readonly truncation: TruncationMeta | undefined;
135
152
  readonly artifactError?: OutputArtifactError;
153
+ readonly minimized?: MinimizedMeta;
136
154
  }
137
155
 
138
156
  /**
@@ -170,6 +188,7 @@ export function describeExecutionCard(input: ExecutionCardInput): NativeNode {
170
188
  if (input.status === "cancelled") note("(cancelled)", "warning");
171
189
  else if (input.status === "error") note(`(exit ${input.exitCode})`, "error");
172
190
  if (input.truncation) note(formatTruncationMetaNotice(input.truncation), "warning");
191
+ if (input.minimized) note(`Minimized: ${formatMinimizedMeta(input.minimized)}`, "muted");
173
192
  if (input.artifactError) note(formatArtifactErrorNotice(input.artifactError), "warning");
174
193
  if (notes.length > 0) children.push(text(notes, { wrap: "word", key: "notes" }));
175
194
  return card(
@@ -207,6 +226,7 @@ export interface ExecutionToolInput {
207
226
  readonly exitCode: number | undefined;
208
227
  readonly truncation: TruncationMeta | undefined;
209
228
  readonly artifactError?: OutputArtifactError;
229
+ readonly minimized?: MinimizedMeta;
210
230
  }
211
231
 
212
232
  const EXECUTION_RUN_STATE: Record<ExecutionStatus, RunState> = {
@@ -244,6 +264,7 @@ export function describeExecutionTool(input: ExecutionToolInput): NativeNode {
244
264
  state,
245
265
  exitCode: input.exitCode,
246
266
  elapsedMs: ms,
267
+ facts: input.minimized ? [`Minimized ${formatMinimizedMeta(input.minimized)}`] : undefined,
247
268
  meta: { truncation: input.truncation, artifactError: input.artifactError },
248
269
  }),
249
270
  ],
@@ -3,7 +3,7 @@ import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai";
3
3
  import { type Component } from "../tui";
4
4
  import { Container } from "../tui";
5
5
  import { Text } from "../components/text";
6
- import { getLanguageFromPath, theme } from "../theme";
6
+ import { getLanguageFromPath, theme, type ThemeColor } from "../theme";
7
7
  import { parseLineRanges, selectorLineRanges } from "../tools/line-ranges";
8
8
  import {
9
9
  type ReadRenderArgs,
@@ -12,8 +12,15 @@ import {
12
12
  readResultIsMarkdown,
13
13
  readSourceFsPath,
14
14
  splitPathAndSel,
15
+ splitReadQuery,
15
16
  } from "../tools/read";
16
- import { PREVIEW_LIMITS, shortenPath } from "../render/render-utils";
17
+ import {
18
+ FILE_NAME_COLOR,
19
+ formatStatusIcon,
20
+ formatStyledPath,
21
+ PREVIEW_LIMITS,
22
+ shortenPath,
23
+ } from "../render/render-utils";
17
24
  import { fileHyperlink, renderCodeCell, renderMarkdownCell } from "../render";
18
25
  import { wrapTreeLine } from "../render/hanging-wrap";
19
26
  import { wrapTextWithAnsi } from "../utils";
@@ -21,7 +28,7 @@ import { canonicalizeMessage } from "./thinking-display";
21
28
  import { internalUrlSchemeSpec, splitUrlScheme } from "../tools/url-scheme-host";
22
29
  import type { ToolExecutionHandle } from "./tool-execution";
23
30
  import { formatUsageRow } from "../overlays/usage-row";
24
- import { formatCount, sanitizeText, sanitizeTextDeep } from "@oh-my-pi/pi-utils";
31
+ import { formatCount, isUrlLikePath, sanitizeText, sanitizeTextDeep } from "@oh-my-pi/pi-utils";
25
32
  import type { TspCardStatus, TspSpan, TspText } from "@oh-my-pi/pi-wire";
26
33
  import type { NativeToolHead } from "../tools/renderer";
27
34
  import { card, code, keyed, md, node, span, text, withHidden } from "../native/describe";
@@ -34,7 +41,7 @@ import {
34
41
  } from "../native/node";
35
42
  import { plainText } from "../native/spans";
36
43
  import { Memo } from "../native/memo";
37
- import { fileHref, fileRow, inlineErrorView } from "../tools/native-view";
44
+ import { fileHref, fileRow, inlineErrorView, urlHref } from "../tools/native-view";
38
45
 
39
46
  /**
40
47
  * Extract the read call's target path. `path` is the canonical arg; `file_path`
@@ -158,6 +165,21 @@ const COLLAPSED_PREVIEW_LINES = PREVIEW_LIMITS.OUTPUT_COLLAPSED;
158
165
  */
159
166
  const TRIMMED_BODY_PREVIEW = { lines: 1000 } as const;
160
167
 
168
+ /** How a read path is painted: its path (`isUrl` for a whole URL) and its selectors' color. */
169
+ interface PathPaint {
170
+ path(filePath: string, isUrl: boolean): string;
171
+ selectors: ThemeColor;
172
+ }
173
+
174
+ /** A `Read <path>` header line keeps the accent subject color. */
175
+ const HEADER_PATH_PAINT: PathPaint = { path: filePath => theme.fg("accent", filePath), selectors: "accent" };
176
+
177
+ /** Group rows share the tool-body path style with muted selectors. */
178
+ const ROW_PATH_PAINT: PathPaint = {
179
+ path: (filePath, isUrl) => (isUrl ? theme.fg(FILE_NAME_COLOR, filePath) : formatStyledPath(filePath, theme)),
180
+ selectors: "muted",
181
+ };
182
+
161
183
  /** Native item tone per read status; successful reads stay neutral. */
162
184
  const READ_STATUS_TONE: Record<ReadEntry["status"], "pending" | "warning" | "error" | undefined> = {
163
185
  success: undefined,
@@ -695,7 +717,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
695
717
  return {
696
718
  target: filePath ? `${filePath}${split.sel ? `:${split.sel}` : ""}` : "…",
697
719
  targetKind: "path",
698
- href: fileHref(linkPathForTargets(row.targets)),
720
+ href: fileHref(linkPathForTargets(row.targets)) ?? urlHref(split.path),
699
721
  meta: correctedFrom ? [`corrected from ${shortenPath(correctedFrom)}`] : undefined,
700
722
  badges: conflicts
701
723
  ? [{ text: `${conflicts} conflict${conflicts === 1 ? "" : "s"}`, tone: "warning" }]
@@ -718,7 +740,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
718
740
  : undefined;
719
741
  return fileRow(shortenPath(split.sel ? split.path : row.targetPath), {
720
742
  range: split.sel ? `:${split.sel}` : undefined,
721
- href: fileHref(linkPathForTargets(row.targets)),
743
+ href: fileHref(linkPathForTargets(row.targets)) ?? urlHref(split.path),
722
744
  icon: status === "error" ? "x-circle" : undefined,
723
745
  tone: READ_STATUS_TONE[status],
724
746
  chip: conflicts ? { text: `${conflicts} conflict${conflicts === 1 ? "" : "s"}` } : undefined,
@@ -914,8 +936,8 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
914
936
  if (displayRows.length === 1) {
915
937
  const row = displayRows[0]!;
916
938
  if (!this.#shouldRenderPreviewRow(row)) {
917
- const statusSymbol = this.#formatStatus(this.#statusForTargets(row.targets));
918
- const pathDisplay = this.#formatRowPath(row);
939
+ const statusSymbol = formatStatusIcon(this.#statusForTargets(row.targets), theme);
940
+ const pathDisplay = this.#formatRowPath(row, HEADER_PATH_PAINT);
919
941
  const lines = [` ${statusSymbol} ${theme.fg("toolTitle", theme.bold("Read"))} ${pathDisplay}`.trimEnd()];
920
942
  const usageRows = this.#usageRowsBySummaryRow(displayRows).get(0) ?? [];
921
943
  this.#appendUsageRows(lines, usageRows, " ");
@@ -1080,16 +1102,17 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
1080
1102
 
1081
1103
  #formatRow(row: ReadSummaryRow): string {
1082
1104
  const status = this.#statusForTargets(row.targets);
1083
- const statusPrefix = status === "success" ? "" : `${this.#formatStatus(status)} `;
1105
+ const statusPrefix = status === "success" ? "" : `${formatStatusIcon(status, theme)} `;
1084
1106
  return `${statusPrefix}${this.#formatRowPath(row)}`;
1085
1107
  }
1086
1108
 
1087
- #formatRowPath(row: ReadSummaryRow): string {
1088
- return this.#formatPathValue(row.targetPath, {
1109
+ #formatRowPath(row: ReadSummaryRow, paint = ROW_PATH_PAINT): string {
1110
+ return this.#formatPathValue(row.targetPath, paint, {
1089
1111
  correctedFrom: this.#correctedFromForTargets(row.targets),
1090
1112
  conflictCount: this.#conflictCountForTargets(row.targets),
1091
1113
  line: firstSelectorLineForTargets(row.targets),
1092
1114
  linkPath: linkPathForTargets(row.targets),
1115
+ basePath: row.basePath,
1093
1116
  });
1094
1117
  }
1095
1118
 
@@ -1137,25 +1160,35 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
1137
1160
 
1138
1161
  #formatPathValue(
1139
1162
  value: string,
1140
- options: { correctedFrom?: string; conflictCount?: number; line?: number; linkPath?: string } = {},
1163
+ paint: PathPaint,
1164
+ options: {
1165
+ correctedFrom?: string;
1166
+ conflictCount?: number;
1167
+ line?: number;
1168
+ linkPath?: string;
1169
+ /** The path `value` selects from; merged selectors (`1-5,…,9`) do not parse back out of it. */
1170
+ basePath?: string;
1171
+ } = {},
1141
1172
  ): string {
1142
- const split = splitPathAndSel(value);
1143
- const selectorSuffix = split.sel ? `:${split.sel}` : "";
1144
- const baseValue = split.sel ? split.path : value;
1145
- const filePath = shortenPath(baseValue);
1146
- let pathDisplay = filePath ? theme.fg("accent", filePath) : theme.fg("toolOutput", "…");
1173
+ const { basePath } = options;
1174
+ const split =
1175
+ basePath !== undefined && value.startsWith(`${basePath}:`)
1176
+ ? { path: basePath, sel: value.slice(basePath.length + 1) }
1177
+ : splitPathAndSel(value);
1178
+ // `?q=…` queries read like selectors: they follow the path, outside its link.
1179
+ const target = splitReadQuery(split.sel ? split.path : value);
1180
+ const filePath = shortenPath(target.path);
1181
+ const selectors = split.sel ? `${target.query ?? ""}:${split.sel}` : (target.query ?? "");
1182
+ let pathDisplay = filePath ? paint.path(filePath, isUrlLikePath(target.path)) : theme.fg("toolOutput", "…");
1147
1183
  if (filePath && options.linkPath) {
1148
1184
  const linkOptions = options.line !== undefined ? { line: options.line } : undefined;
1149
1185
  pathDisplay = fileHyperlink(options.linkPath, pathDisplay, linkOptions);
1150
1186
  }
1151
- if (selectorSuffix) {
1152
- pathDisplay += theme.fg("accent", selectorSuffix);
1153
- }
1187
+ if (selectors) pathDisplay += theme.fg(paint.selectors, selectors);
1154
1188
  if (options.correctedFrom) {
1155
1189
  pathDisplay += theme.fg("dim", ` (corrected from ${shortenPath(options.correctedFrom)})`);
1156
1190
  }
1157
- pathDisplay += this.#formatConflictBadge(options.conflictCount);
1158
- return pathDisplay;
1191
+ return pathDisplay + this.#formatConflictBadge(options.conflictCount);
1159
1192
  }
1160
1193
 
1161
1194
  #formatConflictBadge(conflictCount: number | undefined): string {
@@ -1174,7 +1207,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
1174
1207
  const lang = getLanguageFromPath(split.path);
1175
1208
  const pathValue = shortenPath(entry.path);
1176
1209
  const pathDisplay = pathValue
1177
- ? this.#formatPathValue(entry.path, {
1210
+ ? this.#formatPathValue(entry.path, HEADER_PATH_PAINT, {
1178
1211
  correctedFrom: entry.correctedFrom,
1179
1212
  conflictCount: entry.conflictCount,
1180
1213
  line: firstSelectorLine(split.sel),
@@ -1252,17 +1285,4 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
1252
1285
  #shouldRenderPreview(entry: ReadEntry): boolean {
1253
1286
  return this.#showContentPreview && entry.contentText !== undefined;
1254
1287
  }
1255
-
1256
- #formatStatus(status: ReadEntry["status"]): string {
1257
- if (status === "success") {
1258
- return theme.fg("text", theme.status.enabled);
1259
- }
1260
- if (status === "warning") {
1261
- return theme.fg("warning", theme.status.warning);
1262
- }
1263
- if (status === "error") {
1264
- return theme.fg("error", theme.status.error);
1265
- }
1266
- return theme.fg("dim", theme.status.pending);
1267
- }
1268
1288
  }
@@ -13,11 +13,18 @@ import type { JobSnapshot } from "../tools/wait";
13
13
  import type { DaemonSnapshot } from "../tools/daemon";
14
14
  import { type CustomMessage, type FileMentionMessage, resolveAbortLabel, shouldRenderAbortReason } from "./messages";
15
15
  import { createIrcMessageCard } from "../tools/wait";
16
- import { formatArtifactErrorNotice, type OutputMeta } from "../tools/output-meta";
16
+ import {
17
+ formatArtifactErrorNotice,
18
+ formatMinimizedMeta,
19
+ type MinimizedMeta,
20
+ type OutputMeta,
21
+ } from "../tools/output-meta";
17
22
  import type { OutputArtifactError } from "../tools/streaming-output";
18
- import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../render/render-utils";
23
+ import { formatStyledPath, replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../render/render-utils";
19
24
  import { canonicalizeMessage } from "./thinking-display";
20
25
  import { ToolActivityContainer } from "../chrome/tool-activity";
26
+ import { CustomMessageComponent } from "./custom-message";
27
+ import type { MessageRenderer } from "./extension-types";
21
28
  import { type TranscriptBlock } from "../chrome/transcript-container";
22
29
  import { TranscriptStatusBlock, type TranscriptStatusRow } from "../chrome/transcript-status";
23
30
  import { theme } from "../theme";
@@ -65,7 +72,7 @@ export function buildAsyncResultBlock(message: CustomOrHookMessage): ToolActivit
65
72
  const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined;
66
73
  rows.push({
67
74
  parts: [
68
- { text: `${theme.status.done} Background job completed`, color: "success" },
75
+ { text: `${theme.status.success} Background job completed`, color: "success" },
69
76
  { text: `[${type}]`, color: "dim" },
70
77
  { text: jobId, color: "accent" },
71
78
  duration ? { text: `(${duration})`, color: "dim" } : undefined,
@@ -76,17 +83,56 @@ export function buildAsyncResultBlock(message: CustomOrHookMessage): ToolActivit
76
83
  },
77
84
  });
78
85
  if (job.meta?.artifactError) rows.push(artifactErrorRow(job.meta.artifactError));
86
+ if (job.meta?.minimized) rows.push(minimizedRow(job.meta.minimized));
79
87
  }
80
88
  if (details?.meta?.artifactError) rows.push(artifactErrorRow(details.meta.artifactError));
81
89
  return new ToolActivityContainer(new TranscriptStatusBlock(rows));
82
90
  }
83
91
 
92
+ /** Hosts an extension renderer; relays `display.hideToolActivity` to the nested built-in fallback block. */
93
+ class AsyncResultCard extends CustomMessageComponent {
94
+ readonly #block: ToolActivityContainer;
95
+
96
+ constructor(message: CustomOrHookMessage, renderer: MessageRenderer, block: ToolActivityContainer) {
97
+ super(message as CustomMessage<unknown>, (delegate, options, rendererTheme) => {
98
+ try {
99
+ return renderer(delegate, options, rendererTheme) ?? block;
100
+ } catch {
101
+ return block;
102
+ }
103
+ });
104
+ this.#block = block;
105
+ }
106
+
107
+ setToolActivityVisible(visible: boolean): void {
108
+ this.#block.setToolActivityVisible(visible);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * The `async-result` card for both transcript surfaces: a registered renderer owns it (#14131);
114
+ * the built-in block covers no renderer, a declining renderer, and a throwing one.
115
+ */
116
+ export function buildAsyncResultComponent(
117
+ message: CustomOrHookMessage,
118
+ renderer: MessageRenderer | undefined,
119
+ ): Component & { setExpanded(expanded: boolean): void } {
120
+ const block = buildAsyncResultBlock(message);
121
+ return renderer ? new AsyncResultCard(message, renderer, block) : block;
122
+ }
123
+
84
124
  /** The warning row under a background job whose output artifact could not be saved. */
85
125
  function artifactErrorRow(error: OutputArtifactError): TranscriptStatusRow {
86
126
  const notice = formatArtifactErrorNotice(error);
87
127
  return { parts: [{ text: notice, color: "warning" }], native: { tone: "warning", message: [span(notice)] } };
88
128
  }
89
129
 
130
+ /** The dim row under a background job whose output the minimizer shortened. */
131
+ function minimizedRow(minimized: MinimizedMeta): TranscriptStatusRow {
132
+ const fact = `Minimized: ${formatMinimizedMeta(minimized)}`;
133
+ return { parts: [{ text: fact, color: "dim" }], native: { tone: "info", message: [span(fact, "dim")] } };
134
+ }
135
+
90
136
  /**
91
137
  * Native notice spans for a row's trailing facts (kind, id, duration): each
92
138
  * after a dim ` · `, absent ones skipped.
@@ -106,7 +152,7 @@ export function buildLaunchCompletionBlock(message: CustomOrHookMessage): ToolAc
106
152
  const daemons = details?.daemons ?? [];
107
153
  if (daemons.length === 0 && typeof message.content === "string") {
108
154
  rows.push({
109
- parts: [{ text: `${theme.status.done} ${message.content}`, color: "dim" }],
155
+ parts: [{ text: `${theme.status.success} ${message.content}`, color: "dim" }],
110
156
  native: { tone: "success", message: [span(message.content)] },
111
157
  });
112
158
  }
@@ -122,7 +168,7 @@ export function buildLaunchCompletionBlock(message: CustomOrHookMessage): ToolAc
122
168
  parts: [
123
169
  failed
124
170
  ? { text: `${theme.status.error} ${label}`, color: "error" }
125
- : { text: `${theme.status.done} ${label}`, color: "success" },
171
+ : { text: `${theme.status.success} ${label}`, color: "success" },
126
172
  { text: daemon.name, color: "accent" },
127
173
  exit ? { text: `(${exit})`, color: "dim" } : undefined,
128
174
  duration ? { text: `(${duration})`, color: "dim" } : undefined,
@@ -203,7 +249,7 @@ export function buildFileMentionBlock(files: FileMentionMessage["files"], indent
203
249
  parts: [
204
250
  { text: theme.tree.last, color: "dim" },
205
251
  { text: "Read", color: "muted" },
206
- { text: file.path, color: "accent" },
252
+ { text: formatStyledPath(file.path, theme) },
207
253
  { text: `(${detail})`, color: "dim" },
208
254
  ],
209
255
  indent,
@@ -1,4 +1,5 @@
1
1
  import type { Component } from "../tui";
2
+ import { truncateToWidth, visibleWidth } from "../utils";
2
3
  import { fgOrPlain, theme } from "../theme/index";
3
4
  import type { NativeNode } from "../native/node";
4
5
  import { EMPTY_NODE } from "../native/describe";
@@ -13,7 +14,9 @@ import { EMPTY_NODE } from "../native/describe";
13
14
  */
14
15
  export class DynamicBorder implements Component {
15
16
  #color: (str: string) => string;
17
+ #label: string | (() => string) | undefined;
16
18
  #cachedWidth = -1;
19
+ #cachedLabel: string | undefined;
17
20
  #cachedLines: string[] | undefined;
18
21
 
19
22
  constructor(color: (str: string) => string = str => fgOrPlain("border", str)) {
@@ -25,19 +28,42 @@ export class DynamicBorder implements Component {
25
28
  this.#cachedLines = undefined;
26
29
  }
27
30
 
31
+ /**
32
+ * Pre-styled text set into the rule after a short lead (`─── label ────`);
33
+ * undefined draws a plain rule. A function label is re-read every render,
34
+ * for live text such as a spinner.
35
+ */
36
+ setLabel(label: string | (() => string) | undefined): void {
37
+ if (label === this.#label) return;
38
+ this.#label = label;
39
+ this.invalidate();
40
+ }
41
+
28
42
  /** Native terminals draw their own chrome; a border rule describes nothing. */
29
43
  describe(): NativeNode {
30
44
  return EMPTY_NODE;
31
45
  }
32
46
 
33
47
  render(width: number): readonly string[] {
34
- if (this.#cachedLines && this.#cachedWidth === width) {
48
+ const label = typeof this.#label === "function" ? this.#label() : this.#label;
49
+ if (this.#cachedLines && this.#cachedWidth === width && this.#cachedLabel === label) {
35
50
  return this.#cachedLines;
36
51
  }
37
52
  const horizontal = typeof theme === "undefined" ? "─" : theme.boxRound.horizontal;
38
- const lines = [this.#color(horizontal.repeat(Math.max(1, width)))];
53
+ const lines = [this.#rule(horizontal, Math.max(1, width), label)];
39
54
  this.#cachedWidth = width;
55
+ this.#cachedLabel = label;
40
56
  this.#cachedLines = lines;
41
57
  return lines;
42
58
  }
59
+
60
+ #rule(horizontal: string, width: number, label: string | undefined): string {
61
+ const lead = horizontal.repeat(3);
62
+ // The label sits between single spaces and leaves at least one rule cell after it.
63
+ const labelRoom = width - visibleWidth(lead) - 3;
64
+ if (!label || labelRoom < 1) return this.#color(horizontal.repeat(width));
65
+ const fitted = ` ${truncateToWidth(label, labelRoom)} `;
66
+ const rest = width - visibleWidth(lead) - visibleWidth(fitted);
67
+ return `${this.#color(lead)}${fitted}${this.#color(horizontal.repeat(rest))}`;
68
+ }
43
69
  }
@@ -6,10 +6,10 @@ import { type ThemeColor, theme } from "../theme";
6
6
  import { TranscriptBlock } from "./transcript-container";
7
7
  import { type NoticeTone, noticeRow } from "./transcript-notice";
8
8
 
9
- /** One themed run of a status row's terminal line. */
9
+ /** One run of a status row's terminal line, in `color` unless the text is already styled. */
10
10
  export interface TranscriptStatusPart {
11
11
  readonly text: string;
12
- readonly color: ThemeColor;
12
+ readonly color?: ThemeColor;
13
13
  }
14
14
 
15
15
  /** A status row in a native terminal: its {@link noticeRow}'s tone, icon and message. */
@@ -41,7 +41,9 @@ export class TranscriptStatusBlock extends TranscriptBlock {
41
41
  constructor(rows: readonly TranscriptStatusRow[]) {
42
42
  super();
43
43
  for (const row of rows) {
44
- const styled = row.parts.flatMap(part => (part ? [theme.fg(part.color, part.text)] : []));
44
+ const styled = row.parts.flatMap(part =>
45
+ part ? [part.color ? theme.fg(part.color, part.text) : part.text] : [],
46
+ );
45
47
  this.addChild(new Text(styled.join(" "), row.indent ?? 1, 0));
46
48
  }
47
49
  this.#native = col(