@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 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 since the
66
- agent's previous result, `state`, `exit_code` or `signal` once exited, and
67
- `changed: false` when the screen matches the previous result. A line the agent
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 and scrolled-off lines; for a Background
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.7.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.5.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. Classify the failure as settings, binary resolution, `bash` conflict, cap, or process behaviour.
16
- 7. 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.
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
- createTerminalStopTool({
135
- registry: this.registry,
136
- scrollback: () => this.session().scrollback,
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
- createTerminalWaitTool({
142
- registry: this.registry,
143
- exitTailLines: () => this.session().exitTailLines,
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
- createBashReplacement({
201
- cwd: context.cwd,
202
- commandPrefix,
203
- shellPath,
204
- registry: currentRegistry,
205
- calls,
206
- bashTail: settings.bashTail,
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 = {
@@ -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,
@@ -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(entry.id, DRIVER_EXITED, entry.lastScreen ?? "", false);
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(id: string, exit: TerminalExit, finalScreen: string, seen: boolean): void {
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(entry.id, snapshot.exit ?? DRIVER_EXITED, snapshot.screen, false);
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> {
@@ -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. */
@@ -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
- screen: Type.String({ description: "The visible screen" }),
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
- return { lines: scrolled, gap: located === undefined };
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 === "" ? [] : screen.split("\n");
686
+ const rows = splitRows(screen);
647
687
  if (shown === screen) return rows;
648
- const kept = shown === "" ? [] : shown.split("\n");
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}${result.changed ? "" : " · screen unchanged"}`,
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
- parts.push(`--- screen ---\n${result.screen === "" ? "(blank)" : result.screen}`);
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?: string,
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 output = await fitOutput(registry, entry, screen, scrolled, scrollback());
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.seenRows = shownRows(screen, output.screen);
829
+ if (scrolled !== NOTHING_SCROLLED) recordSeenRows(entry, screen, view, output.screen);
697
830
  if (snapshot?.state === "exited") {
698
- registry.terminalExited(entry.id, snapshot.exit ?? { code: null, signal: null }, screen, true);
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
- screen: output.screen,
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: formatTerminalText(result, note, output.notice) }],
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) return terminalResult(runtime, entry, undefined, "exited");
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
- runtime,
960
- entry,
961
- snapshot,
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 previous
990
- * screen are what they left, and calls queued behind find the Terminal gone.
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, previousScreen, scrolled, entry };
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, previousScreen, scrolled, entry } =
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 header = wasRunning
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 = [header];
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 repeated = !changed;
1053
- const output = await fitOutput(
1054
- registry,
1055
- entry,
1056
- repeated ? "" : screen,
1057
- scrolled,
1058
- scrollback(),
1059
- );
1060
- result.changed = changed;
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 (repeated) parts.push("Its screen is unchanged since your last result.");
1067
- else
1068
- parts.push(`--- final screen ---\n${output.screen === "" ? "(blank)" : output.screen}`);
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
  };