@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/README.md +32 -19
- package/package.json +4 -3
- package/skills/pi-termctrl/SKILL.md +1 -1
- package/src/bash-replacement.ts +12 -8
- package/src/exit-notification.ts +55 -10
- package/src/pi-termctrl-extension.ts +27 -5
- package/src/pi-termctrl-settings.ts +66 -37
- package/src/ps-panel.ts +81 -35
- package/src/termctrl-registry.ts +7 -1
- package/src/terminal-render.ts +398 -0
- package/src/terminal-tools.ts +199 -45
package/src/ps-panel.ts
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
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
|
|
108
|
-
const
|
|
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
|
-
|
|
113
|
-
|
|
139
|
+
...(previewLines.length === 0 ? [] : ["", ...previewLines]),
|
|
140
|
+
"",
|
|
114
141
|
...help,
|
|
142
|
+
"",
|
|
115
143
|
];
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
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();
|
package/src/termctrl-registry.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|