@ian-pascoe/pi-termctrl 0.6.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/README.md CHANGED
@@ -189,9 +189,16 @@ the session that started the entry, and wait across `/reload`.
189
189
  `/ps` opens an overlay listing every entry in the Pi process, including those of
190
190
  in-process child agents, with kind, id, owner (`root` or `child`), state and
191
191
  age, plus a live preview of the selected Terminal's screen or Background job's
192
- log tail. Keys: `↑`/`↓` select, `k` stops without confirmation (the agent gets an
193
- Exit notification), `x` removes an exited entry, `Esc` closes. The footer shows
194
- `N running` while anything is live. In RPC mode `/ps` sends a one-line summary;
192
+ log tail. It uses Pi's selector frame, with a Status Mark before each entry
193
+ (`●` running, `✓` exited cleanly, `✗` failed, `■` ended by a signal). Keys:
194
+ `↑`/`↓` select, `k` stops without confirmation (the agent gets an Exit
195
+ notification), `x` removes an exited entry, `Esc` closes. The footer shows
196
+ `● termctrl N running` while anything is live.
197
+
198
+ The `terminal_*` tools render like `bash` in the transcript: a header with the
199
+ tool name and target, the last 5 screen lines (`ctrl+o` expands), and a `Took`
200
+ footer. Exit notifications show one row per exit, marked `✓` for a clean exit and
201
+ `■` otherwise. In RPC mode `/ps` sends a one-line summary;
195
202
  print mode has no UI but keeps the tools and notifications.
196
203
 
197
204
  ## Lifecycle
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-termctrl",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "description": "Interactive Terminals, Background jobs, and a /ps panel for Pi.",
6
6
  "keywords": [
@@ -34,11 +34,12 @@
34
34
  "provenance": true
35
35
  },
36
36
  "dependencies": {
37
+ "@ian-pascoe/pi-utils": "^0.5.0",
37
38
  "@kitlangton/terminal-control": "^1.2.1"
38
39
  },
39
40
  "peerDependencies": {
40
- "@earendil-works/pi-coding-agent": ">=0.99.0",
41
- "@earendil-works/pi-tui": ">=0.99.0",
41
+ "@earendil-works/pi-coding-agent": ">=1.1.0",
42
+ "@earendil-works/pi-tui": ">=1.1.0",
42
43
  "typebox": "*"
43
44
  },
44
45
  "engines": {
@@ -19,6 +19,7 @@ import {
19
19
  type TruncationResult,
20
20
  } from "@earendil-works/pi-coding-agent";
21
21
  import { matchesKey } from "@earendil-works/pi-tui";
22
+ import { noticeText } from "@ian-pascoe/pi-utils/ui";
22
23
  import { Type, type TSchema } from "typebox";
23
24
  import { KILLED_EXIT, TermctrlRegistry } from "./termctrl-registry.js";
24
25
  import type { LineByteLimits } from "./pi-termctrl-settings.js";
@@ -264,7 +265,10 @@ export class RunningBashCalls {
264
265
  try {
265
266
  call.background(this.registry());
266
267
  } catch (error) {
267
- ui.notify(error instanceof Error ? error.message : String(error), "warning");
268
+ ui.notify(
269
+ noticeText("Termctrl", error instanceof Error ? error.message : String(error)),
270
+ "warning",
271
+ );
268
272
  }
269
273
  }
270
274
  }
@@ -1,5 +1,14 @@
1
1
  import type { MessageRenderer } from "@earendil-works/pi-coding-agent";
2
2
  import { Text, type Component } from "@earendil-works/pi-tui";
3
+ import {
4
+ COLLAPSED_LINES,
5
+ customMessageBox,
6
+ expandHint,
7
+ joinInline,
8
+ previewBody,
9
+ statusMark,
10
+ summaryExpandHint,
11
+ } from "@ian-pascoe/pi-utils/ui";
3
12
  import { Type, type Static } from "typebox";
4
13
  import { Value } from "typebox/value";
5
14
  import type { ExitNotice } from "./termctrl-registry.js";
@@ -110,19 +119,55 @@ function messageText(content: RenderedMessage["content"]): string {
110
119
  return content.map((part) => (part.type === "text" ? part.text : "")).join("");
111
120
  }
112
121
 
113
- /** Compact renderer: one line per exit, with the full text when expanded. */
122
+ type MessageTheme = Parameters<ExitNotificationRenderer>[2];
123
+
124
+ function exitRow(theme: MessageTheme, exit: ExitNotificationDetails["exits"][number]): string {
125
+ const done = exit.exit_code === 0 && exit.signal === null;
126
+ const body = (text: string) => theme.fg("customMessageText", text);
127
+ const status = describeExitStatus({ code: exit.exit_code, signal: exit.signal });
128
+ return `${statusMark(theme, done ? "done" : "stopped")} ${body(theme.bold(exit.id))} ${joinInline(
129
+ theme,
130
+ [body(status), body(formatDuration(exit.duration_ms)), theme.fg("muted", exit.command)],
131
+ )}`;
132
+ }
133
+
134
+ /** The Collapsed View's rows: at most 10, then the Expand Hint for the rest or for the full text. */
135
+ function collapsedRows(theme: MessageTheme, rows: readonly string[]): string[] {
136
+ const hidden = rows.length - COLLAPSED_LINES.fallback;
137
+ if (hidden > 0) return [...rows.slice(0, COLLAPSED_LINES.fallback), expandHint(theme, hidden)];
138
+ const last = rows.length - 1;
139
+ return rows.map((row, index) => (index === last ? `${row}${summaryExpandHint(theme)}` : row));
140
+ }
141
+
142
+ /** `exit`, or `N exits` when several Terminals or Background jobs ended together. */
143
+ function exitHeading(details: ExitNotificationDetails | undefined): string {
144
+ const count = Value.Check(ExitNotificationDetailsSchema, details) ? details.exits.length : 1;
145
+ return count === 1 ? "exit" : `${count} exits`;
146
+ }
147
+
148
+ /**
149
+ * Pi's custom-message look: a `customMessageBg` box under a bold `[termctrl] exit` label.
150
+ * Collapsed, it shows one Status Mark row per exit; expanded, the full text.
151
+ */
114
152
  export const renderExitNotification: ExitNotificationRenderer = (
115
153
  message,
116
154
  options,
117
155
  theme,
118
156
  ): Component => {
119
- if (options.expanded || !Value.Check(ExitNotificationDetailsSchema, message.details)) {
120
- return new Text(theme.fg("muted", messageText(message.content)), options.outputPad, 0);
121
- }
122
- const lines = message.details.exits.map((exit) => {
123
- const failed = exit.exit_code !== 0 || exit.signal !== null;
124
- const status = describeExitStatus({ code: exit.exit_code, signal: exit.signal });
125
- return `${theme.fg(failed ? "warning" : "success", "■")} ${theme.bold(exit.id)} ${status} · ${formatDuration(exit.duration_ms)} · ${theme.fg("muted", exit.command)}`;
126
- });
127
- return new Text(lines.join("\n"), options.outputPad, 0);
157
+ const details = message.details;
158
+ const rows =
159
+ !options.expanded && Value.Check(ExitNotificationDetailsSchema, details)
160
+ ? collapsedRows(
161
+ theme,
162
+ details.exits.map((exit) => exitRow(theme, exit)),
163
+ )
164
+ : previewBody(theme, messageText(message.content).replace(/\n+$/u, "").split("\n"), {
165
+ limit: COLLAPSED_LINES.fallback,
166
+ expanded: options.expanded,
167
+ color: "customMessageText",
168
+ });
169
+ const heading = theme.fg("customMessageText", exitHeading(details));
170
+ return customMessageBox(theme, { outputPad: options.outputPad, source: "termctrl", heading }, [
171
+ new Text(rows.join("\n"), 0, 0),
172
+ ]);
128
173
  };
@@ -7,6 +7,7 @@ import {
7
7
  type ExtensionFactory,
8
8
  type SessionShutdownEvent,
9
9
  } from "@earendil-works/pi-coding-agent";
10
+ import { expandMessageOnClick, noticeText } from "@ian-pascoe/pi-utils/ui";
10
11
  import { createBashReplacement, RunningBashCalls } from "./bash-replacement.js";
11
12
  import {
12
13
  EXIT_NOTIFICATION_TYPE,
@@ -84,7 +85,10 @@ class PiTermctrlController {
84
85
  }
85
86
 
86
87
  register(): void {
87
- this.pi.registerMessageRenderer(EXIT_NOTIFICATION_TYPE, renderExitNotification);
88
+ this.pi.registerMessageRenderer(
89
+ EXIT_NOTIFICATION_TYPE,
90
+ expandMessageOnClick(renderExitNotification),
91
+ );
88
92
  this.pi.registerCommand("ps", {
89
93
  description: "Show and stop Terminals and Background jobs",
90
94
  handler: async () => {
@@ -154,7 +158,10 @@ class PiTermctrlController {
154
158
  );
155
159
  const settings = resolveTermctrlSettings(settingsManager);
156
160
  if (settings.warnings.length > 0) {
157
- context.ui.notify(`Pi Termctrl settings:\n- ${settings.warnings.join("\n- ")}`, "warning");
161
+ context.ui.notify(
162
+ noticeText("Termctrl", `Settings:\n- ${settings.warnings.join("\n- ")}`),
163
+ "warning",
164
+ );
158
165
  }
159
166
  const registry = this.registry;
160
167
  const owner = context.sessionManager.getSessionId();
@@ -178,7 +185,10 @@ class PiTermctrlController {
178
185
  const commandPrefix = settingsManager.getShellCommandPrefix();
179
186
  if (this.binary.kind === "missing") {
180
187
  context.ui.notify(
181
- `Pi Termctrl: Terminal tools are unavailable: ${this.binary.reason}\nRun /skill:pi-termctrl to diagnose.`,
188
+ noticeText(
189
+ "Termctrl",
190
+ `Terminal tools are unavailable: ${this.binary.reason}\nRun /skill:pi-termctrl to diagnose.`,
191
+ ),
182
192
  "warning",
183
193
  );
184
194
  if (settings.replaceBash) this.registerManagementTools();
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();
@@ -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
+ }
@@ -20,6 +20,17 @@ import { describeExitStatus, formatExitNotice, lastLines } from "./exit-notifica
20
20
  import { termctrlTemporaryDirectory } from "./termctrl-driver.js";
21
21
  import type { LineByteLimits, TerminalViewport } from "./pi-termctrl-settings.js";
22
22
  import type { TerminalExit, TerminalSnapshot } from "./terminal-driver.js";
23
+ import {
24
+ renderListCall,
25
+ renderListResult,
26
+ renderSendCall,
27
+ renderStartCall,
28
+ renderStopCall,
29
+ renderStopResult,
30
+ renderTerminalResult,
31
+ renderWaitCall,
32
+ renderWaitResult,
33
+ } from "./terminal-render.js";
23
34
  import { TROUBLESHOOTING_HINT } from "./troubleshooting-skill.js";
24
35
 
25
36
  /** Screen quiet period that settles a Terminal. */
@@ -112,7 +123,7 @@ const TerminalResultSchema = Type.Object({
112
123
  }),
113
124
  ),
114
125
  });
115
- type TerminalResult = Static<typeof TerminalResultSchema>;
126
+ export type TerminalResult = Static<typeof TerminalResultSchema>;
116
127
 
117
128
  const ListEntrySchema = Type.Object({
118
129
  id: Type.String(),
@@ -128,7 +139,7 @@ const ListResultSchema = Type.Object({
128
139
  Type.Object({ ...ListEntrySchema.properties, log_path: Type.String() }),
129
140
  ),
130
141
  });
131
- type ListResult = Static<typeof ListResultSchema>;
142
+ export type ListResult = Static<typeof ListResultSchema>;
132
143
 
133
144
  const StopResultSchema = Type.Object({
134
145
  id: Type.String(),
@@ -166,7 +177,7 @@ const StopResultSchema = Type.Object({
166
177
  ),
167
178
  output: Type.Optional(Type.String({ description: "Background job only: its recent output" })),
168
179
  });
169
- type StopResult = Static<typeof StopResultSchema>;
180
+ export type StopResult = Static<typeof StopResultSchema>;
170
181
 
171
182
  const WaitMsSchema = Type.Optional(
172
183
  Type.Integer({ minimum: 0, description: "Maximum milliseconds to wait (clamped to 300000)" }),
@@ -268,7 +279,7 @@ const WaitResultSchema = Type.Object({
268
279
  }),
269
280
  ),
270
281
  });
271
- type WaitResult = Static<typeof WaitResultSchema>;
282
+ export type WaitResult = Static<typeof WaitResultSchema>;
272
283
  type WaitReason = WaitResult["reason"];
273
284
 
274
285
  /** Clamp a requested wait to the fixed 0 to 5 minute range. */
@@ -837,6 +848,8 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
837
848
  openWorldHint: true,
838
849
  },
839
850
  outputSchema: TerminalResultSchema,
851
+ renderCall: renderStartCall,
852
+ renderResult: renderTerminalResult,
840
853
  async execute(_toolCallId, params, signal, _onUpdate, context) {
841
854
  const cwd = await resolveWorkingDirectory(context.cwd, params.cwd);
842
855
  const entry = await runtime.registry.startTerminal(ownerOf(context), {
@@ -894,6 +907,8 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
894
907
  openWorldHint: true,
895
908
  },
896
909
  outputSchema: TerminalResultSchema,
910
+ renderCall: renderSendCall,
911
+ renderResult: renderTerminalResult,
897
912
  async execute(_toolCallId, params, signal, _onUpdate, context) {
898
913
  const entry = runtime.registry.find(ownerOf(context), params.id);
899
914
  if (entry === undefined) throw unknownId(params.id);
@@ -1004,6 +1019,8 @@ export function createTerminalStopTool({ registry, scrollback }: TerminalResultR
1004
1019
  openWorldHint: false,
1005
1020
  },
1006
1021
  outputSchema: StopResultSchema,
1022
+ renderCall: renderStopCall,
1023
+ renderResult: renderStopResult,
1007
1024
  async execute(_toolCallId, params, signal, _onUpdate, context) {
1008
1025
  const owner = ownerOf(context);
1009
1026
  const known = registry.find(owner, params.id);
@@ -1166,6 +1183,8 @@ export function createTerminalWaitTool(runtime: TerminalWaitRuntime) {
1166
1183
  openWorldHint: false,
1167
1184
  },
1168
1185
  outputSchema: WaitResultSchema,
1186
+ renderCall: renderWaitCall,
1187
+ renderResult: renderWaitResult,
1169
1188
  async execute(_toolCallId, params, signal, _onUpdate, context) {
1170
1189
  const owner = ownerOf(context);
1171
1190
  const targets = waitTargets(registry, owner, params.ids);
@@ -1238,6 +1257,8 @@ export function createTerminalListTool(registry: TermctrlRegistry) {
1238
1257
  openWorldHint: false,
1239
1258
  },
1240
1259
  outputSchema: ListResultSchema,
1260
+ renderCall: renderListCall,
1261
+ renderResult: renderListResult,
1241
1262
  async execute(_toolCallId, _params, _signal, _onUpdate, context) {
1242
1263
  const now = Date.now();
1243
1264
  const entries = registry.ownedEntries(ownerOf(context));