@ian-pascoe/pi-termctrl 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,10 +64,11 @@ code or signal; a poll (neither `text` nor `keys`) still returns its final scree
64
64
 
65
65
  Results contain the visible screen, the log lines that scrolled off since the
66
66
  agent's previous result, `state`, `exit_code` or `signal` once exited, and
67
- `changed: false` when the screen matches the previous result. Scrolled-off lines
68
- include lines the agent saw on an earlier screen, so a line rewritten in place,
69
- such as a progress line, arrives in its final form. Tools that declare
70
- structured output give `codemode` scripts typed values.
67
+ `changed: false` when the screen matches the previous result. A line the agent
68
+ saw on an earlier screen is not repeated when it scrolls off unchanged; a line
69
+ rewritten in place since, such as a progress line or a prompt the agent typed
70
+ at, arrives again in its final form. Tools that declare structured output give
71
+ `codemode` scripts typed values.
71
72
 
72
73
  Scrolled-off lines are best effort: they come from termctrl's scrollback, which
73
74
  is limited (several hundred lines; fewer when lines are long) and drops its
@@ -80,11 +81,14 @@ scrollback holds before that result, its oldest lines are lost without
80
81
  `output_missing`, because termctrl does not report the trimming. Run commands
81
82
  whose complete output matters with `bash`, or redirect their output to a file.
82
83
 
83
- A result is limited to Pi's 2000 lines or 50 KB. The screen is kept first, from
84
- its bottom, and the newest scrolled-off lines fill the rest. When anything is
85
- cut, every line of the result goes to
86
- `$TMPDIR/pi-termctrl/<pid>-<id>-output-<n>.log`, named in the result's notice
87
- and `full_output_path`.
84
+ Scrolled-off lines are limited by `termctrl.scrollback` (100 lines or 16 KB by
85
+ default). Past it, a result keeps the first and last lines and puts a
86
+ `[… N lines omitted …]` line between them. A whole result is limited to Pi's
87
+ 2000 lines or 50 KB: the screen is kept first, from its bottom, and the
88
+ scrolled-off lines fill the rest. With `scrollback` set to `false`, only Pi's
89
+ limits apply and the newest scrolled-off lines are kept. When anything is cut,
90
+ every line of the result goes to `$TMPDIR/pi-termctrl/<pid>-<id>-output-<n>.log`,
91
+ named in the result's notice and `full_output_path`.
88
92
 
89
93
  `keys` are termctrl key names: `Enter`, `Escape`, `ArrowUp`, `ArrowDown`,
90
94
  `ArrowLeft`, `ArrowRight`, `Tab`, `Shift+Tab`, `Backspace`, `Delete`, `Home`,
@@ -185,9 +189,16 @@ the session that started the entry, and wait across `/reload`.
185
189
  `/ps` opens an overlay listing every entry in the Pi process, including those of
186
190
  in-process child agents, with kind, id, owner (`root` or `child`), state and
187
191
  age, plus a live preview of the selected Terminal's screen or Background job's
188
- log tail. Keys: `↑`/`↓` select, `k` stops without confirmation (the agent gets an
189
- Exit notification), `x` removes an exited entry, `Esc` closes. The footer shows
190
- `N running` while anything is live. In RPC mode `/ps` sends a one-line summary;
192
+ log tail. It uses Pi's selector frame, with a Status Mark before each entry
193
+ (`●` running, `✓` exited cleanly, `✗` failed, `■` ended by a signal). Keys:
194
+ `↑`/`↓` select, `k` stops without confirmation (the agent gets an Exit
195
+ notification), `x` removes an exited entry, `Esc` closes. The footer shows
196
+ `● termctrl N running` while anything is live.
197
+
198
+ The `terminal_*` tools render like `bash` in the transcript: a header with the
199
+ tool name and target, the last 5 screen lines (`ctrl+o` expands), and a `Took`
200
+ footer. Exit notifications show one row per exit, marked `✓` for a clean exit and
201
+ `■` otherwise. In RPC mode `/ps` sends a one-line summary;
191
202
  print mode has no UI but keeps the tools and notifications.
192
203
 
193
204
  ## Lifecycle
@@ -214,17 +225,19 @@ by field:
214
225
  "replaceBash": true,
215
226
  "defaultViewport": { "cols": 120, "rows": 40 },
216
227
  "exitTailLines": 20,
217
- "bashTail": { "lines": 300, "bytes": 16384 }
228
+ "bashTail": { "lines": 300, "bytes": 16384 },
229
+ "scrollback": { "lines": 100, "bytes": 16384 }
218
230
  }
219
231
  }
220
232
  ```
221
233
 
222
- | Setting | Default | Meaning |
223
- | ----------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------ |
224
- | `replaceBash` | `true` | Replace `bash` to enable Background jobs |
225
- | `defaultViewport` | `{ cols: 120, rows: 40 }` | Terminal size; each dimension 1 to 1000 |
226
- | `exitTailLines` | `20` | Lines of output in an Exit notification, 0–1000 |
227
- | `bashTail` | `{ lines: 300, bytes: 16384 }` | Model-visible `bash` output tail: `lines` 1–2000, `bytes` 1–51200; `false` or `0` restores Pi's limits |
234
+ | Setting | Default | Meaning |
235
+ | ----------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------- |
236
+ | `replaceBash` | `true` | Replace `bash` to enable Background jobs |
237
+ | `defaultViewport` | `{ cols: 120, rows: 40 }` | Terminal size; each dimension 1 to 1000 |
238
+ | `exitTailLines` | `20` | Lines of output in an Exit notification, 0–1000 |
239
+ | `bashTail` | `{ lines: 300, bytes: 16384 }` | Model-visible `bash` output tail: `lines` 1–2000, `bytes` 1–51200; `false` or `0` restores Pi's limits |
240
+ | `scrollback` | `{ lines: 100, bytes: 16384 }` | Scrolled-off lines in a Terminal result, keeping the first and last; same ranges and opt-out as `bashTail` |
228
241
 
229
242
  Invalid values keep the lower layer and unknown keys produce warnings at session
230
243
  start. Use `/reload` after editing settings.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-termctrl",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "description": "Interactive Terminals, Background jobs, and a /ps panel for Pi.",
6
6
  "keywords": [
@@ -34,11 +34,12 @@
34
34
  "provenance": true
35
35
  },
36
36
  "dependencies": {
37
+ "@ian-pascoe/pi-utils": "^0.5.0",
37
38
  "@kitlangton/terminal-control": "^1.2.1"
38
39
  },
39
40
  "peerDependencies": {
40
- "@earendil-works/pi-coding-agent": ">=0.99.0",
41
- "@earendil-works/pi-tui": ">=0.99.0",
41
+ "@earendil-works/pi-coding-agent": ">=1.1.0",
42
+ "@earendil-works/pi-tui": ">=1.1.0",
42
43
  "typebox": "*"
43
44
  },
44
45
  "engines": {
@@ -11,7 +11,7 @@ disable-model-invocation: true
11
11
  2. If `terminal_start` is missing, check the startup diagnostic. Confirm the platform has a packaged `termctrl` binary or that `TERMCTRL_BINARY` names an executable one.
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
- 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.
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
15
  6. Classify the failure as settings, binary resolution, `bash` conflict, cap, or process behaviour.
16
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.
17
17
 
@@ -19,9 +19,10 @@ import {
19
19
  type TruncationResult,
20
20
  } from "@earendil-works/pi-coding-agent";
21
21
  import { matchesKey } from "@earendil-works/pi-tui";
22
+ import { noticeText } from "@ian-pascoe/pi-utils/ui";
22
23
  import { Type, type TSchema } from "typebox";
23
24
  import { KILLED_EXIT, TermctrlRegistry } from "./termctrl-registry.js";
24
- import type { BashTailLimits } from "./pi-termctrl-settings.js";
25
+ import type { LineByteLimits } from "./pi-termctrl-settings.js";
25
26
  import { termctrlTemporaryDirectory } from "./termctrl-driver.js";
26
27
  import type { TerminalExit } from "./terminal-driver.js";
27
28
 
@@ -264,7 +265,10 @@ export class RunningBashCalls {
264
265
  try {
265
266
  call.background(this.registry());
266
267
  } catch (error) {
267
- ui.notify(error instanceof Error ? error.message : String(error), "warning");
268
+ ui.notify(
269
+ noticeText("Termctrl", error instanceof Error ? error.message : String(error)),
270
+ "warning",
271
+ );
268
272
  }
269
273
  }
270
274
  }
@@ -286,7 +290,7 @@ export interface BashReplacementOptions {
286
290
  * Limits on the model-visible tail of a result and of "output so far"; undefined keeps Pi's own
287
291
  * limits. Pi's `bash` accepts no limits, so the replacement tightens what Pi returns.
288
292
  */
289
- readonly bashTail?: BashTailLimits | undefined;
293
+ readonly bashTail?: LineByteLimits | undefined;
290
294
  /** Builds Pi's `bash` definition; tests wrap it to observe what reaches Pi's accumulator. */
291
295
  readonly definitionFactory?: typeof createBashToolDefinition;
292
296
  }
@@ -377,7 +381,7 @@ function limitForegroundText(
377
381
  call: BashCall,
378
382
  piText: string,
379
383
  piDetails: BashToolDetails | undefined,
380
- limits: BashTailLimits,
384
+ limits: LineByteLimits,
381
385
  hasStatus: boolean,
382
386
  ) {
383
387
  const output = call.foregroundOutput();
@@ -403,7 +407,7 @@ function limitForegroundText(
403
407
  function backgroundResult(
404
408
  outcome: BackgroundOutcome,
405
409
  startedAt: number,
406
- limits: BashTailLimits | undefined,
410
+ limits: LineByteLimits | undefined,
407
411
  ) {
408
412
  const truncation = truncateBuffered(outcome.output, outcome.dropped, limits ?? {});
409
413
  let text = truncation.content;
@@ -427,7 +431,7 @@ function backgroundResult(
427
431
 
428
432
  type BashResult = Awaited<ReturnType<ReturnType<typeof createBashToolDefinition>["execute"]>>;
429
433
 
430
- function limitResult(call: BashCall, result: BashResult, limits: BashTailLimits): BashResult {
434
+ function limitResult(call: BashCall, result: BashResult, limits: LineByteLimits): BashResult {
431
435
  const [first] = result.content;
432
436
  if (first?.type !== "text") return result;
433
437
  const limited = limitForegroundText(
@@ -445,7 +449,7 @@ function limitResult(call: BashCall, result: BashResult, limits: BashTailLimits)
445
449
  };
446
450
  }
447
451
 
448
- function limitError(call: BashCall, error: Error, limits: BashTailLimits): Error {
452
+ function limitError(call: BashCall, error: Error, limits: LineByteLimits): Error {
449
453
  // Only Pi's output-plus-status messages are rewritten; any other error passes through.
450
454
  if (!PI_STATUS.test(error.message)) return error;
451
455
  const limited = limitForegroundText(call, error.message, undefined, limits, true);
@@ -453,7 +457,7 @@ function limitError(call: BashCall, error: Error, limits: BashTailLimits): Error
453
457
  }
454
458
 
455
459
  /** Pi's description names its own limits; name the ones in force instead. */
456
- function describeTail(description: string, limits: BashTailLimits | undefined): string {
460
+ function describeTail(description: string, limits: LineByteLimits | undefined): string {
457
461
  if (limits === undefined) return description;
458
462
  const piLimits = `${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB`;
459
463
  const ours = `${limits.maxLines} lines or ${formatSize(limits.maxBytes)}`;
@@ -1,5 +1,14 @@
1
1
  import type { MessageRenderer } from "@earendil-works/pi-coding-agent";
2
2
  import { Text, type Component } from "@earendil-works/pi-tui";
3
+ import {
4
+ COLLAPSED_LINES,
5
+ customMessageBox,
6
+ expandHint,
7
+ joinInline,
8
+ previewBody,
9
+ statusMark,
10
+ summaryExpandHint,
11
+ } from "@ian-pascoe/pi-utils/ui";
3
12
  import { Type, type Static } from "typebox";
4
13
  import { Value } from "typebox/value";
5
14
  import type { ExitNotice } from "./termctrl-registry.js";
@@ -110,19 +119,55 @@ function messageText(content: RenderedMessage["content"]): string {
110
119
  return content.map((part) => (part.type === "text" ? part.text : "")).join("");
111
120
  }
112
121
 
113
- /** Compact renderer: one line per exit, with the full text when expanded. */
122
+ type MessageTheme = Parameters<ExitNotificationRenderer>[2];
123
+
124
+ function exitRow(theme: MessageTheme, exit: ExitNotificationDetails["exits"][number]): string {
125
+ const done = exit.exit_code === 0 && exit.signal === null;
126
+ const body = (text: string) => theme.fg("customMessageText", text);
127
+ const status = describeExitStatus({ code: exit.exit_code, signal: exit.signal });
128
+ return `${statusMark(theme, done ? "done" : "stopped")} ${body(theme.bold(exit.id))} ${joinInline(
129
+ theme,
130
+ [body(status), body(formatDuration(exit.duration_ms)), theme.fg("muted", exit.command)],
131
+ )}`;
132
+ }
133
+
134
+ /** The Collapsed View's rows: at most 10, then the Expand Hint for the rest or for the full text. */
135
+ function collapsedRows(theme: MessageTheme, rows: readonly string[]): string[] {
136
+ const hidden = rows.length - COLLAPSED_LINES.fallback;
137
+ if (hidden > 0) return [...rows.slice(0, COLLAPSED_LINES.fallback), expandHint(theme, hidden)];
138
+ const last = rows.length - 1;
139
+ return rows.map((row, index) => (index === last ? `${row}${summaryExpandHint(theme)}` : row));
140
+ }
141
+
142
+ /** `exit`, or `N exits` when several Terminals or Background jobs ended together. */
143
+ function exitHeading(details: ExitNotificationDetails | undefined): string {
144
+ const count = Value.Check(ExitNotificationDetailsSchema, details) ? details.exits.length : 1;
145
+ return count === 1 ? "exit" : `${count} exits`;
146
+ }
147
+
148
+ /**
149
+ * Pi's custom-message look: a `customMessageBg` box under a bold `[termctrl] exit` label.
150
+ * Collapsed, it shows one Status Mark row per exit; expanded, the full text.
151
+ */
114
152
  export const renderExitNotification: ExitNotificationRenderer = (
115
153
  message,
116
154
  options,
117
155
  theme,
118
156
  ): Component => {
119
- if (options.expanded || !Value.Check(ExitNotificationDetailsSchema, message.details)) {
120
- return new Text(theme.fg("muted", messageText(message.content)), options.outputPad, 0);
121
- }
122
- const lines = message.details.exits.map((exit) => {
123
- const failed = exit.exit_code !== 0 || exit.signal !== null;
124
- const status = describeExitStatus({ code: exit.exit_code, signal: exit.signal });
125
- return `${theme.fg(failed ? "warning" : "success", "■")} ${theme.bold(exit.id)} ${status} · ${formatDuration(exit.duration_ms)} · ${theme.fg("muted", exit.command)}`;
126
- });
127
- return new Text(lines.join("\n"), options.outputPad, 0);
157
+ const details = message.details;
158
+ const rows =
159
+ !options.expanded && Value.Check(ExitNotificationDetailsSchema, details)
160
+ ? collapsedRows(
161
+ theme,
162
+ details.exits.map((exit) => exitRow(theme, exit)),
163
+ )
164
+ : previewBody(theme, messageText(message.content).replace(/\n+$/u, "").split("\n"), {
165
+ limit: COLLAPSED_LINES.fallback,
166
+ expanded: options.expanded,
167
+ color: "customMessageText",
168
+ });
169
+ const heading = theme.fg("customMessageText", exitHeading(details));
170
+ return customMessageBox(theme, { outputPad: options.outputPad, source: "termctrl", heading }, [
171
+ new Text(rows.join("\n"), 0, 0),
172
+ ]);
128
173
  };
@@ -7,6 +7,7 @@ import {
7
7
  type ExtensionFactory,
8
8
  type SessionShutdownEvent,
9
9
  } from "@earendil-works/pi-coding-agent";
10
+ import { expandMessageOnClick, noticeText } from "@ian-pascoe/pi-utils/ui";
10
11
  import { createBashReplacement, RunningBashCalls } from "./bash-replacement.js";
11
12
  import {
12
13
  EXIT_NOTIFICATION_TYPE,
@@ -17,7 +18,11 @@ import { TermctrlPsController } from "./ps-panel.js";
17
18
  import { resolveTermctrlBinary, type TermctrlBinaryResolution } from "./termctrl-binary.js";
18
19
  import { createTermctrlDriver } from "./termctrl-driver.js";
19
20
  import { TermctrlRegistry } from "./termctrl-registry.js";
20
- import { resolveTermctrlSettings, type TerminalViewport } from "./pi-termctrl-settings.js";
21
+ import {
22
+ resolveTermctrlSettings,
23
+ type LineByteLimits,
24
+ type TerminalViewport,
25
+ } from "./pi-termctrl-settings.js";
21
26
  import type { TerminalDriver } from "./terminal-driver.js";
22
27
  import {
23
28
  createTerminalListTool,
@@ -41,6 +46,7 @@ interface ActiveSession {
41
46
  readonly ps: TermctrlPsController;
42
47
  readonly shell: () => TerminalShell;
43
48
  readonly viewport: TerminalViewport;
49
+ readonly scrollback: LineByteLimits | undefined;
44
50
  readonly exitTailLines: number;
45
51
  }
46
52
 
@@ -79,7 +85,10 @@ class PiTermctrlController {
79
85
  }
80
86
 
81
87
  register(): void {
82
- this.pi.registerMessageRenderer(EXIT_NOTIFICATION_TYPE, renderExitNotification);
88
+ this.pi.registerMessageRenderer(
89
+ EXIT_NOTIFICATION_TYPE,
90
+ expandMessageOnClick(renderExitNotification),
91
+ );
83
92
  this.pi.registerCommand("ps", {
84
93
  description: "Show and stop Terminals and Background jobs",
85
94
  handler: async () => {
@@ -91,6 +100,7 @@ class PiTermctrlController {
91
100
  registry: this.registry,
92
101
  shell: () => this.session().shell(),
93
102
  viewport: () => this.session().viewport,
103
+ scrollback: () => this.session().scrollback,
94
104
  };
95
105
  this.pi.registerTool(createTerminalStartTool(runtime));
96
106
  this.pi.registerTool(createTerminalSendTool(runtime));
@@ -120,7 +130,12 @@ class PiTermctrlController {
120
130
  }
121
131
 
122
132
  private registerManagementTools(): void {
123
- this.pi.registerTool(createTerminalStopTool(this.registry));
133
+ this.pi.registerTool(
134
+ createTerminalStopTool({
135
+ registry: this.registry,
136
+ scrollback: () => this.session().scrollback,
137
+ }),
138
+ );
124
139
  this.pi.registerTool(createTerminalListTool(this.registry));
125
140
  this.pi.registerTool(
126
141
  createTerminalWaitTool({
@@ -143,7 +158,10 @@ class PiTermctrlController {
143
158
  );
144
159
  const settings = resolveTermctrlSettings(settingsManager);
145
160
  if (settings.warnings.length > 0) {
146
- context.ui.notify(`Pi Termctrl settings:\n- ${settings.warnings.join("\n- ")}`, "warning");
161
+ context.ui.notify(
162
+ noticeText("Termctrl", `Settings:\n- ${settings.warnings.join("\n- ")}`),
163
+ "warning",
164
+ );
147
165
  }
148
166
  const registry = this.registry;
149
167
  const owner = context.sessionManager.getSessionId();
@@ -167,7 +185,10 @@ class PiTermctrlController {
167
185
  const commandPrefix = settingsManager.getShellCommandPrefix();
168
186
  if (this.binary.kind === "missing") {
169
187
  context.ui.notify(
170
- `Pi Termctrl: Terminal tools are unavailable: ${this.binary.reason}\nRun /skill:pi-termctrl to diagnose.`,
188
+ noticeText(
189
+ "Termctrl",
190
+ `Terminal tools are unavailable: ${this.binary.reason}\nRun /skill:pi-termctrl to diagnose.`,
191
+ ),
171
192
  "warning",
172
193
  );
173
194
  if (settings.replaceBash) this.registerManagementTools();
@@ -195,6 +216,7 @@ class PiTermctrlController {
195
216
  return { shell: shell.shell, args: shell.args, commandPrefix };
196
217
  },
197
218
  viewport: settings.defaultViewport,
219
+ scrollback: settings.scrollback,
198
220
  exitTailLines: settings.exitTailLines,
199
221
  };
200
222
  }
@@ -20,16 +20,24 @@ export interface TerminalViewport {
20
20
  readonly rows: number;
21
21
  }
22
22
 
23
- /** The most lines and bytes of the model-visible `bash` output: a foreground result or the output so far of a backgrounding one. */
24
- export type BashTailLimits = Readonly<Required<TruncationOptions>>;
23
+ /** The most lines and bytes of model-visible output that a setting allows. */
24
+ export type LineByteLimits = Readonly<Required<TruncationOptions>>;
25
25
 
26
26
  /** The effective `settings.termctrl` values after layering global and trusted project settings. */
27
27
  export interface ResolvedTermctrlSettings {
28
28
  readonly replaceBash: boolean;
29
29
  readonly defaultViewport: TerminalViewport;
30
30
  readonly exitTailLines: number;
31
- /** Limits on the model-visible `bash` tail; undefined restores Pi's own limits. */
32
- readonly bashTail: BashTailLimits | undefined;
31
+ /**
32
+ * Limits on the model-visible `bash` output: a foreground result or the output so far of a
33
+ * backgrounding one. Undefined restores Pi's own limits.
34
+ */
35
+ readonly bashTail: LineByteLimits | undefined;
36
+ /**
37
+ * Limits on the scrolled-off lines a Terminal result shows, kept from their start and end.
38
+ * Undefined restores Pi's own limits on the whole result.
39
+ */
40
+ readonly scrollback: LineByteLimits | undefined;
33
41
  readonly warnings: readonly string[];
34
42
  }
35
43
 
@@ -44,31 +52,42 @@ export interface TermctrlSettingsReader {
44
52
  getProjectSettings(): TermctrlSettingsDocumentInput;
45
53
  }
46
54
 
55
+ /** The settings that limit model-visible output. */
56
+ type LineByteLimitKey = "bashTail" | "scrollback";
57
+
58
+ /** One layer's value for a `bashTail` or `scrollback` setting. */
59
+ interface LineByteLimitsLayer {
60
+ enabled?: boolean;
61
+ lines?: number;
62
+ bytes?: number;
63
+ }
64
+
47
65
  interface TermctrlLayer {
48
66
  replaceBash?: boolean;
49
67
  cols?: number;
50
68
  rows?: number;
51
69
  exitTailLines?: number;
52
- bashTailEnabled?: boolean;
53
- bashTailLines?: number;
54
- bashTailBytes?: number;
70
+ bashTail?: LineByteLimitsLayer;
71
+ scrollback?: LineByteLimitsLayer;
55
72
  }
56
73
 
57
- export const DEFAULT_TERMCTRL_SETTINGS: Omit<ResolvedTermctrlSettings, "warnings" | "bashTail"> & {
58
- readonly bashTail: BashTailLimits;
59
- } = {
74
+ export const DEFAULT_TERMCTRL_SETTINGS: Omit<
75
+ ResolvedTermctrlSettings,
76
+ "warnings" | LineByteLimitKey
77
+ > & { readonly [key in LineByteLimitKey]: LineByteLimits } = {
60
78
  replaceBash: true,
61
79
  defaultViewport: { cols: 120, rows: 40 },
62
80
  exitTailLines: 20,
63
81
  bashTail: { maxLines: 300, maxBytes: 16 * 1024 },
82
+ scrollback: { maxLines: 100, maxBytes: 16 * 1024 },
64
83
  };
65
84
 
66
85
  const JsonObjectSchema = Type.Record(Type.String(), Type.Any());
67
86
  const SettingsDocumentSchema = Type.Object({ termctrl: Type.Optional(Type.Any()) });
68
87
  const BooleanSchema = Type.Boolean();
69
88
  const ViewportDimensionSchema = Type.Integer({ minimum: 1, maximum: VIEWPORT_LIMIT });
70
- const BashTailLinesSchema = Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_LINES });
71
- const BashTailBytesSchema = Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_BYTES });
89
+ const LimitLinesSchema = Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_LINES });
90
+ const LimitBytesSchema = Type.Integer({ minimum: 1, maximum: DEFAULT_MAX_BYTES });
72
91
  const ExitTailLinesSchema = Type.Integer({ minimum: 0, maximum: EXIT_TAIL_LIMIT });
73
92
 
74
93
  function isJsonObject(value: JsonValue): value is JsonObject {
@@ -103,7 +122,8 @@ function readLayer(
103
122
  else warnings.push(`${path}: expected an integer from 0 to ${EXIT_TAIL_LIMIT}`);
104
123
  break;
105
124
  case "bashTail":
106
- readBashTail(value, path, layer, warnings);
125
+ case "scrollback":
126
+ layer[key] = readLineByteLimits(value, path, warnings);
107
127
  break;
108
128
  case "defaultViewport":
109
129
  readViewport(value, path, layer, warnings);
@@ -130,29 +150,28 @@ function readViewport(value: JsonValue, path: string, layer: TermctrlLayer, warn
130
150
  }
131
151
  }
132
152
 
133
- function readBashTail(value: JsonValue, path: string, layer: TermctrlLayer, warnings: string[]) {
134
- if (Value.Check(BooleanSchema, value)) {
135
- layer.bashTailEnabled = value;
136
- return;
137
- }
138
- if (value === 0) {
139
- layer.bashTailEnabled = false;
140
- return;
141
- }
153
+ function readLineByteLimits(
154
+ value: JsonValue,
155
+ path: string,
156
+ warnings: string[],
157
+ ): LineByteLimitsLayer {
158
+ if (Value.Check(BooleanSchema, value)) return { enabled: value };
159
+ if (value === 0) return { enabled: false };
142
160
  if (!isJsonObject(value)) {
143
161
  warnings.push(`${path}: expected a boolean, 0, or a JSON object`);
144
- return;
162
+ return {};
145
163
  }
164
+ const limits: LineByteLimitsLayer = {};
146
165
  let valid = 0;
147
166
  for (const [key, limit] of Object.entries(value)) {
148
167
  if (key === "lines") {
149
- if (Value.Check(BashTailLinesSchema, limit)) {
150
- layer.bashTailLines = limit;
168
+ if (Value.Check(LimitLinesSchema, limit)) {
169
+ limits.lines = limit;
151
170
  valid++;
152
171
  } else warnings.push(`${path}.lines: expected an integer from 1 to ${DEFAULT_MAX_LINES}`);
153
172
  } else if (key === "bytes") {
154
- if (Value.Check(BashTailBytesSchema, limit)) {
155
- layer.bashTailBytes = limit;
173
+ if (Value.Check(LimitBytesSchema, limit)) {
174
+ limits.bytes = limit;
156
175
  valid++;
157
176
  } else warnings.push(`${path}.bytes: expected an integer from 1 to ${DEFAULT_MAX_BYTES}`);
158
177
  } else {
@@ -160,7 +179,24 @@ function readBashTail(value: JsonValue, path: string, layer: TermctrlLayer, warn
160
179
  }
161
180
  }
162
181
  // An object with only invalid fields must not override a lower layer's opt-out.
163
- if (valid > 0 || Object.keys(value).length === 0) layer.bashTailEnabled = true;
182
+ if (valid > 0 || Object.keys(value).length === 0) limits.enabled = true;
183
+ return limits;
184
+ }
185
+
186
+ /** Layer a `bashTail` or `scrollback` setting: the project's fields over the global ones, over the defaults. */
187
+ function resolveLineByteLimits(
188
+ key: LineByteLimitKey,
189
+ globalLayer: TermctrlLayer,
190
+ projectLayer: TermctrlLayer,
191
+ ): LineByteLimits | undefined {
192
+ const global = globalLayer[key];
193
+ const project = projectLayer[key];
194
+ if (!(project?.enabled ?? global?.enabled ?? true)) return undefined;
195
+ const defaults = DEFAULT_TERMCTRL_SETTINGS[key];
196
+ return {
197
+ maxLines: project?.lines ?? global?.lines ?? defaults.maxLines,
198
+ maxBytes: project?.bytes ?? global?.bytes ?? defaults.maxBytes,
199
+ };
164
200
  }
165
201
 
166
202
  /** Resolve global and trusted-project `termctrl` settings, keeping valid fields around warnings. */
@@ -177,15 +213,8 @@ export function resolveTermctrlSettings(reader: TermctrlSettingsReader): Resolve
177
213
  },
178
214
  exitTailLines:
179
215
  projectLayer.exitTailLines ?? globalLayer.exitTailLines ?? defaults.exitTailLines,
180
- bashTail:
181
- (projectLayer.bashTailEnabled ?? globalLayer.bashTailEnabled ?? true)
182
- ? {
183
- maxLines:
184
- projectLayer.bashTailLines ?? globalLayer.bashTailLines ?? defaults.bashTail.maxLines,
185
- maxBytes:
186
- projectLayer.bashTailBytes ?? globalLayer.bashTailBytes ?? defaults.bashTail.maxBytes,
187
- }
188
- : undefined,
216
+ bashTail: resolveLineByteLimits("bashTail", globalLayer, projectLayer),
217
+ scrollback: resolveLineByteLimits("scrollback", globalLayer, projectLayer),
189
218
  warnings,
190
219
  };
191
220
  }