@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.
- package/README.md +117 -1
- package/dist/agent/session.js +1 -5
- package/dist/components/frame.js +39 -1
- package/dist/errors/constructors.js +115 -5
- package/dist/errors/types.js +17 -0
- package/dist/lsp/index.js +1 -1
- package/dist/lsp/registry.js +28 -10
- package/dist/plan/service.js +26 -1
- package/dist/plan/submit-plan.js +11 -0
- package/dist/render/capabilities.js +9 -2
- package/dist/render/index.js +6 -1
- package/dist/tools/file/apply-patch.js +164 -70
- package/dist/tui/app.js +18 -3
- package/dist/tui/index.js +3 -2
- package/dist/tui/mode-ring.js +84 -0
- package/dist/tui/renderer.js +145 -4
- package/dist/tui/restore.js +137 -0
- package/dist/tui/supports.js +22 -0
- package/dist/tui/tool-versions.js +119 -18
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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", "
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
118
|
-
async function
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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: () =>
|
|
481
|
-
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
|
+
}
|
package/dist/tui/renderer.js
CHANGED
|
@@ -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.
|
|
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
|
|
767
|
-
//
|
|
768
|
-
|
|
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
|
}
|