@ian-pascoe/pi-termctrl 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ps-panel.ts CHANGED
@@ -1,12 +1,27 @@
1
- import type { ExtensionContext, KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ DynamicBorder,
3
+ keyText,
4
+ type ExtensionContext,
5
+ type KeybindingsManager,
6
+ type Theme,
7
+ } from "@earendil-works/pi-coding-agent";
2
8
  import {
3
9
  matchesKey,
4
- truncateToWidth,
5
10
  visibleWidth,
11
+ wrapTextWithAnsi,
6
12
  type Component,
7
13
  type OverlayHandle,
8
14
  type TUI,
9
15
  } from "@earendil-works/pi-tui";
16
+ import {
17
+ clipPlain,
18
+ footerStatus,
19
+ hintLine,
20
+ joinInline,
21
+ noticeText,
22
+ statusMark,
23
+ type StatusKind,
24
+ } from "@ian-pascoe/pi-utils/ui";
10
25
  import { formatDuration } from "./exit-notification.js";
11
26
  import type { TermctrlEntry, TermctrlRegistry } from "./termctrl-registry.js";
12
27
 
@@ -40,6 +55,13 @@ function singleLine(text: string): string {
40
55
  return text.replaceAll(/\s+/gu, " ").trim();
41
56
  }
42
57
 
58
+ /** The Status Mark of an entry: running, ended cleanly, ended by a signal, or failed. */
59
+ function markKind(entry: TermctrlEntry): StatusKind {
60
+ if (entry.state === "running") return "active";
61
+ if (entry.exit?.signal !== null && entry.exit?.signal !== undefined) return "stopped";
62
+ return entry.exit?.code === 0 ? "done" : "failed";
63
+ }
64
+
43
65
  /** The live `/ps` overlay: every entry in the process, a preview of the selected one, and stop keys. */
44
66
  export class TermctrlPsPanel implements Component {
45
67
  private entries: readonly TermctrlEntry[] = [];
@@ -85,40 +107,54 @@ export class TermctrlPsPanel implements Component {
85
107
  1,
86
108
  Math.min(Math.floor(this.tui.terminal.rows * 0.9), this.tui.terminal.rows - 2 * MARGIN),
87
109
  );
88
- if (width < 8 || height < 6) {
89
- return [truncateToWidth("Esc close · Enlarge terminal", width, "…")];
110
+ const inner = width - 2;
111
+ const help = wrapTextWithAnsi(this.hints(), inner);
112
+ if (width < 8 || height < 8 + help.length) {
113
+ return wrapTextWithAnsi(this.theme.fg("muted", "Enlarge terminal"), width);
90
114
  }
91
- const inner = width - 4;
92
115
  const now = Date.now();
93
116
  const running = this.entries.filter((entry) => entry.state === "running").length;
117
+ const title = joinInline(this.theme, [
118
+ this.theme.fg("accent", this.theme.bold("Terminals and Background jobs")),
119
+ this.theme.fg("muted", `${running} running`),
120
+ ]);
94
121
  const header = [
95
- this.theme.bold(`Terminals and Background jobs · ${running} running`),
96
- this.theme.fg("warning", this.notice),
122
+ ...wrapTextWithAnsi(title, inner),
123
+ ...(this.notice === "" ? [] : wrapTextWithAnsi(this.theme.fg("warning", this.notice), inner)),
97
124
  ];
98
- const help = [this.theme.fg("dim", "↑↓ select · k stop · x remove exited · esc close")];
99
- const listHeight = Math.max(
100
- 1,
101
- Math.min(this.entries.length || 1, Math.floor((height - 6) / 2)),
102
- );
125
+ // Two borders and the blank lines around the title, the list, and the hints.
126
+ const room = height - 6 - header.length - help.length;
127
+ const listHeight = Math.max(1, Math.min(this.entries.length || 1, Math.floor(room / 2)));
103
128
  const rows =
104
129
  this.entries.length === 0
105
130
  ? [this.theme.fg("muted", "Nothing is running.")]
106
131
  : this.visibleRows(listHeight).map((entry) => this.row(entry, inner, now));
107
- const previewHeight = Math.max(0, height - 2 - header.length - rows.length - help.length - 1);
108
- const previewLines = this.previewLines(previewHeight);
132
+ const previewLines = this.previewLines(room - rows.length - 1, inner);
133
+ const border = new DynamicBorder((text) => this.theme.fg("border", text)).render(width);
109
134
  const body = [
135
+ "",
110
136
  ...header,
137
+ "",
111
138
  ...rows,
112
- this.theme.fg("border", "─".repeat(inner)),
113
- ...previewLines,
139
+ ...(previewLines.length === 0 ? [] : ["", ...previewLines]),
140
+ "",
114
141
  ...help,
142
+ "",
115
143
  ];
116
- const border = (text: string) => this.theme.fg("border", text);
117
- const framed = body.map((line) => {
118
- const content = truncateToWidth(line, inner, "…");
119
- return `${border("│")} ${content}${" ".repeat(Math.max(0, inner - visibleWidth(content)))} ${border("│")}`;
120
- });
121
- return [border(`╭${"─".repeat(width - 2)}╮`), ...framed, border(`╰${"─".repeat(width - 2)}╯`)];
144
+ const framed = body.map(
145
+ (line) => ` ${line}${" ".repeat(Math.max(0, inner - visibleWidth(line)))} `,
146
+ );
147
+ return [...border, ...framed, ...border];
148
+ }
149
+
150
+ /** The footer hints, drawn from the injected theme as Pi's selector does. */
151
+ private hints(): string {
152
+ return hintLine(this.theme, [
153
+ { key: "\u2191\u2193", description: "navigate" },
154
+ { key: "k", description: "stop" },
155
+ { key: "x", description: "remove exited" },
156
+ { key: keyText("tui.select.cancel"), description: "close" },
157
+ ]);
122
158
  }
123
159
 
124
160
  invalidate(): void {}
@@ -163,17 +199,15 @@ export class TermctrlPsPanel implements Component {
163
199
  ageOf(entry, now).padStart(6),
164
200
  ]
165
201
  : [entry.id.padEnd(4), stateLabel(entry).padEnd(9)];
166
- const line = `${selected ? ">" : " "} ${columns.join(" ")} ${singleLine(entry.command)}`;
167
- const styled = selected
168
- ? this.theme.fg("accent", line)
169
- : entry.state === "exited"
170
- ? this.theme.fg("muted", line)
171
- : line;
172
- return truncateToWidth(styled, width, "…");
202
+ // Pi's selector marks the selected row with `→ `; the Status Mark follows it.
203
+ const text = clipPlain(`${columns.join(" ")} ${singleLine(entry.command)}`, width - 4);
204
+ const color = selected ? "accent" : entry.state === "exited" ? "muted" : "text";
205
+ const marker = selected ? this.theme.fg("accent", "→ ") : " ";
206
+ return `${marker}${statusMark(this.theme, markKind(entry))} ${this.theme.fg(color, text)}`;
173
207
  }
174
208
 
175
- private previewLines(height: number): string[] {
176
- if (height <= 0) return [];
209
+ private previewLines(height: number, width: number): string[] {
210
+ if (height < 2) return [];
177
211
  const selected = this.entries.find((entry) => entry.id === this.selectedId);
178
212
  if (selected === undefined) return [];
179
213
  const title = this.theme.fg(
@@ -181,7 +215,10 @@ export class TermctrlPsPanel implements Component {
181
215
  selected.kind === "terminal" ? `${selected.id} screen` : `${selected.id} log tail`,
182
216
  );
183
217
  const text = this.preview.replace(/\n+$/u, "");
184
- const lines = text === "" ? [this.theme.fg("muted", "(no output yet)")] : text.split("\n");
218
+ const lines =
219
+ text === ""
220
+ ? [this.theme.fg("muted", "(no output yet)")]
221
+ : text.split("\n").map((line) => this.theme.fg("text", clipPlain(line, width)));
185
222
  return [title, ...lines.slice(-(height - 1))];
186
223
  }
187
224
 
@@ -235,7 +272,7 @@ export class TermctrlPsPanel implements Component {
235
272
  private stopSelected(): void {
236
273
  const entry = this.entries.find((candidate) => candidate.id === this.selectedId);
237
274
  if (entry === undefined || entry.state !== "running") return;
238
- this.notice = `Stopping ${entry.id}…`;
275
+ this.notice = `Stopping ${entry.id}...`;
239
276
  void this.registry.stopByUser(entry.id).then(() => {
240
277
  if (this.disposed) return;
241
278
  this.notice = `Stopped ${entry.id}.`;
@@ -274,7 +311,16 @@ export class TermctrlPsController {
274
311
  ) {
275
312
  const update = () => {
276
313
  const running = registry.runningCount();
277
- context.ui.setStatus(STATUS_KEY, running > 0 ? `${running} running` : undefined);
314
+ context.ui.setStatus(
315
+ STATUS_KEY,
316
+ running > 0
317
+ ? footerStatus(context.ui.theme, {
318
+ mark: "active",
319
+ name: "termctrl",
320
+ value: `${running} running`,
321
+ })
322
+ : undefined,
323
+ );
278
324
  };
279
325
  this.stopFooter = context.hasUI ? registry.onChange(update) : () => {};
280
326
  if (context.hasUI) update();
@@ -322,7 +368,7 @@ export class TermctrlPsController {
322
368
  },
323
369
  )
324
370
  .catch(() => {
325
- this.context.ui.notify("The /ps view failed.", "error");
371
+ this.context.ui.notify(noticeText("Termctrl", "The /ps view failed."), "error");
326
372
  })
327
373
  .finally(() => {
328
374
  this.panel?.dispose();
@@ -13,7 +13,7 @@ import type { TerminalViewport } from "./pi-termctrl-settings.js";
13
13
  /** The `globalThis` key every registry version shares. Its value always satisfies {@link RegistrySlot}. */
14
14
  export const REGISTRY_KEY = Symbol.for("@ian-pascoe/pi-termctrl/registry");
15
15
  /** Bump on any change to the state's shape: a reloaded module must not adopt an older shape. */
16
- export const REGISTRY_VERSION = 5;
16
+ export const REGISTRY_VERSION = 6;
17
17
 
18
18
  /** Live Terminals plus Background jobs allowed across the whole process. */
19
19
  export const LIVE_ENTRY_CAP = 16;
@@ -84,6 +84,11 @@ export interface TerminalEntry extends EntryBase {
84
84
  * last lines before the cursor, or, while the cursor is 0, the log's first line.
85
85
  */
86
86
  logAnchor: readonly string[];
87
+ /**
88
+ * The screen rows the agent last received, which start at `logCursor` in the log; `undefined`
89
+ * marks a row cut from that result. Lines that scroll off unchanged are not reported again.
90
+ */
91
+ seenRows: readonly (string | undefined)[];
87
92
  }
88
93
 
89
94
  /** A `bash` command moved to the background. */
@@ -463,6 +468,7 @@ export class TermctrlRegistry {
463
468
  lastScreen: undefined,
464
469
  logCursor: 0,
465
470
  logAnchor: [],
471
+ seenRows: [],
466
472
  };
467
473
  this.state.entries.set(id, entry);
468
474
  this.syncWatcher();
@@ -0,0 +1,398 @@
1
+ import { Container, Spacer, Text, type Component } from "@earendil-works/pi-tui";
2
+ import {
3
+ appendDurationFooter,
4
+ callDurationFooter,
5
+ COLLAPSED_LINES,
6
+ expandHint,
7
+ joinInline,
8
+ previewBody,
9
+ toolHeader,
10
+ type DurationContext,
11
+ type UiTheme,
12
+ } from "@ian-pascoe/pi-utils/ui";
13
+ import { describeExitStatus, formatDuration } from "./exit-notification.js";
14
+ import type { ListResult, StopResult, TerminalResult, WaitResult } from "./terminal-tools.js";
15
+
16
+ type RenderTheme = Pick<UiTheme, "fg" | "bold">;
17
+
18
+ /** What a result renderer reads from Pi's render context. */
19
+ export interface ResultContext extends DurationContext {
20
+ readonly isError: boolean;
21
+ }
22
+
23
+ /** What a result renderer reads from Pi's render options. */
24
+ export interface ResultOptions {
25
+ readonly expanded: boolean;
26
+ readonly isPartial: boolean;
27
+ }
28
+
29
+ interface RenderedResult<TDetails> {
30
+ readonly content: readonly { readonly type: string; readonly text?: string }[];
31
+ readonly details: TDetails | undefined;
32
+ }
33
+
34
+ /** The widest a typed or sent value gets in a call header before it is cut. */
35
+ const ARGUMENT_WIDTH = 60;
36
+
37
+ function column(...children: Component[]): Container {
38
+ const container = new Container();
39
+ for (const child of children) container.addChild(child);
40
+ return container;
41
+ }
42
+
43
+ function textBlock(lines: readonly string[]): Component {
44
+ return new Text(lines.join("\n"), 0, 0);
45
+ }
46
+
47
+ /** The call row: Pi's header, with the live `Elapsed` footer until a result row takes over. */
48
+ function callRow(
49
+ theme: RenderTheme & Pick<UiTheme, "fg">,
50
+ context: DurationContext,
51
+ header: string,
52
+ ): Component {
53
+ return column(textBlock([header]), callDurationFooter(theme, context));
54
+ }
55
+
56
+ function quoted(value: string): string {
57
+ const characters = Array.from(JSON.stringify(value));
58
+ return characters.length <= ARGUMENT_WIDTH
59
+ ? characters.join("")
60
+ : `${characters.slice(0, ARGUMENT_WIDTH - 3).join("")}...`;
61
+ }
62
+
63
+ function when(condition: boolean, text: string): string | undefined {
64
+ return condition ? text : undefined;
65
+ }
66
+
67
+ function joinArguments(parts: readonly (string | undefined)[]): string | undefined {
68
+ const present = parts.filter((part): part is string => part !== undefined);
69
+ return present.length === 0 ? undefined : present.join(" ");
70
+ }
71
+
72
+ /** Arguments of a `terminal_start` call as they stream in, so every one may be absent. */
73
+ export interface StartCallArguments {
74
+ readonly command?: string;
75
+ readonly cwd?: string;
76
+ readonly wait_ms?: number;
77
+ readonly notify?: boolean;
78
+ }
79
+
80
+ /** `terminal_start` call row: `terminal_start $ command`, as Pi's bash shows its command. */
81
+ export function renderStartCall(
82
+ args: StartCallArguments,
83
+ theme: RenderTheme,
84
+ context: DurationContext,
85
+ ): Component {
86
+ const command = args.command === undefined ? "..." : args.command;
87
+ const extra = joinArguments([
88
+ args.cwd === undefined ? undefined : `in ${args.cwd}`,
89
+ args.wait_ms === undefined ? undefined : `wait ${args.wait_ms}ms`,
90
+ when(args.notify === false, "no notify"),
91
+ ]);
92
+ return callRow(theme, context, toolHeader(theme, "terminal_start", `$ ${command}`, extra));
93
+ }
94
+
95
+ /** Arguments of a `terminal_send` call as they stream in. */
96
+ export interface SendCallArguments {
97
+ readonly id?: string;
98
+ readonly text?: string;
99
+ readonly keys?: readonly string[];
100
+ readonly wait_for_text?: string;
101
+ readonly wait_ms?: number;
102
+ }
103
+
104
+ /** `terminal_send` call row: the Terminal id, then what is typed, pressed and waited for. */
105
+ export function renderSendCall(
106
+ args: SendCallArguments,
107
+ theme: RenderTheme,
108
+ context: DurationContext,
109
+ ): Component {
110
+ const text = args.text ?? "";
111
+ const keys = args.keys ?? [];
112
+ const extra = joinArguments([
113
+ text === "" ? undefined : `type ${quoted(text)}`,
114
+ keys.length === 0 ? undefined : `press ${keys.join(" ")}`,
115
+ args.wait_for_text === undefined ? undefined : `wait_for ${quoted(args.wait_for_text)}`,
116
+ args.wait_ms === undefined ? undefined : `${args.wait_ms}ms`,
117
+ when(text === "" && keys.length === 0 && args.wait_for_text === undefined, "poll"),
118
+ ]);
119
+ return callRow(theme, context, toolHeader(theme, "terminal_send", args.id ?? "...", extra));
120
+ }
121
+
122
+ /** `terminal_stop` call row. */
123
+ export function renderStopCall(
124
+ args: { readonly id?: string },
125
+ theme: RenderTheme,
126
+ context: DurationContext,
127
+ ): Component {
128
+ return callRow(theme, context, toolHeader(theme, "terminal_stop", args.id ?? "..."));
129
+ }
130
+
131
+ /** `terminal_wait` call row: the ids it waits for, or every running entry. */
132
+ export function renderWaitCall(
133
+ args: { readonly ids?: readonly string[]; readonly wait_ms?: number },
134
+ theme: RenderTheme,
135
+ context: DurationContext,
136
+ ): Component {
137
+ const ids = args.ids === undefined || args.ids.length === 0 ? undefined : args.ids.join(" ");
138
+ const extra = args.wait_ms === undefined ? undefined : `${args.wait_ms}ms`;
139
+ return callRow(theme, context, toolHeader(theme, "terminal_wait", ids, extra));
140
+ }
141
+
142
+ /** `terminal_list` takes no arguments. */
143
+ export type ListCallArguments = Readonly<Record<never, never>>;
144
+
145
+ /** `terminal_list` call row. */
146
+ export function renderListCall(
147
+ _args: ListCallArguments,
148
+ theme: RenderTheme,
149
+ context: DurationContext,
150
+ ): Component {
151
+ return callRow(theme, context, toolHeader(theme, "terminal_list"));
152
+ }
153
+
154
+ function contentText(content: RenderedResult<unknown>["content"]): string {
155
+ return content.map((part) => (part.type === "text" ? (part.text ?? "") : "")).join("");
156
+ }
157
+
158
+ /** Pi's result layout, as its bash renderer builds it: a blank line, the body, then the footer. */
159
+ function resultRow(
160
+ theme: RenderTheme,
161
+ context: ResultContext,
162
+ options: ResultOptions,
163
+ body: readonly string[],
164
+ ): Component {
165
+ const container = new Container();
166
+ if (body.length > 0) {
167
+ container.addChild(new Spacer(1));
168
+ container.addChild(new Text(body.join("\n"), 0, 0));
169
+ }
170
+ appendDurationFooter(container, theme, context, { isPartial: options.isPartial });
171
+ return container;
172
+ }
173
+
174
+ function errorRow(
175
+ theme: RenderTheme,
176
+ context: ResultContext,
177
+ options: ResultOptions,
178
+ result: RenderedResult<unknown>,
179
+ ): Component {
180
+ const lines = contentText(result.content).trim().split("\n");
181
+ return resultRow(
182
+ theme,
183
+ context,
184
+ options,
185
+ previewBody(theme, lines, {
186
+ limit: COLLAPSED_LINES.fallback,
187
+ expanded: options.expanded,
188
+ color: "error",
189
+ }),
190
+ );
191
+ }
192
+
193
+ function warning(theme: RenderTheme, text: string): string {
194
+ return theme.fg("warning", `[${text}]`);
195
+ }
196
+
197
+ function splitLines(text: string | undefined): string[] {
198
+ if (text === undefined || text === "") return [];
199
+ return text.replace(/\n+$/u, "").split("\n");
200
+ }
201
+
202
+ function screenLines(screen: string): string[] {
203
+ const lines = splitLines(screen);
204
+ return lines.length === 0 ? ["(blank)"] : lines;
205
+ }
206
+
207
+ /**
208
+ * A Terminal's lines in bash's Collapsed View, the last 5 with the earlier lines counted in the
209
+ * Expand Hint. The Expanded View labels the scrolled-off lines apart from the screen.
210
+ */
211
+ function terminalBody(
212
+ theme: RenderTheme,
213
+ expanded: boolean,
214
+ scrolledOff: readonly string[],
215
+ screen: readonly string[],
216
+ ): string[] {
217
+ const styled = (lines: readonly string[]) => lines.map((line) => theme.fg("toolOutput", line));
218
+ if (!expanded) {
219
+ return previewBody(theme, [...scrolledOff, ...screen], {
220
+ limit: COLLAPSED_LINES.terminal,
221
+ expanded,
222
+ keep: "end",
223
+ });
224
+ }
225
+ if (scrolledOff.length === 0) return styled(screen);
226
+ return [
227
+ theme.fg("muted", "--- scrolled off ---"),
228
+ ...styled(scrolledOff),
229
+ theme.fg("muted", "--- screen ---"),
230
+ ...styled(screen),
231
+ ];
232
+ }
233
+
234
+ function terminalWarnings(
235
+ theme: RenderTheme,
236
+ details: Pick<TerminalResult, "output_missing" | "full_output_path">,
237
+ ): string[] {
238
+ const warnings: string[] = [];
239
+ if (details.output_missing === true) warnings.push(warning(theme, "Earlier output is missing"));
240
+ if (details.full_output_path !== undefined) {
241
+ warnings.push(warning(theme, `Full output: ${details.full_output_path}`));
242
+ }
243
+ return warnings;
244
+ }
245
+
246
+ function exitText(details: Pick<TerminalResult, "state" | "exit_code" | "signal">): string {
247
+ return details.state === "running"
248
+ ? "running"
249
+ : describeExitStatus({ code: details.exit_code ?? null, signal: details.signal ?? null });
250
+ }
251
+
252
+ function isFailedExit(details: Pick<TerminalResult, "state" | "exit_code" | "signal">): boolean {
253
+ return details.state === "exited" && (details.exit_code !== 0 || details.signal !== undefined);
254
+ }
255
+
256
+ /** `terminal_start` and `terminal_send` result: a status line, then the screen like bash output. */
257
+ export function renderTerminalResult(
258
+ result: RenderedResult<TerminalResult>,
259
+ options: ResultOptions,
260
+ theme: RenderTheme,
261
+ context: ResultContext,
262
+ ): Component {
263
+ const details = result.details;
264
+ if (context.isError || details === undefined) return errorRow(theme, context, options, result);
265
+ const status = joinInline(theme, [
266
+ theme.fg(isFailedExit(details) ? "warning" : "muted", `${details.id} ${exitText(details)}`),
267
+ theme.fg("muted", `settled: ${details.settle_reason}`),
268
+ details.changed ? undefined : theme.fg("muted", "screen unchanged"),
269
+ ]);
270
+ const body = terminalBody(
271
+ theme,
272
+ options.expanded,
273
+ splitLines(details.scrolled_off),
274
+ screenLines(details.screen),
275
+ );
276
+ return resultRow(theme, context, options, [status, ...body, ...terminalWarnings(theme, details)]);
277
+ }
278
+
279
+ /** `terminal_stop` result: the stop line, then the final screen or the job's recent output. */
280
+ export function renderStopResult(
281
+ result: RenderedResult<StopResult>,
282
+ options: ResultOptions,
283
+ theme: RenderTheme,
284
+ context: ResultContext,
285
+ ): Component {
286
+ const details = result.details;
287
+ if (context.isError || details === undefined) return errorRow(theme, context, options, result);
288
+ const status = theme.fg("muted", contentText(result.content).split("\n")[0] ?? "");
289
+ const body =
290
+ details.kind === "terminal"
291
+ ? terminalBody(
292
+ theme,
293
+ options.expanded,
294
+ splitLines(details.scrolled_off),
295
+ splitLines(details.screen),
296
+ )
297
+ : terminalBody(theme, options.expanded, [], splitLines(details.output));
298
+ return resultRow(theme, context, options, [status, ...body, ...terminalWarnings(theme, details)]);
299
+ }
300
+
301
+ /** A list in ls's Collapsed View: its first 20 rows and the Expand Hint for the rest. */
302
+ function listRows(theme: RenderTheme, rows: readonly string[], expanded: boolean): string[] {
303
+ const hidden = rows.length - COLLAPSED_LINES.list;
304
+ if (expanded || hidden <= 0) return [...rows];
305
+ return [...rows.slice(0, COLLAPSED_LINES.list), expandHint(theme, hidden)];
306
+ }
307
+
308
+ function entryRow(
309
+ theme: RenderTheme,
310
+ entry: {
311
+ id: string;
312
+ command: string;
313
+ state: string;
314
+ exit_code?: number | undefined;
315
+ signal?: string | undefined;
316
+ },
317
+ ageSeconds: number,
318
+ ): string {
319
+ const state =
320
+ entry.state === "running"
321
+ ? "running"
322
+ : describeExitStatus({ code: entry.exit_code ?? null, signal: entry.signal ?? null });
323
+ return `${theme.fg("accent", entry.id)} ${joinInline(theme, [
324
+ theme.fg("toolOutput", state),
325
+ theme.fg("muted", formatDuration(ageSeconds * 1000)),
326
+ theme.fg("toolOutput", entry.command),
327
+ ])}`;
328
+ }
329
+
330
+ /** `terminal_list` result: one row per Terminal and Background job, like ls. */
331
+ export function renderListResult(
332
+ result: RenderedResult<ListResult>,
333
+ options: ResultOptions,
334
+ theme: RenderTheme,
335
+ context: ResultContext,
336
+ ): Component {
337
+ const details = result.details;
338
+ if (context.isError || details === undefined) return errorRow(theme, context, options, result);
339
+ const rows = [
340
+ ...details.terminals.map((entry) => entryRow(theme, entry, entry.age_seconds)),
341
+ ...details.background_jobs.map((entry) => {
342
+ const row = entryRow(theme, entry, entry.age_seconds);
343
+ return options.expanded ? `${row} ${theme.fg("muted", `(log ${entry.log_path})`)}` : row;
344
+ }),
345
+ ];
346
+ const body =
347
+ rows.length === 0 ? [theme.fg("muted", "(none)")] : listRows(theme, rows, options.expanded);
348
+ return resultRow(theme, context, options, body);
349
+ }
350
+
351
+ const WAIT_REASON_SUMMARY = {
352
+ exited: undefined,
353
+ timeout: "Nothing exited before the wait ended",
354
+ message: "Returned early: a message is waiting",
355
+ aborted: "Wait cancelled",
356
+ nothing_running: "Nothing to wait for",
357
+ } as const satisfies Record<WaitResult["reason"], string | undefined>;
358
+
359
+ /**
360
+ * `terminal_wait` result: what exited and what still runs, one row each. The Expanded View adds
361
+ * each exit's last lines.
362
+ */
363
+ export function renderWaitResult(
364
+ result: RenderedResult<WaitResult>,
365
+ options: ResultOptions,
366
+ theme: RenderTheme,
367
+ context: ResultContext,
368
+ ): Component {
369
+ const details = result.details;
370
+ if (context.isError || details === undefined) return errorRow(theme, context, options, result);
371
+ const summary = WAIT_REASON_SUMMARY[details.reason];
372
+ const rows: string[] = [];
373
+ for (const exit of details.exited) {
374
+ const status = describeExitStatus({
375
+ code: exit.exit_code ?? null,
376
+ signal: exit.signal ?? null,
377
+ });
378
+ rows.push(
379
+ `${theme.fg("accent", exit.id)} ${joinInline(theme, [
380
+ theme.fg("toolOutput", status),
381
+ theme.fg("muted", formatDuration(exit.duration_ms)),
382
+ theme.fg("toolOutput", exit.command),
383
+ ])}`,
384
+ );
385
+ if (options.expanded) {
386
+ for (const line of splitLines(exit.output)) rows.push(` ${theme.fg("toolOutput", line)}`);
387
+ if (exit.log_path !== undefined) rows.push(theme.fg("muted", ` Log: ${exit.log_path}`));
388
+ }
389
+ }
390
+ for (const entry of details.running) {
391
+ rows.push(entryRow(theme, { ...entry, state: "running" }, entry.age_seconds));
392
+ }
393
+ const body = [
394
+ ...(summary === undefined ? [] : [theme.fg("muted", summary)]),
395
+ ...listRows(theme, rows, options.expanded),
396
+ ];
397
+ return resultRow(theme, context, options, body);
398
+ }