@cruxy/cli 1.6.0 → 1.7.1

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.
@@ -5,34 +5,93 @@ import { resolveToolPath, toPosix } from "./paths.js";
5
5
  import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
6
6
  /** How many leading lines of a created file the approval preview shows. */
7
7
  const PREVIEW_LINES = 20;
8
- const HunkSchema = z.object({
8
+ /**
9
+ * ONE FLAT OPERATION SHAPE — deliberately flat, and it must stay that way.
10
+ *
11
+ * The provider rejects a tool schema nested 8 or more levels deep, and it
12
+ * rejects the entire REQUEST when any tool trips it: one over-deep schema kills
13
+ * every turn of every session, which is exactly how 1.7.0 died in the field.
14
+ * JSON Schema's own wrapper keys (`properties`, `items`, `anyOf`) are containers
15
+ * too, so every semantic level an author writes costs two, and a union costs two
16
+ * more on top. The previous shape — a discriminated union of operations, each
17
+ * carrying an array of hunk OBJECTS — rendered 11 levels deep.
18
+ *
19
+ * Neither lever alone was enough (both measured, see schema-depth.test.ts):
20
+ * keeping the union and flattening hunks still renders 8; dropping the union and
21
+ * keeping the hunk array renders 9. So both go. The discriminator survives as a
22
+ * plain `type` enum, and the per-variant field requirements the union used to
23
+ * encode are enforced by {@link refineOperation} below — `superRefine` is a
24
+ * runtime check that adds NO depth to the rendered schema, so the model still
25
+ * gets a precise rejection for a malformed operation, just from zod instead of
26
+ * from the schema's shape.
27
+ *
28
+ * The multi-hunk capability is NOT lost: a path may appear in as many `update`
29
+ * operations as it likes, and they apply in order against the running content —
30
+ * the same semantics the `hunks` array had, spelled one hunk per operation.
31
+ */
32
+ const OperationSchema = z
33
+ .object({
34
+ type: z
35
+ .enum(["update", "create", "delete"])
36
+ .describe('What to do: "update" replaces oldStr with newStr in an existing file; ' +
37
+ '"create" writes a new file from content; "delete" removes an existing file.'),
38
+ path: z.string().describe("Path to the file, relative to the root."),
9
39
  oldStr: z
10
40
  .string()
11
41
  .min(1)
12
- .describe("Exact text to replace; must occur exactly once in the file."),
13
- newStr: z.string().describe("Replacement text."),
14
- });
15
- const OperationSchema = z.discriminatedUnion("type", [
16
- z.object({
17
- type: z.literal("update"),
18
- path: z
19
- .string()
20
- .describe("Path to an existing file, relative to the root."),
21
- hunks: z
22
- .array(HunkSchema)
23
- .min(1)
24
- .describe("Edits applied in order; each oldStr must match exactly once."),
25
- }),
26
- z.object({
27
- type: z.literal("create"),
28
- path: z.string().describe("Path for a new file; must not already exist."),
29
- content: z.string().describe("Full UTF-8 contents of the new file."),
30
- }),
31
- z.object({
32
- type: z.literal("delete"),
33
- path: z.string().describe("Path to an existing file to delete."),
34
- }),
35
- ]);
42
+ .optional()
43
+ .describe("update only — exact text to replace; must occur exactly once in the " +
44
+ "file as it stands when this operation runs."),
45
+ newStr: z
46
+ .string()
47
+ .optional()
48
+ .describe("update only — the replacement text (may be empty to delete)."),
49
+ content: z
50
+ .string()
51
+ .optional()
52
+ .describe("create only — full UTF-8 contents of the new file."),
53
+ })
54
+ .superRefine(refineOperation);
55
+ /**
56
+ * The per-variant requirements the discriminated union used to express in the
57
+ * schema. Enforced here so a malformed operation is still rejected before
58
+ * `execute` runs, with a message naming the exact field.
59
+ */
60
+ function refineOperation(op, ctx) {
61
+ const needs = (field) => {
62
+ if (op[field] === undefined) {
63
+ ctx.addIssue({
64
+ code: z.ZodIssueCode.custom,
65
+ path: [field],
66
+ message: `"${field}" is required when type is "${op.type}"`,
67
+ });
68
+ }
69
+ };
70
+ const forbid = (field) => {
71
+ if (op[field] !== undefined) {
72
+ ctx.addIssue({
73
+ code: z.ZodIssueCode.custom,
74
+ path: [field],
75
+ message: `"${field}" is not allowed when type is "${op.type}"`,
76
+ });
77
+ }
78
+ };
79
+ if (op.type === "update") {
80
+ needs("oldStr");
81
+ needs("newStr");
82
+ forbid("content");
83
+ return;
84
+ }
85
+ if (op.type === "create") {
86
+ needs("content");
87
+ forbid("oldStr");
88
+ forbid("newStr");
89
+ return;
90
+ }
91
+ forbid("oldStr");
92
+ forbid("newStr");
93
+ forbid("content");
94
+ }
36
95
  const parameters = z.object({
37
96
  operations: z
38
97
  .array(OperationSchema)
@@ -50,14 +109,19 @@ export const applyPatchTool = {
50
109
  name: "apply_patch",
51
110
  description: "Apply multiple edits across one or more files in a single, atomic, reviewed change — preferred over many edit_file calls for multi-file or multi-hunk work. " +
52
111
  "Input is { operations: [...] } where each operation is one of: " +
53
- '{ "type":"update", "path", "hunks":[{ "oldStr", "newStr" }] } — replace each oldStr (which must match EXACTLY ONCE in the file, like edit_file; hunks apply in order) with newStr; ' +
112
+ '{ "type":"update", "path", "oldStr", "newStr" } — replace oldStr (which must match EXACTLY ONCE in the file, like edit_file) with newStr; ' +
54
113
  '{ "type":"create", "path", "content" } — create a new file (must not already exist); ' +
55
114
  '{ "type":"delete", "path" } — delete an existing file. ' +
115
+ "To make several edits to the SAME file, list several update operations with the same path: they apply in order, each one matching against the result of the previous. " +
116
+ "A path used by a create or a delete may appear only once. " +
56
117
  "The whole patch is validated before anything is written: if any operation is invalid, nothing is applied and the failing operation is reported.",
57
118
  parameters,
58
119
  async execute(input, ctx) {
59
- const planned = [];
60
- const seen = new Set();
120
+ // One track per path, in first-touch order. Repeated `update` operations on
121
+ // a path fold into its track, each hunk matching against the running content
122
+ // — so a file is still written exactly once, from one final byte string.
123
+ const tracks = new Map();
124
+ const order = [];
61
125
  for (let i = 0; i < input.operations.length; i++) {
62
126
  const op = input.operations[i];
63
127
  let abs;
@@ -67,18 +131,29 @@ export const applyPatchTool = {
67
131
  catch (err) {
68
132
  return { ok: false, error: opError(i, op, err.message) };
69
133
  }
70
- if (seen.has(abs)) {
71
- return {
72
- ok: false,
73
- error: opError(i, op, "duplicate path in patch"),
74
- };
134
+ const existing = tracks.get(abs);
135
+ if (existing) {
136
+ // Only an update chain may share a path. A create or a delete alongside
137
+ // anything else on the same path is an order-dependent muddle, and the
138
+ // shape that preceded this one couldn't express it either.
139
+ if (existing.kind !== "update" || op.type !== "update") {
140
+ return {
141
+ ok: false,
142
+ error: opError(i, op, `path already used by operation ${existing.firstOp + 1}; only repeated "update" operations may share a path`),
143
+ };
144
+ }
145
+ const failure = applyHunk(i, op, existing);
146
+ if (failure)
147
+ return { ok: false, error: failure };
148
+ continue;
75
149
  }
76
- seen.add(abs);
77
- const planResult = await planOp(i, op, abs, ctx);
78
- if (!planResult.ok)
79
- return planResult;
80
- planned.push(planResult.planned);
150
+ const opened = await openTrack(i, op, abs, ctx);
151
+ if (!opened.ok)
152
+ return opened;
153
+ tracks.set(abs, opened.track);
154
+ order.push(abs);
81
155
  }
156
+ const planned = order.map((abs) => toPlanned(tracks.get(abs)));
82
157
  // One approval for the whole patch — denial writes nothing.
83
158
  const decision = await ctx.requestApproval({
84
159
  kind: "patch",
@@ -114,27 +189,31 @@ export const applyPatchTool = {
114
189
  return { ok: true, output: `applied patch:\n${applied.join("\n")}` };
115
190
  },
116
191
  };
117
- /** Validate one operation against the filesystem and compute its final bytes. */
118
- async function planOp(i, op, abs, ctx) {
192
+ /** Validate the FIRST operation on a path and open its track. */
193
+ async function openTrack(i, op, abs, ctx) {
119
194
  // Forward-slash for model-facing output (the `applied` lines and error
120
195
  // messages), consistent with every other path tool — see {@link toPosix}.
121
196
  const rel = toPosix(path.relative(ctx.cwd, abs));
197
+ const base = { abs, rel, firstOp: i, hunks: [] };
122
198
  if (op.type === "create") {
123
199
  if (await exists(abs)) {
124
200
  return { ok: false, error: opError(i, op, "file already exists") };
125
201
  }
202
+ const content = op.content ?? "";
126
203
  return {
127
204
  ok: true,
128
- planned: { op: "create", abs, rel, content: op.content },
205
+ track: { ...base, kind: "create", content, eol: detectEol(content) },
129
206
  };
130
207
  }
131
208
  if (op.type === "delete") {
132
209
  if (!(await exists(abs))) {
133
210
  return { ok: false, error: opError(i, op, "file not found") };
134
211
  }
135
- return { ok: true, planned: { op: "delete", abs, rel } };
212
+ return {
213
+ ok: true,
214
+ track: { ...base, kind: "delete", content: "", eol: "\n" },
215
+ };
136
216
  }
137
- // update: read, then apply each hunk in order against the running content.
138
217
  let content;
139
218
  try {
140
219
  content = await fs.readFile(abs, "utf8");
@@ -145,34 +224,49 @@ async function planOp(i, op, abs, ctx) {
145
224
  }
146
225
  return { ok: false, error: opError(i, op, err.message) };
147
226
  }
148
- // Detect the file's line ending once, from the original bytes, so every hunk
149
- // re-encodes newStr to the same convention as content mutates across hunks.
150
- const fileEol = detectEol(content);
151
- for (let h = 0; h < op.hunks.length; h++) {
152
- const { oldStr, newStr } = op.hunks[h];
153
- const match = findMatch(content, oldStr);
154
- if (match.kind === "none") {
155
- return {
156
- ok: false,
157
- error: opError(i, op, `hunk ${h + 1}: oldStr not found`),
158
- };
159
- }
160
- if (match.kind === "ambiguous") {
161
- return {
162
- ok: false,
163
- error: opError(i, op, `hunk ${h + 1}: oldStr not unique (${match.count} matches${tierLabel(match.tier)})`),
164
- };
165
- }
166
- // Splice by offset so `$` patterns in newStr aren't interpreted.
167
- content =
168
- content.slice(0, match.start) +
169
- applyEol(newStr, fileEol) +
170
- content.slice(match.end);
171
- }
172
- return {
173
- ok: true,
174
- planned: { op: "update", abs, rel, content, hunks: op.hunks },
227
+ const track = {
228
+ ...base,
229
+ kind: "update",
230
+ content,
231
+ eol: detectEol(content),
175
232
  };
233
+ const failure = applyHunk(i, op, track);
234
+ return failure ? { ok: false, error: failure } : { ok: true, track };
235
+ }
236
+ /**
237
+ * Apply one update operation's hunk to its track's running content. Returns an
238
+ * error string on failure, or `undefined` on success (the track is mutated).
239
+ */
240
+ function applyHunk(i, op, track) {
241
+ const { oldStr, newStr } = op;
242
+ // Guaranteed present by `refineOperation`; re-checked so the narrowing is
243
+ // structural rather than a cast, and a schema regression fails loud.
244
+ if (oldStr === undefined || newStr === undefined) {
245
+ return opError(i, op, 'update requires both "oldStr" and "newStr"');
246
+ }
247
+ const match = findMatch(track.content, oldStr);
248
+ if (match.kind === "none") {
249
+ return opError(i, op, "oldStr not found");
250
+ }
251
+ if (match.kind === "ambiguous") {
252
+ return opError(i, op, `oldStr not unique (${match.count} matches${tierLabel(match.tier)})`);
253
+ }
254
+ // Splice by offset so `$` patterns in newStr aren't interpreted.
255
+ track.content =
256
+ track.content.slice(0, match.start) +
257
+ applyEol(newStr, track.eol) +
258
+ track.content.slice(match.end);
259
+ track.hunks.push({ oldStr, newStr });
260
+ return undefined;
261
+ }
262
+ /** Collapse a finished track into the single write it represents. */
263
+ function toPlanned(track) {
264
+ const { kind, abs, rel, content, hunks } = track;
265
+ if (kind === "delete")
266
+ return { op: "delete", abs, rel };
267
+ if (kind === "create")
268
+ return { op: "create", abs, rel, content };
269
+ return { op: "update", abs, rel, content, hunks };
176
270
  }
177
271
  /** Shape a planned op into its approval-preview form. */
178
272
  function toPreview(p) {
package/dist/tui/app.js CHANGED
@@ -5,6 +5,7 @@ import { TUI_ONLY_COMMANDS } from "../cli/command-catalog.js";
5
5
  import { selectList } from "../components/select.js";
6
6
  import { viewLabel, viewOrder } from "./views.js";
7
7
  import { canOverlay, createKeyLease, createOverlayIO, } from "./overlay.js";
8
+ import { ModeRing } from "./mode-ring.js";
8
9
  import { openPalette } from "./palette.js";
9
10
  import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
10
11
  import { CLOSABLE_PANELS, columnOf, RAIL_PANELS, } from "./layout.js";
@@ -263,7 +264,11 @@ async function readLine(keys, renderer, hooks) {
263
264
  // Mode is a property of the session, not of the message — losing a
264
265
  // half-written prompt to a mode switch would make the binding one
265
266
  // people learn not to press.
266
- hooks.cycleMode();
267
+ //
268
+ // The chip moves on this keypress; the session hears about it when the
269
+ // ring stops turning (Q5). Passing `paint` gives the ring a way to
270
+ // correct the chip if the settle resolves somewhere else.
271
+ hooks.cycleMode(paint);
267
272
  paint();
268
273
  break;
269
274
  case "page-up":
@@ -302,6 +307,11 @@ async function readLine(keys, renderer, hooks) {
302
307
  }
303
308
  }
304
309
  finally {
310
+ // The line is over — by Enter, by EOF, by Ctrl+C. Whatever the ring stopped
311
+ // on is what the user chose, and it is committed and announced HERE rather
312
+ // than a debounce later, so the mode is in force before the turn it was
313
+ // chosen for runs and its log event lands ahead of that turn's messages.
314
+ hooks.settleMode();
305
315
  keys.restore();
306
316
  }
307
317
  }
@@ -476,9 +486,14 @@ export async function runTui(session, renderer, opts = {}) {
476
486
  theme: renderer.theme,
477
487
  fit: (line) => line,
478
488
  };
489
+ // The mode ring (Q5). The chip follows every press; the session is told once,
490
+ // when the presses stop — see `mode-ring.ts` for why passing through a mode is
491
+ // not the same as choosing it.
492
+ const ring = new ModeRing(session, (mode) => announceMode(out, mode), opts.modeSettleMs);
479
493
  const hooks = {
480
- mode: () => session.getMode(),
481
- cycleMode: () => announceMode(out, session.cycleMode()),
494
+ mode: () => ring.current(),
495
+ cycleMode: (repaint) => ring.advance(repaint),
496
+ settleMode: () => ring.settle(),
482
497
  openPalette: () => openPalette(renderer, lease, opts.slashCommands ?? []),
483
498
  };
484
499
  // The TUI's picker (P6 track 1): an overlay drawer on the SAME reader this
package/dist/tui/index.js CHANGED
@@ -8,9 +8,10 @@ export { createOverviewView } from "./overview.js";
8
8
  export { createGitView, gitViewLines, GIT_VIEW_MAX_FILES, } from "./git-view.js";
9
9
  export { createTasksView, tasksViewLines, TASKS_VIEW_MAX_JOBS, TASKS_VIEW_TAIL, } from "./tasks-view.js";
10
10
  export { createSettingsView, settingsViewLines, formatSettingValue, } from "./settings-view.js";
11
- export { TuiRenderer, PAINT_INTERVAL_MS, VIEW_PULSE_MS, SCROLL_PAGE_OVERLAP, SCROLLBACK_LINES, } from "./renderer.js";
11
+ export { TuiRenderer, EXIT_TAIL_LINES, PAINT_INTERVAL_MS, VIEW_PULSE_MS, SCROLL_PAGE_OVERLAP, SCROLLBACK_LINES, } from "./renderer.js";
12
12
  export { CONVERSATION_VIEW, cycleView, navLines, viewLabel, viewOrder, } from "./views.js";
13
13
  export { runTui, renderInput, TUI_COMMANDS } from "./app.js";
14
14
  export { openPalette, paletteItems, paletteInsertion, paletteLabel, } from "./palette.js";
15
15
  export { canOverlay, createKeyLease, createOverlayFrame, createOverlayIO, } from "./overlay.js";
16
- export { supportsTui } from "./supports.js";
16
+ export { supportsTui, usesAltScreen } from "./supports.js";
17
+ export { installScreenGuard, restoreAllScreens, screenGuardCount, RESTORE_SIGNALS, SIGNAL_EXIT_CODES, } from "./restore.js";
@@ -0,0 +1,84 @@
1
+ import { nextMode } from "../agent/index.js";
2
+ /**
3
+ * The Shift+Tab mode ring, debounced (Q5).
4
+ *
5
+ * Shift+Tab walks a four-entry cycle, so reaching `full-auto` from `manual`
6
+ * means passing THROUGH `auto-approve` and `plan`. Every press used to be a
7
+ * commit: three `setMode` calls, three `mode` events appended to the session
8
+ * log, and three announcements — two of them for modes the user was not stopping
9
+ * on and never saw. The log then read as a session that deliberately chose
10
+ * auto-approve, then planning, then full-auto, which is not what happened; and
11
+ * the conversation column filled with descriptions of modes that were already
12
+ * gone by the time they were painted.
13
+ *
14
+ * So the ring turns freely and commits once. `current()` is the mode the ring is
15
+ * POINTING AT — the chip reads it, so every press still lands instantly on
16
+ * screen, which is the feedback the keypress owes the user. The session only
17
+ * hears about it once the presses stop, and that single commit is what gets
18
+ * announced and journaled.
19
+ *
20
+ * The chip is not a promise. `setMode` returns the EFFECTIVE mode (a session
21
+ * with no plan runner cannot enter a planning mode), so a settle that resolves
22
+ * elsewhere corrects the chip and announces where it actually landed.
23
+ */
24
+ /** Quiet period after the last press before the ring is taken as settled. */
25
+ export const MODE_SETTLE_MS = 400;
26
+ export class ModeRing {
27
+ session;
28
+ announce;
29
+ settleMs;
30
+ /** Where the ring is pointing, or null when it matches the committed mode. */
31
+ pending = null;
32
+ timer = null;
33
+ /** Repaint for a settle that lands on the timer rather than on a key. */
34
+ repaint;
35
+ constructor(session, announce, settleMs = MODE_SETTLE_MS) {
36
+ this.session = session;
37
+ this.announce = announce;
38
+ this.settleMs = settleMs;
39
+ }
40
+ /** The mode to SHOW. Pure — safe on the paint path, called every keystroke. */
41
+ current() {
42
+ return this.pending ?? this.session.getMode();
43
+ }
44
+ /**
45
+ * One press of Shift+Tab: advance the ring and restart the quiet period.
46
+ * `repaint` runs after a settle that lands on the timer, because the input
47
+ * row's chip is a stored string and a correction to it needs a new paint.
48
+ */
49
+ advance(repaint) {
50
+ this.pending = nextMode(this.current());
51
+ this.repaint = repaint;
52
+ this.arm();
53
+ }
54
+ /**
55
+ * Commit what the ring stopped on, now — the caller knows the turn is over
56
+ * (Enter, EOF) and will not wait out the timer. A no-op when nothing is
57
+ * pending, so it is safe in a `finally` on every line.
58
+ */
59
+ settle() {
60
+ this.disarm();
61
+ const want = this.pending;
62
+ if (want === null)
63
+ return;
64
+ this.pending = null;
65
+ this.announce(this.session.setMode(want));
66
+ }
67
+ arm() {
68
+ this.disarm();
69
+ const timer = setTimeout(() => {
70
+ this.timer = null;
71
+ this.settle();
72
+ this.repaint?.();
73
+ }, this.settleMs);
74
+ // A decorative debounce must never be the reason a process stays alive.
75
+ timer.unref?.();
76
+ this.timer = timer;
77
+ }
78
+ disarm() {
79
+ if (this.timer === null)
80
+ return;
81
+ clearTimeout(this.timer);
82
+ this.timer = null;
83
+ }
84
+ }
@@ -12,6 +12,8 @@ import { budgetColumns, bodyRows, composeScreen, droppedForWidth, fitOverlay, ov
12
12
  import { CONVERSATION_VIEW, cycleView, navLines, } from "./views.js";
13
13
  import { contextPanelLines, gitPanelLines, headerModel, mainWelcome, modelPanelLines, railBlocks, sidebarLines, toolsPanelLines, } from "./panels.js";
14
14
  import { limitsPanelLines } from "./limits-panel.js";
15
+ import { installScreenGuard, } from "./restore.js";
16
+ import { usesAltScreen } from "./supports.js";
15
17
  /**
16
18
  * The full-viewport renderer (P1) — the fourth {@link StreamRenderer}, and the
17
19
  * only one that owns the whole screen rather than a single managed line.
@@ -35,8 +37,36 @@ import { limitsPanelLines } from "./limits-panel.js";
35
37
  * Paints are marked dirty and flushed at most every {@link PAINT_INTERVAL_MS},
36
38
  * which keeps streaming smooth without a redraw per token.
37
39
  */
40
+ /**
41
+ * Enter the alternate screen buffer and hide the cursor (Q4 track 3), and the
42
+ * exact inverse. Emitted as one pair by one object, in one order, so there is
43
+ * no arrangement of them that can leave a terminal half-restored.
44
+ *
45
+ * The cursor is hidden because this renderer DRAWS its caret (see
46
+ * `renderInput`): the real cursor parks wherever the last painted row ended,
47
+ * which is a second, wrong caret blinking somewhere in the frame.
48
+ */
49
+ const ENTER_ALT_SCREEN = "\x1b[?1049h\x1b[?25l";
50
+ const LEAVE_ALT_SCREEN = "\x1b[?25h\x1b[?1049l";
38
51
  /** Coalescing window for repaints (~30fps). */
39
52
  export const PAINT_INTERVAL_MS = 33;
53
+ /**
54
+ * Logical lines of the conversation echoed into the NORMAL buffer when the TUI
55
+ * exits cleanly (Q4 track 3).
56
+ *
57
+ * The alternate screen is discarded when it is left — that is the whole point
58
+ * of it, and it is why the shell comes back exactly as it was. It also means
59
+ * the session the user just had would be gone the instant they typed `/exit`:
60
+ * no transcript in the scrollback, nothing to copy an error message out of,
61
+ * nothing to page back through. Losing that is a real regression, and an env
62
+ * knob nobody finds is not a fix for it.
63
+ *
64
+ * A dozen lines is chosen to be a REMINDER rather than a transcript. It is
65
+ * enough to carry the last answer and the command that produced it, and short
66
+ * enough that quitting a long session does not dump a screenful into the shell.
67
+ * The session log is the transcript; this is the tail.
68
+ */
69
+ export const EXIT_TAIL_LINES = 12;
40
70
  /**
41
71
  * How often a selected view that reports itself {@link ViewSource.live} gets
42
72
  * repainted with no other event to prompt it (P7 track 5).
@@ -57,6 +87,18 @@ export class TuiRenderer {
57
87
  theme;
58
88
  out;
59
89
  frame;
90
+ /**
91
+ * The process-level restore backstop (Q4 track 1). Installed at construction
92
+ * — the moment this renderer starts owning the screen — and dropped in
93
+ * {@link close}, so a process with no TUI up carries no handlers.
94
+ */
95
+ guard;
96
+ /** Whether this renderer took the alternate screen and owes the inverse. */
97
+ altScreen;
98
+ /** Lines the opening banner occupies — the buffer's contents at construction. */
99
+ openingLines;
100
+ /** Logical lines committed since construction, INCLUDING ones rolled off. */
101
+ committed = 0;
60
102
  /** Committed conversation content, UNWRAPPED — wrapped fresh at each paint. */
61
103
  buffer = [];
62
104
  /** The streamed line still being assembled (no newline seen yet). */
@@ -149,7 +191,35 @@ export class TuiRenderer {
149
191
  this.initialModel = opts.model;
150
192
  this.tools = opts.tools;
151
193
  this.buffer = mainWelcome(this.theme, "/help for commands · /exit to quit");
152
- this.frame = createFrame((text) => this.out.write(text), caps);
194
+ this.openingLines = this.buffer.length;
195
+ // THE ALTERNATE SCREEN (Q4 track 3), taken before a single byte is painted.
196
+ //
197
+ // This is what makes track 2's absolute addressing safe to use here. Rows
198
+ // land at screen positions 1..n, so without a buffer of its own the shell
199
+ // would overwrite whatever the terminal was showing — the user's last
200
+ // screenful of shell history, gone rather than scrolled into scrollback.
201
+ // On the alternate screen there is nothing to overwrite, and leaving it
202
+ // restores the normal buffer byte for byte: the prompt cruxy was started
203
+ // from comes back exactly as it was, with no frame-shaped hole in it.
204
+ //
205
+ // The inverse is owed from this line onward, which is why track 1 landed
206
+ // first: `close()` is not the only way out of this constructor's reach.
207
+ this.altScreen = opts.altScreen ?? usesAltScreen(caps);
208
+ if (this.altScreen)
209
+ out.write(ENTER_ALT_SCREEN);
210
+ // ABSOLUTE rows (Q4 track 2). This renderer owns the viewport outright, so
211
+ // it has no reason to infer where its rows are from where the cursor was
212
+ // left — and every reason not to. A frame this tall repaints thirty times a
213
+ // second; one cursor position the walk got wrong (a stray byte, a soft-wrap
214
+ // the width math did not predict, a scroll) would put every subsequent
215
+ // paint one row further off, with no paint able to recover. Row 1 is row 1.
216
+ this.frame = createFrame((text) => this.out.write(text), caps, {
217
+ absolute: true,
218
+ });
219
+ // Installed here, not in `close()`'s vicinity, because the window it covers
220
+ // opens with the first paint: from this line on there is a shell on screen
221
+ // that only this object knows how to take down.
222
+ this.guard = installScreenGuard(() => this.releaseScreen(), opts.signals);
153
223
  // The single shared clock (U.10): an inert clock under reduced motion, so
154
224
  // the spinner is drawn once statically and no timer is ever scheduled.
155
225
  this.clock = createFrameClock(caps.spinner);
@@ -763,11 +833,78 @@ export class TuiRenderer {
763
833
  this.unsubscribeResize = null;
764
834
  this.unsubscribeModel?.();
765
835
  this.unsubscribeModel = null;
766
- // Erase the whole shell: after the TUI exits the terminal holds zero
767
- // leftover bytes from it, exactly like a resolved component frame.
768
- this.frame.clear();
836
+ // Erase the whole shell through the SAME path a signal takes (Q4 track 1),
837
+ // so a clean exit and a killed one leave the terminal in one state rather
838
+ // than two that drift apart the next time either is edited. The guard is
839
+ // idempotent, so the two can also race harmlessly.
840
+ this.guard.restoreScreen();
841
+ this.guard.dispose();
842
+ // AFTER the screen is handed back, so this lands in the normal buffer and
843
+ // survives in the shell's scrollback (Q4 track 3). On the clean path only:
844
+ // a signal handler's job is to give the terminal up, not to write a report
845
+ // into a shell the user may not be looking at.
846
+ this.printExitTail();
769
847
  }
770
848
  // ── internals ─────────────────────────────────────────────────────────────
849
+ /**
850
+ * Hand the terminal back (Q4 track 1): everything {@link close} does to the
851
+ * SCREEN, and nothing it does to the session. This is what the signal and
852
+ * exit handlers run, so it must be safe from a context where nothing async
853
+ * can be awaited and nothing that throws will be reported.
854
+ *
855
+ * After the TUI releases the screen the terminal holds zero leftover bytes
856
+ * from it, exactly like a resolved component frame.
857
+ *
858
+ * `closed` is set here rather than only in `close()`: on the signal path this
859
+ * is the last thing that runs before `process.exit`, and a repaint scheduled
860
+ * in between would draw a shell back onto a terminal that has just been
861
+ * handed over.
862
+ */
863
+ releaseScreen() {
864
+ this.closed = true;
865
+ this.frame.clear();
866
+ // The exact inverse of the constructor's pair, and the last bytes this
867
+ // renderer ever writes to the managed screen.
868
+ if (this.altScreen)
869
+ this.out.write(LEAVE_ALT_SCREEN);
870
+ }
871
+ /**
872
+ * Echo the tail of the conversation into the normal buffer (Q4 track 3).
873
+ *
874
+ * The alternate screen is discarded when it is left, so without this the
875
+ * session would vanish the moment the user typed `/exit` — no transcript in
876
+ * the scrollback, no error message left to copy, nothing to page back
877
+ * through. That is a regression the alternate screen would otherwise trade
878
+ * for a clean prompt, and it is not a trade worth making silently.
879
+ *
880
+ * Nothing is printed for a session that produced nothing: opening the shell
881
+ * and quitting leaves the terminal exactly as it was found, which is what the
882
+ * alternate screen is for. When some output DID roll past the tail, the count
883
+ * says so — and it counts every line ever committed, including the ones that
884
+ * rolled off the scrollback buffer entirely, so the number is the true amount
885
+ * hidden rather than the amount this object still happens to remember.
886
+ *
887
+ * Lines go out unwrapped and untruncated. The normal buffer soft-wraps them,
888
+ * which is right: this is the copy the user keeps, and nothing in it should be
889
+ * cut to a width the terminal is no longer being managed at.
890
+ */
891
+ printExitTail() {
892
+ if (this.committed === 0)
893
+ return;
894
+ const shown = this.buffer.slice(Math.max(0, this.buffer.length - EXIT_TAIL_LINES));
895
+ if (shown.length === 0)
896
+ return;
897
+ const hidden = this.openingLines + this.committed - shown.length;
898
+ const notice = hidden > 0
899
+ ? [
900
+ this.theme.muted(`… ${hidden} earlier line${hidden === 1 ? "" : "s"} not shown`),
901
+ ]
902
+ : [];
903
+ // Leading and trailing blank rows: this is a quotation dropped into the
904
+ // shell, and it needs to read as separate from both the prompt above it and
905
+ // whatever the exit path prints below.
906
+ this.out.write(["", ...notice, ...shown, ""].join("\n"));
907
+ }
771
908
  newPrinter() {
772
909
  return createStreamPrinter((text) => {
773
910
  this.commit(this.highlighter.push(text));
@@ -814,6 +951,10 @@ export class TuiRenderer {
814
951
  this.scrollOffset += added;
815
952
  }
816
953
  this.buffer.push(...lines);
954
+ // Counted here rather than measured off the buffer at exit, because the
955
+ // buffer forgets: `committed` has to keep counting past the roll-off below
956
+ // for the exit tail's "N earlier lines" to be a true number.
957
+ this.committed += lines.length;
817
958
  if (this.buffer.length > SCROLLBACK_LINES) {
818
959
  this.buffer = this.buffer.slice(this.buffer.length - SCROLLBACK_LINES);
819
960
  }