@ian-pascoe/pi-termctrl 0.7.0 → 0.8.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 +29 -11
- package/package.json +2 -2
- package/skills/pi-termctrl/SKILL.md +3 -2
- package/src/pi-termctrl-extension.ts +26 -19
- package/src/termctrl-driver.ts +4 -0
- package/src/termctrl-registry.ts +35 -4
- package/src/terminal-driver.ts +8 -0
- package/src/terminal-tools.ts +199 -54
package/README.md
CHANGED
|
@@ -23,13 +23,13 @@ For a local checkout, run `pi -e ./packages/pi-termctrl/src/index.ts`.
|
|
|
23
23
|
|
|
24
24
|
## Terminal tools
|
|
25
25
|
|
|
26
|
-
| Tool | Parameters
|
|
27
|
-
| ---------------- |
|
|
28
|
-
| `terminal_start` | `command`, `cwd?`, `wait_ms?`, `notify?` (default `true`)
|
|
29
|
-
| `terminal_send` | `id`, `text?`, `keys?`, `wait_for_text?`, `wait_ms?`
|
|
30
|
-
| `terminal_stop` | `id`: a Terminal id (`t1`) or Background job id (`b1`)
|
|
31
|
-
| `terminal_list` | none
|
|
32
|
-
| `terminal_wait` | `ids?`, `wait_ms?` (default and maximum 300000)
|
|
26
|
+
| Tool | Parameters |
|
|
27
|
+
| ---------------- | -------------------------------------------------------------------- |
|
|
28
|
+
| `terminal_start` | `command`, `cwd?`, `wait_ms?`, `notify?` (default `true`) |
|
|
29
|
+
| `terminal_send` | `id`, `text?`, `keys?`, `wait_for_text?`, `wait_ms?`, `full_screen?` |
|
|
30
|
+
| `terminal_stop` | `id`: a Terminal id (`t1`) or Background job id (`b1`) |
|
|
31
|
+
| `terminal_list` | none |
|
|
32
|
+
| `terminal_wait` | `ids?`, `wait_ms?` (default and maximum 300000) |
|
|
33
33
|
|
|
34
34
|
`terminal_start` runs `command` through the shell Pi's `bash` uses (the
|
|
35
35
|
`shellPath` setting, otherwise Pi's default bash), applies `shellCommandPrefix`,
|
|
@@ -62,9 +62,26 @@ their reason too.
|
|
|
62
62
|
Sending `text` or `keys` to an exited Terminal is an error that names its exit
|
|
63
63
|
code or signal; a poll (neither `text` nor `keys`) still returns its final screen.
|
|
64
64
|
|
|
65
|
-
Results contain the visible screen, the log lines that scrolled off
|
|
66
|
-
agent's previous result, `state`, `exit_code` or `signal` once exited,
|
|
67
|
-
`changed: false` when the screen matches the previous result.
|
|
65
|
+
Results contain the visible screen, the cursor, the log lines that scrolled off
|
|
66
|
+
since the agent's previous result, `state`, `exit_code` or `signal` once exited,
|
|
67
|
+
and `changed: false` when the screen matches the previous result. The first line
|
|
68
|
+
reports the cursor one-based as row:column, such as
|
|
69
|
+
`t1 running · settled: quiet · cursor 38:5`, or `cursor hidden`; `cursor` is
|
|
70
|
+
`{ row, column }` or `null`. Rows count from the top of the screen, and the
|
|
71
|
+
cursor row may be below the last row shown, because trailing blank rows are
|
|
72
|
+
trimmed.
|
|
73
|
+
|
|
74
|
+
The text sends a **Screen Delta** when it can: when every row above the
|
|
75
|
+
previous result's cursor row that is still on the screen is unchanged, it shows
|
|
76
|
+
only the rows from that row down, as when a REPL prints a command's output and
|
|
77
|
+
a new prompt, or the agent types at the prompt. The marker names the rows, such
|
|
78
|
+
as `--- screen (rows 36-40 of 40; rows above unchanged) ---`, and
|
|
79
|
+
`screen_from_row` gives the first. A clear, a redraw, a hidden cursor, or a
|
|
80
|
+
previous screen cut to fit shows the whole screen. A `terminal_send` whose
|
|
81
|
+
screen is unchanged shows no rows: its first line says `screen unchanged` and
|
|
82
|
+
still reports the cursor. `full_screen: true` shows the whole screen in that
|
|
83
|
+
result; `terminal_start` always does. Only the text shrinks: `screen` is always
|
|
84
|
+
the whole screen. A line the agent
|
|
68
85
|
saw on an earlier screen is not repeated when it scrolls off unchanged; a line
|
|
69
86
|
rewritten in place since, such as a progress line or a prompt the agent typed
|
|
70
87
|
at, arrives again in its final form. Tools that declare structured output give
|
|
@@ -102,7 +119,8 @@ another fails and lists the caller's live entries.
|
|
|
102
119
|
|
|
103
120
|
`terminal_stop` stops a running entry (termctrl stop, then `SIGKILL` of the
|
|
104
121
|
Terminal's process group if it is still alive after 3 s) and forgets it. For a
|
|
105
|
-
Terminal it returns the final screen
|
|
122
|
+
Terminal it returns the final screen, as a Screen Delta when it can, with its
|
|
123
|
+
cursor in the first line (`Terminal t1 stopped · cursor 4:7`), and scrolled-off lines; for a Background
|
|
106
124
|
job, its recent output. When a Terminal's screen is
|
|
107
125
|
unchanged since the agent's last result, the result omits the screen and reports
|
|
108
126
|
`changed: false` with `state` and `exit_code` or `signal`; `scrolled_off` still
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ian-pascoe/pi-termctrl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Interactive Terminals, Background jobs, and a /ps panel for Pi.",
|
|
6
6
|
"keywords": [
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"provenance": true
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@ian-pascoe/pi-utils": "^0.
|
|
37
|
+
"@ian-pascoe/pi-utils": "^0.6.0",
|
|
38
38
|
"@kitlangton/terminal-control": "^1.2.1"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
@@ -12,7 +12,8 @@ disable-model-invocation: true
|
|
|
12
12
|
3. If `bash` lacks the `background` parameter, check `termctrl.replaceBash` and look for another extension that also registers `bash`.
|
|
13
13
|
4. Call `terminal_list` to inspect live and exited Terminals and Background jobs. Ask the user to open `/ps` for a live preview.
|
|
14
14
|
5. If `bash` results are shorter than expected, check `termctrl.bashTail` (default 300 lines or 16 KB; `false` restores Pi's limits); the result's notice names the limits and the file holding every line. If a Terminal result's `scrolled_off` shows `[… N lines omitted …]`, check `termctrl.scrollback` (default 100 lines or 16 KB) the same way.
|
|
15
|
-
6.
|
|
16
|
-
7.
|
|
15
|
+
6. If a Terminal result shows only some screen rows, that is a Screen Delta: the rows above the previous result's cursor row are unchanged, `screen_from_row` names the first row shown, and `screen` holds the whole screen. Pass `full_screen: true` to `terminal_send` to see every row, and read `cursor` (or `cursor hidden`) in the first line to find the input position.
|
|
16
|
+
7. Classify the failure as settings, binary resolution, `bash` conflict, cap, or process behaviour.
|
|
17
|
+
8. For a requested change, edit one settings layer, validate its JSON, and reload Pi. Finish when the expected tools appear or one exact cause remains.
|
|
17
18
|
|
|
18
19
|
Ask before stopping a Terminal or Background job you did not start.
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type ExtensionFactory,
|
|
8
8
|
type SessionShutdownEvent,
|
|
9
9
|
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { acceptNullForOptionalArguments } from "@ian-pascoe/pi-utils/null-optional-arguments";
|
|
10
11
|
import { expandMessageOnClick, noticeText } from "@ian-pascoe/pi-utils/ui";
|
|
11
12
|
import { createBashReplacement, RunningBashCalls } from "./bash-replacement.js";
|
|
12
13
|
import {
|
|
@@ -102,8 +103,8 @@ class PiTermctrlController {
|
|
|
102
103
|
viewport: () => this.session().viewport,
|
|
103
104
|
scrollback: () => this.session().scrollback,
|
|
104
105
|
};
|
|
105
|
-
this.pi.registerTool(createTerminalStartTool(runtime));
|
|
106
|
-
this.pi.registerTool(createTerminalSendTool(runtime));
|
|
106
|
+
this.pi.registerTool(acceptNullForOptionalArguments(createTerminalStartTool(runtime)));
|
|
107
|
+
this.pi.registerTool(acceptNullForOptionalArguments(createTerminalSendTool(runtime)));
|
|
107
108
|
this.registerManagementTools();
|
|
108
109
|
}
|
|
109
110
|
this.pi.on("session_start", (_event, context) => this.start(context));
|
|
@@ -131,17 +132,21 @@ class PiTermctrlController {
|
|
|
131
132
|
|
|
132
133
|
private registerManagementTools(): void {
|
|
133
134
|
this.pi.registerTool(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
acceptNullForOptionalArguments(
|
|
136
|
+
createTerminalStopTool({
|
|
137
|
+
registry: this.registry,
|
|
138
|
+
scrollback: () => this.session().scrollback,
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
138
141
|
);
|
|
139
|
-
this.pi.registerTool(createTerminalListTool(this.registry));
|
|
142
|
+
this.pi.registerTool(acceptNullForOptionalArguments(createTerminalListTool(this.registry)));
|
|
140
143
|
this.pi.registerTool(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
144
|
+
acceptNullForOptionalArguments(
|
|
145
|
+
createTerminalWaitTool({
|
|
146
|
+
registry: this.registry,
|
|
147
|
+
exitTailLines: () => this.session().exitTailLines,
|
|
148
|
+
}),
|
|
149
|
+
),
|
|
145
150
|
);
|
|
146
151
|
}
|
|
147
152
|
|
|
@@ -197,14 +202,16 @@ class PiTermctrlController {
|
|
|
197
202
|
const calls = new RunningBashCalls(currentRegistry);
|
|
198
203
|
if (settings.replaceBash) {
|
|
199
204
|
this.pi.registerTool(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
205
|
+
acceptNullForOptionalArguments(
|
|
206
|
+
createBashReplacement({
|
|
207
|
+
cwd: context.cwd,
|
|
208
|
+
commandPrefix,
|
|
209
|
+
shellPath,
|
|
210
|
+
registry: currentRegistry,
|
|
211
|
+
calls,
|
|
212
|
+
bashTail: settings.bashTail,
|
|
213
|
+
}),
|
|
214
|
+
),
|
|
208
215
|
);
|
|
209
216
|
}
|
|
210
217
|
this.active = {
|
package/src/termctrl-driver.ts
CHANGED
|
@@ -70,6 +70,10 @@ class TermctrlTerminal implements TerminalHandle {
|
|
|
70
70
|
);
|
|
71
71
|
return {
|
|
72
72
|
screen: capture.text,
|
|
73
|
+
cursor:
|
|
74
|
+
capture.frame.cursor === null
|
|
75
|
+
? null
|
|
76
|
+
: { x: capture.frame.cursor.x, y: capture.frame.cursor.y },
|
|
73
77
|
state: status.state,
|
|
74
78
|
exit: status.exit === null ? null : { code: status.exit.code, signal: status.exit.signal },
|
|
75
79
|
idleForMs: status.idleForMs,
|
package/src/termctrl-registry.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { rm } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type {
|
|
4
|
+
ScreenPosition,
|
|
4
5
|
TerminalDriver,
|
|
5
6
|
TerminalExit,
|
|
6
7
|
TerminalHandle,
|
|
@@ -71,12 +72,19 @@ export interface TerminalEntry extends EntryBase {
|
|
|
71
72
|
readonly cwd: string;
|
|
72
73
|
/** The screen captured when the Terminal exited. */
|
|
73
74
|
finalScreen: string | undefined;
|
|
75
|
+
/** The cursor on `finalScreen`; `null` when it was hidden or is unknown. */
|
|
76
|
+
finalCursor: ScreenPosition | null;
|
|
74
77
|
/** Number of agent tool calls running or queued to drive this Terminal; the exit watcher skips them. */
|
|
75
78
|
activeCalls: number;
|
|
76
79
|
/** The agent's calls to this Terminal: one runs at a time and the rest wait in arrival order. */
|
|
77
80
|
readonly callQueue: { running: boolean; readonly waiting: (() => void)[] };
|
|
78
81
|
/** The screen the agent last received, for `changed: false`. */
|
|
79
82
|
lastScreen: string | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* The cursor on `lastScreen`: `null` when it was hidden, `undefined` before the first result.
|
|
85
|
+
* Its row bounds the rows the next result can skip as unchanged.
|
|
86
|
+
*/
|
|
87
|
+
lastCursor: ScreenPosition | null | undefined;
|
|
80
88
|
/** Count of log lines that scrolled off before the agent's last result. */
|
|
81
89
|
logCursor: number;
|
|
82
90
|
/**
|
|
@@ -463,9 +471,11 @@ export class TermctrlRegistry {
|
|
|
463
471
|
handle,
|
|
464
472
|
generation: slot.generation,
|
|
465
473
|
finalScreen: undefined,
|
|
474
|
+
finalCursor: null,
|
|
466
475
|
activeCalls: 0,
|
|
467
476
|
callQueue: { running: false, waiting: [] },
|
|
468
477
|
lastScreen: undefined,
|
|
478
|
+
lastCursor: undefined,
|
|
469
479
|
logCursor: 0,
|
|
470
480
|
logAnchor: [],
|
|
471
481
|
seenRows: [],
|
|
@@ -511,19 +521,32 @@ export class TermctrlRegistry {
|
|
|
511
521
|
if (this.state.driver?.generation === generation) this.state.driver = undefined;
|
|
512
522
|
for (const entry of this.entries()) {
|
|
513
523
|
if (entry.kind === "terminal" && entry.generation === generation) {
|
|
514
|
-
this.terminalExited(
|
|
524
|
+
this.terminalExited(
|
|
525
|
+
entry.id,
|
|
526
|
+
DRIVER_EXITED,
|
|
527
|
+
entry.lastScreen ?? "",
|
|
528
|
+
false,
|
|
529
|
+
entry.lastCursor ?? null,
|
|
530
|
+
);
|
|
515
531
|
}
|
|
516
532
|
}
|
|
517
533
|
}
|
|
518
534
|
|
|
519
535
|
/** Record a Terminal's exit, as seen by a tool call (`seen`) or by the exit watcher. */
|
|
520
|
-
terminalExited(
|
|
536
|
+
terminalExited(
|
|
537
|
+
id: string,
|
|
538
|
+
exit: TerminalExit,
|
|
539
|
+
finalScreen: string,
|
|
540
|
+
seen: boolean,
|
|
541
|
+
finalCursor: ScreenPosition | null,
|
|
542
|
+
): void {
|
|
521
543
|
const entry = this.state.entries.get(id);
|
|
522
544
|
if (entry?.kind !== "terminal" || entry.state === "exited") return;
|
|
523
545
|
entry.state = "exited";
|
|
524
546
|
entry.exit = exit;
|
|
525
547
|
entry.exitedAt = Date.now();
|
|
526
548
|
entry.finalScreen = finalScreen;
|
|
549
|
+
entry.finalCursor = finalCursor;
|
|
527
550
|
if (seen) entry.seen = true;
|
|
528
551
|
this.queueNotice(entry);
|
|
529
552
|
this.syncWatcher();
|
|
@@ -567,7 +590,13 @@ export class TermctrlRegistry {
|
|
|
567
590
|
const snapshot = await entry.handle.snapshot();
|
|
568
591
|
if (entry.activeCalls > 0 || entry.state !== "running") continue;
|
|
569
592
|
if (snapshot.state === "exited") {
|
|
570
|
-
this.terminalExited(
|
|
593
|
+
this.terminalExited(
|
|
594
|
+
entry.id,
|
|
595
|
+
snapshot.exit ?? DRIVER_EXITED,
|
|
596
|
+
snapshot.screen,
|
|
597
|
+
false,
|
|
598
|
+
snapshot.cursor,
|
|
599
|
+
);
|
|
571
600
|
}
|
|
572
601
|
} catch (cause) {
|
|
573
602
|
this.reportTerminalError(
|
|
@@ -740,10 +769,12 @@ export class TermctrlRegistry {
|
|
|
740
769
|
private async stopTerminal(entry: TerminalEntry): Promise<void> {
|
|
741
770
|
const seen = entry.stopRequested === "agent";
|
|
742
771
|
let screen = entry.lastScreen ?? "";
|
|
772
|
+
let cursor = entry.lastCursor ?? null;
|
|
743
773
|
let exit = KILLED_EXIT;
|
|
744
774
|
try {
|
|
745
775
|
const snapshot = await entry.handle.snapshot();
|
|
746
776
|
screen = snapshot.screen;
|
|
777
|
+
cursor = snapshot.cursor;
|
|
747
778
|
if (snapshot.state === "exited" && snapshot.exit !== null) exit = snapshot.exit;
|
|
748
779
|
} catch {
|
|
749
780
|
// The Terminal may already be gone; keep the last screen the agent saw.
|
|
@@ -767,7 +798,7 @@ export class TermctrlRegistry {
|
|
|
767
798
|
remainingMs -= STOP_POLL_MS;
|
|
768
799
|
}
|
|
769
800
|
if (outcome !== "stopped" || entry.handle.isAlive()) entry.handle.kill();
|
|
770
|
-
this.terminalExited(entry.id, exit, screen, seen);
|
|
801
|
+
this.terminalExited(entry.id, exit, screen, seen, cursor);
|
|
771
802
|
}
|
|
772
803
|
|
|
773
804
|
private async removeEntry(entry: TermctrlEntry): Promise<void> {
|
package/src/terminal-driver.ts
CHANGED
|
@@ -7,9 +7,17 @@ export interface TerminalExit {
|
|
|
7
7
|
readonly signal: string | null;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/** A zero-based screen position: `x` counts columns from the left, `y` rows from the top. */
|
|
11
|
+
export interface ScreenPosition {
|
|
12
|
+
readonly x: number;
|
|
13
|
+
readonly y: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
10
16
|
/** One polled view of a Terminal: the visible screen plus process state. */
|
|
11
17
|
export interface TerminalSnapshot {
|
|
12
18
|
readonly screen: string;
|
|
19
|
+
/** The cursor on the screen, or `null` while the program hides it. */
|
|
20
|
+
readonly cursor: ScreenPosition | null;
|
|
13
21
|
readonly state: "running" | "exited";
|
|
14
22
|
readonly exit: TerminalExit | null;
|
|
15
23
|
/** Milliseconds since the Terminal last produced output, when termctrl knows. */
|
package/src/terminal-tools.ts
CHANGED
|
@@ -19,7 +19,7 @@ import type {
|
|
|
19
19
|
import { describeExitStatus, formatExitNotice, lastLines } from "./exit-notification.js";
|
|
20
20
|
import { termctrlTemporaryDirectory } from "./termctrl-driver.js";
|
|
21
21
|
import type { LineByteLimits, TerminalViewport } from "./pi-termctrl-settings.js";
|
|
22
|
-
import type { TerminalExit, TerminalSnapshot } from "./terminal-driver.js";
|
|
22
|
+
import type { ScreenPosition, TerminalExit, TerminalSnapshot } from "./terminal-driver.js";
|
|
23
23
|
import {
|
|
24
24
|
renderListCall,
|
|
25
25
|
renderListResult,
|
|
@@ -100,6 +100,22 @@ const SettleReasonSchema = Type.Union(
|
|
|
100
100
|
},
|
|
101
101
|
);
|
|
102
102
|
|
|
103
|
+
const CursorPositionSchema = Type.Object({
|
|
104
|
+
row: Type.Number({ description: "1-based, from the top of the screen" }),
|
|
105
|
+
column: Type.Number({ description: "1-based" }),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const CursorSchema = Type.Union([CursorPositionSchema, Type.Null()], {
|
|
109
|
+
description: "The cursor on the screen, or null while the program hides it",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const ScreenFromRowSchema = Type.Optional(
|
|
113
|
+
Type.Number({
|
|
114
|
+
description:
|
|
115
|
+
"Present when the text shows a Screen Delta: the 1-based row it starts at; the rows above are unchanged since the previous result",
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
|
|
103
119
|
const TerminalResultSchema = Type.Object({
|
|
104
120
|
id: Type.String(),
|
|
105
121
|
state: TerminalStateSchema,
|
|
@@ -107,7 +123,11 @@ const TerminalResultSchema = Type.Object({
|
|
|
107
123
|
exit_code: Type.Optional(Type.Number({ description: "Present once the Terminal has exited" })),
|
|
108
124
|
signal: Type.Optional(Type.String({ description: "Present when a signal ended the Terminal" })),
|
|
109
125
|
changed: Type.Boolean({ description: "Whether the screen differs from the previous result" }),
|
|
110
|
-
|
|
126
|
+
cursor: CursorSchema,
|
|
127
|
+
screen: Type.String({
|
|
128
|
+
description: "The whole visible screen, even when the text shows only part of it",
|
|
129
|
+
}),
|
|
130
|
+
screen_from_row: ScreenFromRowSchema,
|
|
111
131
|
scrolled_off: Type.String({
|
|
112
132
|
description: "Log lines that scrolled off the screen since the previous result",
|
|
113
133
|
}),
|
|
@@ -152,12 +172,19 @@ const StopResultSchema = Type.Object({
|
|
|
152
172
|
description: "Terminal only: whether the screen differs from the previous result",
|
|
153
173
|
}),
|
|
154
174
|
),
|
|
175
|
+
cursor: Type.Optional(
|
|
176
|
+
Type.Union([CursorPositionSchema, Type.Null()], {
|
|
177
|
+
description:
|
|
178
|
+
"Terminal only, with screen: the cursor on the final screen, or null while the program hid it",
|
|
179
|
+
}),
|
|
180
|
+
),
|
|
155
181
|
screen: Type.Optional(
|
|
156
182
|
Type.String({
|
|
157
183
|
description:
|
|
158
|
-
"Terminal only: the final screen; omitted when unchanged since your previous result",
|
|
184
|
+
"Terminal only: the whole final screen; omitted when unchanged since your previous result",
|
|
159
185
|
}),
|
|
160
186
|
),
|
|
187
|
+
screen_from_row: ScreenFromRowSchema,
|
|
161
188
|
scrolled_off: Type.Optional(
|
|
162
189
|
Type.String({
|
|
163
190
|
description: "Terminal only: lines that scrolled off since the previous result",
|
|
@@ -211,6 +238,12 @@ const SendParameters = Type.Object(
|
|
|
211
238
|
Type.String({ description: "Wait until the screen shows this text, or /regex/flags" }),
|
|
212
239
|
),
|
|
213
240
|
wait_ms: WaitMsSchema,
|
|
241
|
+
full_screen: Type.Optional(
|
|
242
|
+
Type.Boolean({
|
|
243
|
+
description:
|
|
244
|
+
"Show the whole screen in this result, even when the rows above the previous cursor row are unchanged or the screen is unchanged",
|
|
245
|
+
}),
|
|
246
|
+
),
|
|
214
247
|
},
|
|
215
248
|
{ additionalProperties: false },
|
|
216
249
|
);
|
|
@@ -369,9 +402,11 @@ const ANCHOR_LINES = 5;
|
|
|
369
402
|
interface ScrolledOff {
|
|
370
403
|
readonly lines: readonly string[];
|
|
371
404
|
readonly gap: boolean;
|
|
405
|
+
/** How many rows the screen scrolled since the previous result; undefined when unknown. */
|
|
406
|
+
readonly shift: number | undefined;
|
|
372
407
|
}
|
|
373
408
|
|
|
374
|
-
const NOTHING_SCROLLED: ScrolledOff = { lines: [], gap: false };
|
|
409
|
+
const NOTHING_SCROLLED: ScrolledOff = { lines: [], gap: false, shift: undefined };
|
|
375
410
|
|
|
376
411
|
/**
|
|
377
412
|
* Find the log cursor in a fresh copy of termctrl's log, or `undefined` when its anchor is gone.
|
|
@@ -444,7 +479,8 @@ async function takeScrolledOff(entry: TerminalEntry, screen: string): Promise<Sc
|
|
|
444
479
|
entry.logCursor = boundary;
|
|
445
480
|
entry.logAnchor =
|
|
446
481
|
boundary > 0 ? lines.slice(Math.max(0, boundary - ANCHOR_LINES), boundary) : lines.slice(0, 1);
|
|
447
|
-
|
|
482
|
+
const shift = located === undefined || boundary < located ? undefined : boundary - located;
|
|
483
|
+
return { lines: scrolled, gap: located === undefined, shift };
|
|
448
484
|
}
|
|
449
485
|
|
|
450
486
|
const GAP_NOTICE =
|
|
@@ -638,18 +674,99 @@ async function truncateOutput(
|
|
|
638
674
|
return { ...fitted, fullOutputPath: path, notice: `[${summary} Full output: ${path}]` };
|
|
639
675
|
}
|
|
640
676
|
|
|
677
|
+
function splitRows(screen: string): string[] {
|
|
678
|
+
return screen === "" ? [] : screen.split("\n");
|
|
679
|
+
}
|
|
680
|
+
|
|
641
681
|
/**
|
|
642
682
|
* The rows of `screen` that the agent received, aligned with `screen`: rows cut from the top of a
|
|
643
683
|
* screen too large for the result, or shortened to fit it, are `undefined`.
|
|
644
684
|
*/
|
|
645
685
|
function shownRows(screen: string, shown: string): (string | undefined)[] {
|
|
646
|
-
const rows = screen
|
|
686
|
+
const rows = splitRows(screen);
|
|
647
687
|
if (shown === screen) return rows;
|
|
648
|
-
const kept = shown
|
|
688
|
+
const kept = splitRows(shown);
|
|
649
689
|
const cut = rows.length - kept.length;
|
|
650
690
|
return rows.map((row, index) => (kept[index - cut] === row ? row : undefined));
|
|
651
691
|
}
|
|
652
692
|
|
|
693
|
+
/**
|
|
694
|
+
* Where a Screen Delta of `rows` starts, or 0 when the result must show the whole screen. The
|
|
695
|
+
* delta starts at the previous result's cursor row, moved up by the `shift` rows the screen
|
|
696
|
+
* scrolled since. It is used only when every row above that cursor row still on the screen is
|
|
697
|
+
* unchanged, the agent received the whole previous screen, and the delta leaves out at least one
|
|
698
|
+
* row and shows at least one. Rows past the end of a screen are blank, as its text trims them.
|
|
699
|
+
*/
|
|
700
|
+
function screenDeltaStart(
|
|
701
|
+
entry: TerminalEntry,
|
|
702
|
+
rows: readonly string[],
|
|
703
|
+
shift: number | undefined,
|
|
704
|
+
): number {
|
|
705
|
+
const cursorRow = entry.lastCursor?.y;
|
|
706
|
+
if (entry.lastScreen === undefined || cursorRow === undefined || shift === undefined) return 0;
|
|
707
|
+
const start = cursorRow - shift;
|
|
708
|
+
if (start <= 0 || start >= rows.length || entry.seenRows.includes(undefined)) return 0;
|
|
709
|
+
const previous = splitRows(entry.lastScreen);
|
|
710
|
+
for (let row = shift; row < cursorRow; row++) {
|
|
711
|
+
if ((previous[row] ?? "") !== (rows[row - shift] ?? "")) return 0;
|
|
712
|
+
}
|
|
713
|
+
return start;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** The part of a screen a result shows. */
|
|
717
|
+
interface ScreenView {
|
|
718
|
+
/** The rows the text shows, joined. */
|
|
719
|
+
readonly text: string;
|
|
720
|
+
/** Zero-based row the text starts at; above 0 for a Screen Delta. */
|
|
721
|
+
readonly startRow: number;
|
|
722
|
+
/** The text shows no rows, as the screen is unchanged since the previous result. */
|
|
723
|
+
readonly omitted: boolean;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Choose what a result shows of `screen`: nothing when it is unchanged since the previous result,
|
|
728
|
+
* a Screen Delta when the rows above the previous cursor row are unchanged, otherwise the whole
|
|
729
|
+
* screen. `fullScreen` always shows the whole screen.
|
|
730
|
+
*/
|
|
731
|
+
function viewScreen(
|
|
732
|
+
entry: TerminalEntry,
|
|
733
|
+
screen: string,
|
|
734
|
+
shift: number | undefined,
|
|
735
|
+
fullScreen: boolean,
|
|
736
|
+
): ScreenView {
|
|
737
|
+
if (fullScreen) return { text: screen, startRow: 0, omitted: false };
|
|
738
|
+
if (entry.lastScreen === screen) return { text: "", startRow: 0, omitted: true };
|
|
739
|
+
const rows = splitRows(screen);
|
|
740
|
+
const startRow = screenDeltaStart(entry, rows, shift);
|
|
741
|
+
return startRow === 0
|
|
742
|
+
? { text: screen, startRow, omitted: false }
|
|
743
|
+
: { text: rows.slice(startRow).join("\n"), startRow, omitted: false };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Record the rows of `screen` the agent now knows: the rows above a Screen Delta it already had,
|
|
748
|
+
* then the rows the result showed. An omitted, unchanged screen leaves them as they were.
|
|
749
|
+
*/
|
|
750
|
+
function recordSeenRows(entry: TerminalEntry, screen: string, view: ScreenView, shown: string) {
|
|
751
|
+
if (view.omitted) return;
|
|
752
|
+
entry.seenRows = [...splitRows(screen).slice(0, view.startRow), ...shownRows(view.text, shown)];
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function cursorField(cursor: ScreenPosition | null): TerminalResult["cursor"] {
|
|
756
|
+
return cursor === null ? null : { row: cursor.y + 1, column: cursor.x + 1 };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function describeCursor(cursor: TerminalResult["cursor"]): string {
|
|
760
|
+
return cursor === null ? "cursor hidden" : `cursor ${cursor.row}:${cursor.column}`;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** The marker above a shown screen, naming the rows a Screen Delta shows. */
|
|
764
|
+
function screenMarker(label: string, screen: string, view: ScreenView): string {
|
|
765
|
+
if (view.startRow === 0) return `--- ${label} ---`;
|
|
766
|
+
const total = splitRows(screen).length;
|
|
767
|
+
return `--- ${label} (rows ${view.startRow + 1}-${total} of ${total}; rows above unchanged) ---`;
|
|
768
|
+
}
|
|
769
|
+
|
|
653
770
|
/** Says that `wait_for_text` was not seen when its wait timed out. */
|
|
654
771
|
function unmatchedNote(reason: SettleReason, pattern: string | undefined): string | undefined {
|
|
655
772
|
return reason === "timeout" && pattern !== undefined
|
|
@@ -659,6 +776,8 @@ function unmatchedNote(reason: SettleReason, pattern: string | undefined): strin
|
|
|
659
776
|
|
|
660
777
|
function formatTerminalText(
|
|
661
778
|
result: TerminalResult,
|
|
779
|
+
view: ScreenView,
|
|
780
|
+
shown: string,
|
|
662
781
|
note: string | undefined,
|
|
663
782
|
notice: string | undefined,
|
|
664
783
|
): string {
|
|
@@ -666,36 +785,56 @@ function formatTerminalText(
|
|
|
666
785
|
result.state === "running"
|
|
667
786
|
? "running"
|
|
668
787
|
: describeExit({ code: result.exit_code ?? null, signal: result.signal ?? null });
|
|
788
|
+
const unchanged = result.changed ? "" : " · screen unchanged";
|
|
669
789
|
const parts = [
|
|
670
|
-
`${result.id} ${exit} · settled: ${result.settle_reason}${
|
|
790
|
+
`${result.id} ${exit} · settled: ${result.settle_reason}${unchanged} · ${describeCursor(result.cursor)}`,
|
|
671
791
|
];
|
|
672
792
|
if (note !== undefined) parts.push(note);
|
|
673
793
|
if (result.scrolled_off !== "") {
|
|
674
794
|
parts.push(`--- scrolled off ---\n${result.scrolled_off}`);
|
|
675
795
|
}
|
|
676
|
-
|
|
796
|
+
if (!view.omitted) {
|
|
797
|
+
parts.push(
|
|
798
|
+
`${screenMarker("screen", result.screen, view)}\n${shown === "" ? "(blank)" : shown}`,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
677
801
|
const text = parts.join("\n");
|
|
678
802
|
return notice === undefined ? text : `${text}\n\n${notice}`;
|
|
679
803
|
}
|
|
680
804
|
|
|
805
|
+
/** How a result shows a Terminal. */
|
|
806
|
+
interface TerminalResultOptions {
|
|
807
|
+
readonly note?: string | undefined;
|
|
808
|
+
/** Show the whole screen even when a Screen Delta or nothing would do. */
|
|
809
|
+
readonly fullScreen?: boolean | undefined;
|
|
810
|
+
}
|
|
811
|
+
|
|
681
812
|
/** Build the agent's view of a Terminal after a wait, recording the exit as seen. */
|
|
682
813
|
async function terminalResult(
|
|
683
814
|
{ registry, scrollback }: TerminalResultRuntime,
|
|
684
815
|
entry: TerminalEntry,
|
|
685
816
|
snapshot: TerminalSnapshot | undefined,
|
|
686
817
|
settleReason: SettleReason,
|
|
687
|
-
note
|
|
818
|
+
{ note, fullScreen = false }: TerminalResultOptions = {},
|
|
688
819
|
) {
|
|
689
820
|
const screen = snapshot?.screen ?? entry.finalScreen ?? entry.lastScreen ?? "";
|
|
821
|
+
const cursor = snapshot === undefined ? entry.finalCursor : snapshot.cursor;
|
|
690
822
|
const scrolled =
|
|
691
823
|
entry.state === "running" || snapshot !== undefined
|
|
692
824
|
? await takeScrolledOff(entry, screen)
|
|
693
825
|
: NOTHING_SCROLLED;
|
|
694
|
-
const
|
|
826
|
+
const view = viewScreen(entry, screen, scrolled.shift, fullScreen);
|
|
827
|
+
const output = await fitOutput(registry, entry, view.text, scrolled, scrollback());
|
|
695
828
|
// The screen now starts at the log cursor; when the cursor did not move, neither do the rows.
|
|
696
|
-
if (scrolled !== NOTHING_SCROLLED) entry
|
|
829
|
+
if (scrolled !== NOTHING_SCROLLED) recordSeenRows(entry, screen, view, output.screen);
|
|
697
830
|
if (snapshot?.state === "exited") {
|
|
698
|
-
registry.terminalExited(
|
|
831
|
+
registry.terminalExited(
|
|
832
|
+
entry.id,
|
|
833
|
+
snapshot.exit ?? { code: null, signal: null },
|
|
834
|
+
screen,
|
|
835
|
+
true,
|
|
836
|
+
cursor,
|
|
837
|
+
);
|
|
699
838
|
}
|
|
700
839
|
registry.markSeen(entry.id);
|
|
701
840
|
const exited = entry.state === "exited";
|
|
@@ -705,14 +844,18 @@ async function terminalResult(
|
|
|
705
844
|
settle_reason: settleReason,
|
|
706
845
|
...exitFields(exited ? entry.exit : null),
|
|
707
846
|
changed: entry.lastScreen !== screen,
|
|
708
|
-
|
|
847
|
+
cursor: cursorField(cursor),
|
|
848
|
+
screen,
|
|
709
849
|
scrolled_off: output.scrolledOff,
|
|
710
850
|
};
|
|
851
|
+
if (view.startRow > 0) result.screen_from_row = view.startRow + 1;
|
|
711
852
|
if (output.gap) result.output_missing = true;
|
|
712
853
|
if (output.fullOutputPath !== undefined) result.full_output_path = output.fullOutputPath;
|
|
713
854
|
entry.lastScreen = screen;
|
|
855
|
+
entry.lastCursor = cursor;
|
|
856
|
+
const text = formatTerminalText(result, view, output.screen, note, output.notice);
|
|
714
857
|
return {
|
|
715
|
-
content: [{ type: "text" as const, text
|
|
858
|
+
content: [{ type: "text" as const, text }],
|
|
716
859
|
details: result,
|
|
717
860
|
structuredContent: result,
|
|
718
861
|
};
|
|
@@ -872,7 +1015,7 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
|
|
|
872
1015
|
baseline: undefined,
|
|
873
1016
|
signal,
|
|
874
1017
|
});
|
|
875
|
-
return terminalResult(runtime, entry, snapshot, reason);
|
|
1018
|
+
return terminalResult(runtime, entry, snapshot, reason, { fullScreen: true });
|
|
876
1019
|
});
|
|
877
1020
|
},
|
|
878
1021
|
});
|
|
@@ -897,7 +1040,7 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
|
|
|
897
1040
|
name: "terminal_send",
|
|
898
1041
|
label: "terminal_send",
|
|
899
1042
|
description:
|
|
900
|
-
"Type text and press keys in a Terminal, then return its screen once it settles: 250 ms of quiet, a wait_for_text match, exit, or wait_ms (default 500). With neither text nor keys it polls: it waits up to wait_ms (default 30000) for new output. Results include the lines that scrolled off since your previous result; a truncated result names a file with its full output.",
|
|
1043
|
+
"Type text and press keys in a Terminal, then return its screen once it settles: 250 ms of quiet, a wait_for_text match, exit, or wait_ms (default 500). With neither text nor keys it polls: it waits up to wait_ms (default 30000) for new output. Results include the lines that scrolled off since your previous result; a truncated result names a file with its full output. When the rows above the previous result's cursor row are unchanged, the screen shows only the rows from there down; an unchanged screen shows no rows; full_screen: true shows the whole screen.",
|
|
901
1044
|
promptSnippet: "Send input to a Terminal, or poll it, and read its screen",
|
|
902
1045
|
parameters: SendParameters,
|
|
903
1046
|
annotations: {
|
|
@@ -927,7 +1070,11 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
|
|
|
927
1070
|
const hasInput = (params.text ?? "") !== "" || keys.length > 0;
|
|
928
1071
|
return driveTerminal(runtime.registry, entry, signal, async () => {
|
|
929
1072
|
if (entry.state === "exited") {
|
|
930
|
-
if (!hasInput)
|
|
1073
|
+
if (!hasInput) {
|
|
1074
|
+
return terminalResult(runtime, entry, undefined, "exited", {
|
|
1075
|
+
fullScreen: params.full_screen,
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
931
1078
|
// The error tells the agent about the exit, so a deferred Exit notification is redundant.
|
|
932
1079
|
runtime.registry.markSeen(entry.id);
|
|
933
1080
|
throw new InputToExitedError(entry.id, entry.exit);
|
|
@@ -939,7 +1086,7 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
|
|
|
939
1086
|
if (hasInput && before?.state === "exited") {
|
|
940
1087
|
// The exit watcher has not noticed this exit yet.
|
|
941
1088
|
const exit = before.exit ?? { code: null, signal: null };
|
|
942
|
-
runtime.registry.terminalExited(entry.id, exit, before.screen, true);
|
|
1089
|
+
runtime.registry.terminalExited(entry.id, exit, before.screen, true, before.cursor);
|
|
943
1090
|
throw new InputToExitedError(entry.id, exit);
|
|
944
1091
|
}
|
|
945
1092
|
const baseline = matches === undefined ? undefined : before?.screen;
|
|
@@ -955,13 +1102,10 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
|
|
|
955
1102
|
baseline,
|
|
956
1103
|
signal,
|
|
957
1104
|
});
|
|
958
|
-
return terminalResult(
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
reason,
|
|
963
|
-
unmatchedNote(reason, params.wait_for_text),
|
|
964
|
-
);
|
|
1105
|
+
return terminalResult(runtime, entry, snapshot, reason, {
|
|
1106
|
+
note: unmatchedNote(reason, params.wait_for_text),
|
|
1107
|
+
fullScreen: params.full_screen,
|
|
1108
|
+
});
|
|
965
1109
|
});
|
|
966
1110
|
},
|
|
967
1111
|
});
|
|
@@ -980,14 +1124,13 @@ async function finalScrolledOff(entry: TerminalEntry): Promise<ScrolledOff> {
|
|
|
980
1124
|
/** What a stop saw of a Terminal at its turn, before stopping it. */
|
|
981
1125
|
interface StopView {
|
|
982
1126
|
readonly wasRunning: boolean;
|
|
983
|
-
readonly previousScreen: string | undefined;
|
|
984
1127
|
readonly scrolled: ScrolledOff;
|
|
985
1128
|
readonly entry: TermctrlEntry;
|
|
986
1129
|
}
|
|
987
1130
|
|
|
988
1131
|
/**
|
|
989
|
-
* Stop a Terminal in its turn: the calls ahead have finished, so `wasRunning` and the
|
|
990
|
-
*
|
|
1132
|
+
* Stop a Terminal in its turn: the calls ahead have finished, so `wasRunning` and the agent's
|
|
1133
|
+
* previous result are what they left, and calls queued behind find the Terminal gone.
|
|
991
1134
|
*/
|
|
992
1135
|
function stopTerminalInTurn(
|
|
993
1136
|
registry: TermctrlRegistry,
|
|
@@ -996,10 +1139,9 @@ function stopTerminalInTurn(
|
|
|
996
1139
|
): Promise<StopView> {
|
|
997
1140
|
return driveTerminal(registry, known, signal, async () => {
|
|
998
1141
|
const wasRunning = known.state === "running";
|
|
999
|
-
const previousScreen = known.lastScreen;
|
|
1000
1142
|
const scrolled = await finalScrolledOff(known);
|
|
1001
1143
|
const entry = (await registry.stop(known.owner, known.id)) ?? known;
|
|
1002
|
-
return { wasRunning,
|
|
1144
|
+
return { wasRunning, scrolled, entry };
|
|
1003
1145
|
});
|
|
1004
1146
|
}
|
|
1005
1147
|
|
|
@@ -1009,7 +1151,7 @@ export function createTerminalStopTool({ registry, scrollback }: TerminalResultR
|
|
|
1009
1151
|
name: "terminal_stop",
|
|
1010
1152
|
label: "terminal_stop",
|
|
1011
1153
|
description:
|
|
1012
|
-
"Stop a Terminal (t1) or Background job (b1) and forget it. Running processes are killed; exited ones are removed. Returns a Terminal's final screen (omitted when unchanged since your previous result) and scrolled-off lines, or a Background job's recent output.",
|
|
1154
|
+
"Stop a Terminal (t1) or Background job (b1) and forget it. Running processes are killed; exited ones are removed. Returns a Terminal's final screen (omitted when unchanged since your previous result, and only the rows from the previous cursor row down when the rows above are unchanged) and scrolled-off lines, or a Background job's recent output.",
|
|
1013
1155
|
promptSnippet: "Stop a Terminal or Background job",
|
|
1014
1156
|
parameters: StopParameters,
|
|
1015
1157
|
annotations: {
|
|
@@ -1025,54 +1167,57 @@ export function createTerminalStopTool({ registry, scrollback }: TerminalResultR
|
|
|
1025
1167
|
const owner = ownerOf(context);
|
|
1026
1168
|
const known = registry.find(owner, params.id);
|
|
1027
1169
|
if (known === undefined) throw unknownId(params.id);
|
|
1028
|
-
const { wasRunning,
|
|
1170
|
+
const { wasRunning, scrolled, entry } =
|
|
1029
1171
|
known.kind === "terminal"
|
|
1030
1172
|
? await stopTerminalInTurn(registry, known, signal)
|
|
1031
1173
|
: {
|
|
1032
1174
|
wasRunning: known.state === "running",
|
|
1033
|
-
previousScreen: undefined,
|
|
1034
1175
|
scrolled: NOTHING_SCROLLED,
|
|
1035
1176
|
entry: (await registry.stop(owner, params.id)) ?? known,
|
|
1036
1177
|
};
|
|
1037
1178
|
const label = entry.kind === "terminal" ? "Terminal" : "Background job";
|
|
1038
|
-
const
|
|
1039
|
-
? `${label} ${entry.id} stopped
|
|
1040
|
-
: `${label} ${entry.id} had already ${describeExit(entry.exit)}; removed
|
|
1179
|
+
const outcome = wasRunning
|
|
1180
|
+
? `${label} ${entry.id} stopped`
|
|
1181
|
+
: `${label} ${entry.id} had already ${describeExit(entry.exit)}; removed`;
|
|
1041
1182
|
const result: StopResult = {
|
|
1042
1183
|
id: entry.id,
|
|
1043
1184
|
kind: entry.kind === "terminal" ? "terminal" : "background_job",
|
|
1044
1185
|
state: "exited",
|
|
1045
1186
|
...exitFields(entry.exit),
|
|
1046
1187
|
};
|
|
1047
|
-
const parts = [
|
|
1188
|
+
const parts: string[] = [];
|
|
1048
1189
|
if (entry.kind === "terminal") {
|
|
1049
1190
|
const screen = entry.finalScreen ?? "";
|
|
1050
|
-
const changed = previousScreen !== screen;
|
|
1051
1191
|
// A screen the agent already saw is not repeated, whether the Terminal was running or exited.
|
|
1052
|
-
const
|
|
1053
|
-
const output = await fitOutput(
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
result.
|
|
1061
|
-
if (!repeated) result.screen = output.screen;
|
|
1062
|
-
if (!repeated || output.scrolledOff !== "") result.scrolled_off = output.scrolledOff;
|
|
1192
|
+
const view = viewScreen(entry, screen, scrolled.shift, false);
|
|
1193
|
+
const output = await fitOutput(registry, entry, view.text, scrolled, scrollback());
|
|
1194
|
+
result.changed = !view.omitted;
|
|
1195
|
+
if (!view.omitted) {
|
|
1196
|
+
result.cursor = cursorField(entry.finalCursor);
|
|
1197
|
+
result.screen = screen;
|
|
1198
|
+
if (view.startRow > 0) result.screen_from_row = view.startRow + 1;
|
|
1199
|
+
}
|
|
1200
|
+
if (!view.omitted || output.scrolledOff !== "") result.scrolled_off = output.scrolledOff;
|
|
1063
1201
|
if (output.gap) result.output_missing = true;
|
|
1064
1202
|
if (output.fullOutputPath !== undefined) result.full_output_path = output.fullOutputPath;
|
|
1065
1203
|
if (output.scrolledOff !== "") parts.push(`--- scrolled off ---\n${output.scrolledOff}`);
|
|
1066
|
-
if (
|
|
1067
|
-
else
|
|
1068
|
-
|
|
1204
|
+
if (view.omitted) parts.push("Its screen is unchanged since your last result.");
|
|
1205
|
+
else {
|
|
1206
|
+
const marker = screenMarker("final screen", screen, view);
|
|
1207
|
+
parts.push(`${marker}\n${output.screen === "" ? "(blank)" : output.screen}`);
|
|
1208
|
+
}
|
|
1069
1209
|
if (output.notice !== undefined) parts.push(`\n${output.notice}`);
|
|
1070
1210
|
} else {
|
|
1071
1211
|
result.output = entry.child.tail();
|
|
1072
1212
|
if (result.output !== "") parts.push(`--- recent output ---\n${result.output}`);
|
|
1073
1213
|
}
|
|
1214
|
+
// A shown final screen reports its cursor in the first line, as Terminal results do.
|
|
1215
|
+
const header =
|
|
1216
|
+
result.cursor === undefined
|
|
1217
|
+
? `${outcome}.`
|
|
1218
|
+
: `${outcome} · ${describeCursor(result.cursor)}`;
|
|
1074
1219
|
return {
|
|
1075
|
-
content: [{ type: "text" as const, text: parts.join("\n") }],
|
|
1220
|
+
content: [{ type: "text" as const, text: [header, ...parts].join("\n") }],
|
|
1076
1221
|
details: result,
|
|
1077
1222
|
structuredContent: result,
|
|
1078
1223
|
};
|