@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.
@@ -7,7 +7,6 @@ import {
7
7
  formatSize,
8
8
  truncateTail,
9
9
  type ExtensionToolContext,
10
- type TruncationResult,
11
10
  } from "@earendil-works/pi-coding-agent";
12
11
  import type { Key } from "@kitlangton/terminal-control";
13
12
  import { Type, type Static } from "typebox";
@@ -19,8 +18,19 @@ import type {
19
18
  } from "./termctrl-registry.js";
20
19
  import { describeExitStatus, formatExitNotice, lastLines } from "./exit-notification.js";
21
20
  import { termctrlTemporaryDirectory } from "./termctrl-driver.js";
22
- import type { TerminalViewport } from "./pi-termctrl-settings.js";
21
+ import type { LineByteLimits, TerminalViewport } from "./pi-termctrl-settings.js";
23
22
  import type { TerminalExit, TerminalSnapshot } from "./terminal-driver.js";
23
+ import {
24
+ renderListCall,
25
+ renderListResult,
26
+ renderSendCall,
27
+ renderStartCall,
28
+ renderStopCall,
29
+ renderStopResult,
30
+ renderTerminalResult,
31
+ renderWaitCall,
32
+ renderWaitResult,
33
+ } from "./terminal-render.js";
24
34
  import { TROUBLESHOOTING_HINT } from "./troubleshooting-skill.js";
25
35
 
26
36
  /** Screen quiet period that settles a Terminal. */
@@ -73,8 +83,13 @@ export interface TerminalToolRuntime {
73
83
  readonly registry: TermctrlRegistry;
74
84
  readonly shell: () => TerminalShell;
75
85
  readonly viewport: () => TerminalViewport;
86
+ /** The `termctrl.scrollback` limits; undefined leaves only Pi's limits on the whole result. */
87
+ readonly scrollback: () => LineByteLimits | undefined;
76
88
  }
77
89
 
90
+ /** What building a Terminal result reads; `terminal_stop` also stops Background jobs and needs only this. */
91
+ export type TerminalResultRuntime = Pick<TerminalToolRuntime, "registry" | "scrollback">;
92
+
78
93
  const TerminalStateSchema = Type.Union([Type.Literal("running"), Type.Literal("exited")]);
79
94
 
80
95
  const SettleReasonSchema = Type.Union(
@@ -108,7 +123,7 @@ const TerminalResultSchema = Type.Object({
108
123
  }),
109
124
  ),
110
125
  });
111
- type TerminalResult = Static<typeof TerminalResultSchema>;
126
+ export type TerminalResult = Static<typeof TerminalResultSchema>;
112
127
 
113
128
  const ListEntrySchema = Type.Object({
114
129
  id: Type.String(),
@@ -124,7 +139,7 @@ const ListResultSchema = Type.Object({
124
139
  Type.Object({ ...ListEntrySchema.properties, log_path: Type.String() }),
125
140
  ),
126
141
  });
127
- type ListResult = Static<typeof ListResultSchema>;
142
+ export type ListResult = Static<typeof ListResultSchema>;
128
143
 
129
144
  const StopResultSchema = Type.Object({
130
145
  id: Type.String(),
@@ -162,7 +177,7 @@ const StopResultSchema = Type.Object({
162
177
  ),
163
178
  output: Type.Optional(Type.String({ description: "Background job only: its recent output" })),
164
179
  });
165
- type StopResult = Static<typeof StopResultSchema>;
180
+ export type StopResult = Static<typeof StopResultSchema>;
166
181
 
167
182
  const WaitMsSchema = Type.Optional(
168
183
  Type.Integer({ minimum: 0, description: "Maximum milliseconds to wait (clamped to 300000)" }),
@@ -264,7 +279,7 @@ const WaitResultSchema = Type.Object({
264
279
  }),
265
280
  ),
266
281
  });
267
- type WaitResult = Static<typeof WaitResultSchema>;
282
+ export type WaitResult = Static<typeof WaitResultSchema>;
268
283
  type WaitReason = WaitResult["reason"];
269
284
 
270
285
  /** Clamp a requested wait to the fixed 0 to 5 minute range. */
@@ -405,10 +420,11 @@ function locateScreen(lines: readonly string[], screen: readonly string[], floor
405
420
 
406
421
  /**
407
422
  * Lines that left the screen since the previous result, advancing the Terminal's log cursor.
408
- * Lines still on the result's screen stay unread, so a line rewritten after the agent saw it, such
409
- * as a progress line, is reported in its final form once it scrolls off. When the cursor cannot be
410
- * found again, because termctrl dropped lines the agent never received or the screen was cleared,
411
- * every line termctrl still holds is reported and the result says that earlier output is missing.
423
+ * Lines still on the result's screen stay unread. Once they scroll off, a line the agent saw is
424
+ * skipped if it is unchanged, while a line rewritten since, such as a progress line or a prompt the
425
+ * agent typed at, is reported in its final form. When the cursor cannot be found again, because
426
+ * termctrl dropped lines the agent never received or the screen was cleared, every line termctrl
427
+ * still holds is reported and the result says that earlier output is missing.
412
428
  */
413
429
  async function takeScrolledOff(entry: TerminalEntry, screen: string): Promise<ScrolledOff> {
414
430
  let logs: string;
@@ -421,7 +437,10 @@ async function takeScrolledOff(entry: TerminalEntry, screen: string): Promise<Sc
421
437
  const located = locateCursor(lines, entry.logCursor, entry.logAnchor);
422
438
  const screenLines = screen === "" ? [] : screen.split("\n");
423
439
  const boundary = locateScreen(lines, screenLines, located ?? 0);
424
- const scrolled = lines.slice(Math.min(located ?? 0, boundary), boundary);
440
+ const seen = located === undefined ? [] : entry.seenRows;
441
+ const scrolled = lines
442
+ .slice(Math.min(located ?? 0, boundary), boundary)
443
+ .filter((line, row) => line !== seen[row]);
425
444
  entry.logCursor = boundary;
426
445
  entry.logAnchor =
427
446
  boundary > 0 ? lines.slice(Math.max(0, boundary - ANCHOR_LINES), boundary) : lines.slice(0, 1);
@@ -442,35 +461,135 @@ interface FittedOutput {
442
461
  readonly notice?: string;
443
462
  }
444
463
 
445
- function emptyTruncation(content: string): TruncationResult {
446
- const totalLines = content === "" ? 0 : content.split("\n").length;
464
+ /** The scrolled-off lines a result shows. */
465
+ interface ShownScrolled {
466
+ readonly content: string;
467
+ readonly truncated: boolean;
468
+ readonly totalLines: number;
469
+ readonly outputLines: number;
470
+ /** Describes a cut to the `termctrl.scrollback` limits; absent for a cut to Pi's limits alone. */
471
+ readonly summary?: string;
472
+ }
473
+
474
+ function omissionMarker(count: number): string {
475
+ return `[… ${count} lines omitted …]`;
476
+ }
477
+
478
+ function describeLimits(limits: LineByteLimits): string {
479
+ return `(${formatSize(limits.maxBytes)} or ${limits.maxLines} line limit)`;
480
+ }
481
+
482
+ /**
483
+ * Keep the first and last scrolled-off lines within `limits`, splitting them evenly and marking
484
+ * the omitted lines between them on a line of its own. Returns `undefined` when the limits leave
485
+ * no room for a line beside the marker.
486
+ */
487
+ function keepStartAndEnd(
488
+ lines: readonly string[],
489
+ limits: LineByteLimits,
490
+ ): ShownScrolled | undefined {
491
+ // Each kept line is counted with the newline that joins it to the marker or its neighbour.
492
+ const bodyLines = limits.maxLines - 1;
493
+ const bodyBytes = limits.maxBytes - Buffer.byteLength(omissionMarker(lines.length), "utf8");
494
+ if (bodyLines < 1 || bodyBytes < 2) return undefined;
495
+ const head: string[] = [];
496
+ let headBytes = 0;
497
+ const headLines = Math.ceil(bodyLines / 2);
498
+ const headByteLimit = Math.floor(bodyBytes / 2);
499
+ for (const line of lines) {
500
+ const size = Buffer.byteLength(line, "utf8") + 1;
501
+ if (head.length >= headLines || headBytes + size > headByteLimit) break;
502
+ head.push(line);
503
+ headBytes += size;
504
+ }
505
+ const tail: string[] = [];
506
+ let tailBytes = 0;
507
+ let lastLineCut = false;
508
+ const tailByteLimit = bodyBytes - headBytes;
509
+ for (let index = lines.length - 1; index >= head.length; index--) {
510
+ if (tail.length >= bodyLines - head.length) break;
511
+ const line = lines[index] ?? "";
512
+ const size = Buffer.byteLength(line, "utf8") + 1;
513
+ if (tailBytes + size > tailByteLimit) {
514
+ // A last line too long for the limits on its own still shows its end.
515
+ if (head.length === 0 && tail.length === 0) {
516
+ tail.push(truncateTail(line, { maxLines: 1, maxBytes: tailByteLimit - 1 }).content);
517
+ lastLineCut = true;
518
+ }
519
+ break;
520
+ }
521
+ tail.unshift(line);
522
+ tailBytes += size;
523
+ }
524
+ const omitted = lines.length - head.length - tail.length;
525
+ const kept = omitted > 0 ? [...head, omissionMarker(omitted), ...tail] : [...head, ...tail];
526
+ const shown = lastLineCut
527
+ ? `the end of scrolled-off line ${lines.length} of ${lines.length}`
528
+ : `the first ${head.length} and last ${tail.length} of ${lines.length} scrolled-off lines`;
529
+ return {
530
+ content: kept.join("\n"),
531
+ truncated: true,
532
+ totalLines: lines.length,
533
+ outputLines: head.length + tail.length,
534
+ summary: `Showing ${shown} ${describeLimits(limits)}.`,
535
+ };
536
+ }
537
+
538
+ /** Keep the newest scrolled-off lines within `limits`. */
539
+ function keepNewest(lines: readonly string[], limits: LineByteLimits): ShownScrolled {
540
+ const newest = truncateTail(lines.join("\n"), limits);
447
541
  return {
448
- content: "",
449
- truncated: content !== "",
450
- truncatedBy: content === "" ? null : "bytes",
451
- totalLines,
452
- totalBytes: Buffer.byteLength(content, "utf8"),
453
- outputLines: 0,
454
- outputBytes: 0,
455
- lastLinePartial: false,
456
- firstLineExceedsLimit: false,
457
- maxLines: 0,
458
- maxBytes: 0,
542
+ content: newest.content,
543
+ truncated: newest.truncated,
544
+ totalLines: lines.length,
545
+ outputLines: newest.outputLines,
546
+ };
547
+ }
548
+
549
+ /**
550
+ * Fit the scrolled-off lines into the lines and bytes the screen left. With `termctrl.scrollback`
551
+ * limits, a cut keeps their start and end; without them, their newest lines.
552
+ */
553
+ function fitScrolled(
554
+ lines: readonly string[],
555
+ room: LineByteLimits,
556
+ scrollback: LineByteLimits | undefined,
557
+ ): ShownScrolled {
558
+ if (room.maxLines <= 0 || room.maxBytes <= 0) {
559
+ return { content: "", truncated: lines.length > 0, totalLines: lines.length, outputLines: 0 };
560
+ }
561
+ if (scrollback === undefined) return keepNewest(lines, room);
562
+ const limits = {
563
+ maxLines: Math.min(scrollback.maxLines, room.maxLines),
564
+ maxBytes: Math.min(scrollback.maxBytes, room.maxBytes),
565
+ };
566
+ const text = lines.join("\n");
567
+ if (lines.length <= limits.maxLines && Buffer.byteLength(text, "utf8") <= limits.maxBytes) {
568
+ return { content: text, truncated: false, totalLines: lines.length, outputLines: lines.length };
569
+ }
570
+ const kept = keepStartAndEnd(lines, limits);
571
+ if (kept !== undefined) return kept;
572
+ const newest = keepNewest(lines, limits);
573
+ return {
574
+ ...newest,
575
+ summary: `Showing the last ${newest.outputLines} of ${lines.length} scrolled-off lines ${describeLimits(limits)}.`,
459
576
  };
460
577
  }
461
578
 
462
579
  /**
463
580
  * Fit a result into Pi's 2000-line and 50 KB limits. The screen is kept first, from its bottom;
464
- * the newest scrolled-off lines fill what is left. When anything is cut, every line of the result
465
- * goes to a full output file that lasts until the owner's session shuts down.
581
+ * the scrolled-off lines fill what is left, within the `termctrl.scrollback` limits. When anything
582
+ * is cut, every line of the result goes to a full output file that lasts until the owner's session
583
+ * shuts down.
466
584
  */
467
585
  async function fitOutput(
468
586
  registry: TermctrlRegistry,
469
587
  entry: TerminalEntry,
470
588
  screen: string,
471
589
  scrolled: ScrolledOff,
590
+ scrollback: LineByteLimits | undefined,
472
591
  ): Promise<FittedOutput> {
473
- const fitted = await truncateOutput(registry, entry, screen, scrolled.lines);
592
+ const fitted = await truncateOutput(registry, entry, screen, scrolled.lines, scrollback);
474
593
  if (!scrolled.gap) return { ...fitted, gap: false };
475
594
  const notice = fitted.notice === undefined ? GAP_NOTICE : `${GAP_NOTICE}\n${fitted.notice}`;
476
595
  return { ...fitted, gap: true, notice };
@@ -481,24 +600,29 @@ async function truncateOutput(
481
600
  entry: TerminalEntry,
482
601
  screen: string,
483
602
  scrolled: readonly string[],
603
+ scrollback: LineByteLimits | undefined,
484
604
  ): Promise<Omit<FittedOutput, "gap">> {
485
605
  const limits = { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES };
486
- const scrolledText = scrolled.join("\n");
487
606
  const shownScreen = truncateTail(screen, limits);
488
- const lineBudget = limits.maxLines - shownScreen.outputLines;
489
- // One byte for the newline that joins the scrolled-off lines to the screen.
490
- const byteBudget = limits.maxBytes - shownScreen.outputBytes - 1;
491
- const shownScrolled =
492
- lineBudget > 0 && byteBudget > 0
493
- ? truncateTail(scrolledText, { maxLines: lineBudget, maxBytes: byteBudget })
494
- : emptyTruncation(scrolledText);
607
+ // A screen cut to fit leaves no room. One byte for the newline that joins the scrolled-off lines
608
+ // to the screen.
609
+ const room = shownScreen.truncated
610
+ ? { maxLines: 0, maxBytes: 0 }
611
+ : {
612
+ maxLines: limits.maxLines - shownScreen.outputLines,
613
+ maxBytes: limits.maxBytes - shownScreen.outputBytes - 1,
614
+ };
615
+ const shownScrolled = fitScrolled(scrolled, room, scrollback);
616
+ const scrolledText = scrolled.join("\n");
495
617
  if (!shownScreen.truncated && !shownScrolled.truncated)
496
618
  return { screen, scrolledOff: scrolledText };
497
619
 
498
620
  const total = shownScrolled.totalLines + shownScreen.totalLines;
499
621
  const shown = shownScrolled.outputLines + shownScreen.outputLines;
500
622
  const fitted = { screen: shownScreen.content, scrolledOff: shownScrolled.content };
501
- const range = `[Showing lines ${total - shown + 1}-${total} of ${total} (${formatSize(DEFAULT_MAX_BYTES)} or ${DEFAULT_MAX_LINES} line limit).`;
623
+ const summary =
624
+ shownScrolled.summary ??
625
+ `Showing lines ${total - shown + 1}-${total} of ${total} (${formatSize(DEFAULT_MAX_BYTES)} or ${DEFAULT_MAX_LINES} line limit).`;
502
626
  const directory = termctrlTemporaryDirectory();
503
627
  let path: string;
504
628
  try {
@@ -509,9 +633,21 @@ async function truncateOutput(
509
633
  await writeFile(path, full, { encoding: "utf8", mode: 0o600 });
510
634
  } catch (cause) {
511
635
  const reason = cause instanceof Error ? cause.message : String(cause);
512
- return { ...fitted, notice: `${range} The full output could not be saved: ${reason}]` };
636
+ return { ...fitted, notice: `[${summary} The full output could not be saved: ${reason}]` };
513
637
  }
514
- return { ...fitted, fullOutputPath: path, notice: `${range} Full output: ${path}]` };
638
+ return { ...fitted, fullOutputPath: path, notice: `[${summary} Full output: ${path}]` };
639
+ }
640
+
641
+ /**
642
+ * The rows of `screen` that the agent received, aligned with `screen`: rows cut from the top of a
643
+ * screen too large for the result, or shortened to fit it, are `undefined`.
644
+ */
645
+ function shownRows(screen: string, shown: string): (string | undefined)[] {
646
+ const rows = screen === "" ? [] : screen.split("\n");
647
+ if (shown === screen) return rows;
648
+ const kept = shown === "" ? [] : shown.split("\n");
649
+ const cut = rows.length - kept.length;
650
+ return rows.map((row, index) => (kept[index - cut] === row ? row : undefined));
515
651
  }
516
652
 
517
653
  /** Says that `wait_for_text` was not seen when its wait timed out. */
@@ -544,7 +680,7 @@ function formatTerminalText(
544
680
 
545
681
  /** Build the agent's view of a Terminal after a wait, recording the exit as seen. */
546
682
  async function terminalResult(
547
- registry: TermctrlRegistry,
683
+ { registry, scrollback }: TerminalResultRuntime,
548
684
  entry: TerminalEntry,
549
685
  snapshot: TerminalSnapshot | undefined,
550
686
  settleReason: SettleReason,
@@ -555,7 +691,9 @@ async function terminalResult(
555
691
  entry.state === "running" || snapshot !== undefined
556
692
  ? await takeScrolledOff(entry, screen)
557
693
  : NOTHING_SCROLLED;
558
- const output = await fitOutput(registry, entry, screen, scrolled);
694
+ const output = await fitOutput(registry, entry, screen, scrolled, scrollback());
695
+ // 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);
559
697
  if (snapshot?.state === "exited") {
560
698
  registry.terminalExited(entry.id, snapshot.exit ?? { code: null, signal: null }, screen, true);
561
699
  }
@@ -710,6 +848,8 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
710
848
  openWorldHint: true,
711
849
  },
712
850
  outputSchema: TerminalResultSchema,
851
+ renderCall: renderStartCall,
852
+ renderResult: renderTerminalResult,
713
853
  async execute(_toolCallId, params, signal, _onUpdate, context) {
714
854
  const cwd = await resolveWorkingDirectory(context.cwd, params.cwd);
715
855
  const entry = await runtime.registry.startTerminal(ownerOf(context), {
@@ -732,7 +872,7 @@ export function createTerminalStartTool(runtime: TerminalToolRuntime) {
732
872
  baseline: undefined,
733
873
  signal,
734
874
  });
735
- return terminalResult(runtime.registry, entry, snapshot, reason);
875
+ return terminalResult(runtime, entry, snapshot, reason);
736
876
  });
737
877
  },
738
878
  });
@@ -767,6 +907,8 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
767
907
  openWorldHint: true,
768
908
  },
769
909
  outputSchema: TerminalResultSchema,
910
+ renderCall: renderSendCall,
911
+ renderResult: renderTerminalResult,
770
912
  async execute(_toolCallId, params, signal, _onUpdate, context) {
771
913
  const entry = runtime.registry.find(ownerOf(context), params.id);
772
914
  if (entry === undefined) throw unknownId(params.id);
@@ -785,7 +927,7 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
785
927
  const hasInput = (params.text ?? "") !== "" || keys.length > 0;
786
928
  return driveTerminal(runtime.registry, entry, signal, async () => {
787
929
  if (entry.state === "exited") {
788
- if (!hasInput) return terminalResult(runtime.registry, entry, undefined, "exited");
930
+ if (!hasInput) return terminalResult(runtime, entry, undefined, "exited");
789
931
  // The error tells the agent about the exit, so a deferred Exit notification is redundant.
790
932
  runtime.registry.markSeen(entry.id);
791
933
  throw new InputToExitedError(entry.id, entry.exit);
@@ -814,7 +956,7 @@ export function createTerminalSendTool(runtime: TerminalToolRuntime) {
814
956
  signal,
815
957
  });
816
958
  return terminalResult(
817
- runtime.registry,
959
+ runtime,
818
960
  entry,
819
961
  snapshot,
820
962
  reason,
@@ -862,7 +1004,7 @@ function stopTerminalInTurn(
862
1004
  }
863
1005
 
864
1006
  /** `terminal_stop`: stop a Terminal or Background job and forget it. */
865
- export function createTerminalStopTool(registry: TermctrlRegistry) {
1007
+ export function createTerminalStopTool({ registry, scrollback }: TerminalResultRuntime) {
866
1008
  return defineTool<typeof StopParameters, StopResult>({
867
1009
  name: "terminal_stop",
868
1010
  label: "terminal_stop",
@@ -877,6 +1019,8 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
877
1019
  openWorldHint: false,
878
1020
  },
879
1021
  outputSchema: StopResultSchema,
1022
+ renderCall: renderStopCall,
1023
+ renderResult: renderStopResult,
880
1024
  async execute(_toolCallId, params, signal, _onUpdate, context) {
881
1025
  const owner = ownerOf(context);
882
1026
  const known = registry.find(owner, params.id);
@@ -906,7 +1050,13 @@ export function createTerminalStopTool(registry: TermctrlRegistry) {
906
1050
  const changed = previousScreen !== screen;
907
1051
  // A screen the agent already saw is not repeated, whether the Terminal was running or exited.
908
1052
  const repeated = !changed;
909
- const output = await fitOutput(registry, entry, repeated ? "" : screen, scrolled);
1053
+ const output = await fitOutput(
1054
+ registry,
1055
+ entry,
1056
+ repeated ? "" : screen,
1057
+ scrolled,
1058
+ scrollback(),
1059
+ );
910
1060
  result.changed = changed;
911
1061
  if (!repeated) result.screen = output.screen;
912
1062
  if (!repeated || output.scrolledOff !== "") result.scrolled_off = output.scrolledOff;
@@ -1033,6 +1183,8 @@ export function createTerminalWaitTool(runtime: TerminalWaitRuntime) {
1033
1183
  openWorldHint: false,
1034
1184
  },
1035
1185
  outputSchema: WaitResultSchema,
1186
+ renderCall: renderWaitCall,
1187
+ renderResult: renderWaitResult,
1036
1188
  async execute(_toolCallId, params, signal, _onUpdate, context) {
1037
1189
  const owner = ownerOf(context);
1038
1190
  const targets = waitTargets(registry, owner, params.ids);
@@ -1105,6 +1257,8 @@ export function createTerminalListTool(registry: TermctrlRegistry) {
1105
1257
  openWorldHint: false,
1106
1258
  },
1107
1259
  outputSchema: ListResultSchema,
1260
+ renderCall: renderListCall,
1261
+ renderResult: renderListResult,
1108
1262
  async execute(_toolCallId, _params, _signal, _onUpdate, context) {
1109
1263
  const now = Date.now();
1110
1264
  const entries = registry.ownedEntries(ownerOf(context));