@vincemakes/kiso-code 0.1.15 → 0.1.17
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/dist/body.d.ts +4 -1
- package/dist/body.js +102 -32
- package/dist/diff.d.ts +32 -0
- package/dist/diff.js +122 -0
- package/dist/dock.d.ts +27 -28
- package/dist/dock.js +47 -54
- package/dist/editor.d.ts +11 -0
- package/dist/editor.js +70 -4
- package/dist/index.js +196 -57
- package/dist/mode.d.ts +33 -0
- package/dist/mode.js +93 -0
- package/dist/render.d.ts +30 -0
- package/dist/render.js +64 -4
- package/package.json +7 -7
package/dist/body.d.ts
CHANGED
|
@@ -39,6 +39,9 @@ export type BodyCell = {
|
|
|
39
39
|
state: "pending" | "approval" | "running" | "done";
|
|
40
40
|
isError: boolean;
|
|
41
41
|
resultText: string;
|
|
42
|
+
diff: import("./diff.js").DiffLine[] | null;
|
|
43
|
+
added: number;
|
|
44
|
+
removed: number;
|
|
42
45
|
startedAt: number | null;
|
|
43
46
|
doneAt: number | null;
|
|
44
47
|
done: boolean;
|
|
@@ -96,7 +99,7 @@ export declare class Body {
|
|
|
96
99
|
thinkingAppend(text: string): void;
|
|
97
100
|
thinkingEnd(): void;
|
|
98
101
|
toolStart(name: string, callId: string, input: Record<string, unknown>): void;
|
|
99
|
-
toolApproval(callId: string): void;
|
|
102
|
+
toolApproval(callId: string, diff: import("./diff.js").DiffResult | null): void;
|
|
100
103
|
toolRunning(callId: string): void;
|
|
101
104
|
toolSucceeded(callId: string): void;
|
|
102
105
|
toolFailed(callId: string, error: string): void;
|
package/dist/body.js
CHANGED
|
@@ -23,10 +23,11 @@
|
|
|
23
23
|
* NoticeCell / raw block) — deliberately NOT pi's Component interface
|
|
24
24
|
* shape (ADR-0040).
|
|
25
25
|
*/
|
|
26
|
+
import { truncateDiff } from "./diff.js";
|
|
26
27
|
import { displayWidth } from "./editor.js";
|
|
27
28
|
import { escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, } from "./render.js";
|
|
28
|
-
/** The spinner glyphs, cycled by the heartbeat. */
|
|
29
|
-
const SPINNER = ["
|
|
29
|
+
/** The spinner glyphs, cycled by the heartbeat (v3 §05 — the working family). */
|
|
30
|
+
const SPINNER = ["▖", "▘", "▝", "▗"];
|
|
30
31
|
const TOOL_SUMMARY_MAX = 60; // the tool line's parameter summary, chars
|
|
31
32
|
const FRAME_MS = 16; // state changes coalesce to ≥16ms frames
|
|
32
33
|
const HEARTBEAT_MS = 200; // spinner / elapsed cadence
|
|
@@ -35,7 +36,7 @@ export class Body {
|
|
|
35
36
|
#opts;
|
|
36
37
|
#cells = [];
|
|
37
38
|
#nextFrozen = 0; // index of the first not-yet-printed cell
|
|
38
|
-
#frozenRows = 0; // rows
|
|
39
|
+
#frozenRows = 0; // the frozen area's rows filled WITHOUT scrolling (then the real LFs take over)
|
|
39
40
|
#oldTailTop = 0; // the tail's previous first row — for the clear pass
|
|
40
41
|
#frameTimer = null;
|
|
41
42
|
#heartbeat = null;
|
|
@@ -53,6 +54,14 @@ export class Body {
|
|
|
53
54
|
this.#active = opts.active();
|
|
54
55
|
if (this.#isActive()) {
|
|
55
56
|
this.#heartbeat = setInterval(() => {
|
|
57
|
+
// #14 (P1): the idle heartbeat PAINTS NOTHING. An idle body
|
|
58
|
+
// (every cell frozen) has no glyph/elapsed to advance — a
|
|
59
|
+
// beat that renders anyway re-paints the tail + the dock
|
|
60
|
+
// every 200ms with ZERO change (12s idle = 61 copies /
|
|
61
|
+
// ~47KB — the measured defect). Only an ACTIVE tail makes
|
|
62
|
+
// the beat matter: paint, then.
|
|
63
|
+
if (!this.#cells.some((c) => !c.done))
|
|
64
|
+
return;
|
|
56
65
|
this.#spinnerI = (this.#spinnerI + 1) % SPINNER.length;
|
|
57
66
|
this.#dirty = true; // the running cells' glyph/elapsed advance
|
|
58
67
|
this.#scheduleFrame();
|
|
@@ -90,11 +99,13 @@ export class Body {
|
|
|
90
99
|
userLine(text) {
|
|
91
100
|
if (!this.#isActive()) {
|
|
92
101
|
this.#closeOpenThinking();
|
|
102
|
+
this.#closeOpenText();
|
|
93
103
|
const p = palette();
|
|
94
104
|
this.#write(`${p.blue}you> ${escapeTerminal(text)}${p.reset}\n`);
|
|
95
105
|
return;
|
|
96
106
|
}
|
|
97
107
|
this.#closeOpenThinking();
|
|
108
|
+
this.#closeOpenText();
|
|
98
109
|
this.#cells.push({ kind: "user", text, done: true });
|
|
99
110
|
this.#mark();
|
|
100
111
|
}
|
|
@@ -129,19 +140,27 @@ export class Body {
|
|
|
129
140
|
this.#pendingCalls.set(callId, { name, input, result: { content: "", isError: false } });
|
|
130
141
|
if (!this.#isActive()) {
|
|
131
142
|
this.#closeOpenThinking();
|
|
143
|
+
this.#closeOpenText();
|
|
132
144
|
process.stdout.write(`→ ${escapeTerminal(name)}(${escapeTerminal(JSON.stringify(input).slice(0, 200))})\n`);
|
|
133
145
|
return;
|
|
134
146
|
}
|
|
135
147
|
this.#toolCells.set(callId, this.#cells.length);
|
|
136
|
-
this.#cells.push({ kind: "tool", name, input: summary, state: "pending", isError: false, resultText: "", startedAt: null, doneAt: null, done: false });
|
|
148
|
+
this.#cells.push({ kind: "tool", name, input: summary, state: "pending", isError: false, resultText: "", diff: null, added: 0, removed: 0, startedAt: null, doneAt: null, done: false });
|
|
137
149
|
this.#mark();
|
|
138
150
|
}
|
|
139
|
-
toolApproval(callId) {
|
|
151
|
+
toolApproval(callId, diff) {
|
|
140
152
|
if (!this.#isActive())
|
|
141
153
|
return;
|
|
142
154
|
const cell = this.#toolCell(callId);
|
|
143
|
-
if (cell !== null && cell.kind === "tool" && !cell.done)
|
|
155
|
+
if (cell !== null && cell.kind === "tool" && !cell.done) {
|
|
144
156
|
cell.state = "approval";
|
|
157
|
+
// v2e: the mini-diff renders BELOW the tool line at the approval
|
|
158
|
+
// moment — the human sees the change before deciding. Auto-allowed
|
|
159
|
+
// tools pass null (nobody is looking — no diff, no cost).
|
|
160
|
+
cell.diff = diff === null ? null : truncateDiff(diff.lines);
|
|
161
|
+
cell.added = diff?.added ?? 0;
|
|
162
|
+
cell.removed = diff?.removed ?? 0;
|
|
163
|
+
}
|
|
145
164
|
this.#mark();
|
|
146
165
|
}
|
|
147
166
|
toolRunning(callId) {
|
|
@@ -196,6 +215,7 @@ export class Body {
|
|
|
196
215
|
textAppend(text) {
|
|
197
216
|
if (!this.#isActive()) {
|
|
198
217
|
this.#closeOpenThinking();
|
|
218
|
+
this.#closeOpenText();
|
|
199
219
|
process.stdout.write(escapeTerminal(text));
|
|
200
220
|
return;
|
|
201
221
|
}
|
|
@@ -205,6 +225,7 @@ export class Body {
|
|
|
205
225
|
}
|
|
206
226
|
else {
|
|
207
227
|
this.#closeOpenThinking();
|
|
228
|
+
this.#closeOpenText();
|
|
208
229
|
this.#cells.push({ kind: "text", text, done: false });
|
|
209
230
|
}
|
|
210
231
|
this.#mark();
|
|
@@ -223,21 +244,25 @@ export class Body {
|
|
|
223
244
|
terminal(label, statusLine) {
|
|
224
245
|
if (!this.#isActive()) {
|
|
225
246
|
this.#closeOpenThinking();
|
|
247
|
+
this.#closeOpenText();
|
|
226
248
|
// the v2c bytes: the terminal label (\ndone\n) + the status gap.
|
|
227
249
|
process.stdout.write(label + renderTerminalGap(statusLine));
|
|
228
250
|
return;
|
|
229
251
|
}
|
|
230
252
|
this.#closeOpenThinking();
|
|
253
|
+
this.#closeOpenText();
|
|
231
254
|
this.#cells.push({ kind: "terminal", label: label.trim(), line: statusLine, done: true });
|
|
232
255
|
this.#mark();
|
|
233
256
|
}
|
|
234
257
|
notice(text) {
|
|
235
258
|
if (!this.#isActive()) {
|
|
236
259
|
this.#closeOpenThinking();
|
|
260
|
+
this.#closeOpenText();
|
|
237
261
|
process.stdout.write(`${text}\n`);
|
|
238
262
|
return;
|
|
239
263
|
}
|
|
240
264
|
this.#closeOpenThinking();
|
|
265
|
+
this.#closeOpenText();
|
|
241
266
|
this.#cells.push({ kind: "notice", text, done: true });
|
|
242
267
|
this.#mark();
|
|
243
268
|
}
|
|
@@ -246,11 +271,13 @@ export class Body {
|
|
|
246
271
|
raw(lines) {
|
|
247
272
|
if (!this.#isActive()) {
|
|
248
273
|
this.#closeOpenThinking();
|
|
274
|
+
this.#closeOpenText();
|
|
249
275
|
for (const line of lines)
|
|
250
276
|
process.stdout.write(`${line}\n`);
|
|
251
277
|
return;
|
|
252
278
|
}
|
|
253
279
|
this.#closeOpenThinking();
|
|
280
|
+
this.#closeOpenText();
|
|
254
281
|
this.#cells.push({ kind: "raw", lines, done: true });
|
|
255
282
|
this.#mark();
|
|
256
283
|
}
|
|
@@ -280,34 +307,47 @@ export class Body {
|
|
|
280
307
|
return;
|
|
281
308
|
const H = this.#opts.height();
|
|
282
309
|
const W = this.#opts.width();
|
|
283
|
-
|
|
284
|
-
if (regionBottom < 1)
|
|
310
|
+
if (H < 4)
|
|
285
311
|
return;
|
|
286
312
|
const out = [];
|
|
287
|
-
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
313
|
+
// #13 (P1), v2d-B: NO DECSTBM — overflow scrolls with a REAL LF at
|
|
314
|
+
// the screen's last row, so the frozen lines enter the terminal's
|
|
315
|
+
// NATIVE scrollback deterministically (region-scrolled lines are
|
|
316
|
+
// terminal-dependent; some terminals drop them — the measured v2d-A
|
|
317
|
+
// defect). The body fills from the top without scrolling; once full,
|
|
318
|
+
// every new frozen line scrolls the whole screen (\x1b[H;1H\n — the
|
|
319
|
+
// top line leaves into the scrollback) and lands at the body's
|
|
320
|
+
// bottom row, just above the active tail. The dock is redrawn after.
|
|
321
|
+
// The tail (the remaining ACTIVE cells) and its geometry — computed
|
|
322
|
+
// FIRST from the final nextFrozen, so the frozen cells are NOT in it
|
|
323
|
+
// (a stale tail would re-draw them — the double-render).
|
|
324
|
+
let nextFrozen = this.#nextFrozen;
|
|
325
|
+
while (nextFrozen < this.#cells.length && this.#cells[nextFrozen].done)
|
|
326
|
+
nextFrozen += 1;
|
|
327
|
+
const tail = this.#cells.slice(nextFrozen);
|
|
328
|
+
const tailHeight = tail.reduce((n, c) => n + this.#cellHeight(c, W), 0);
|
|
329
|
+
const tailTop = Math.max(1, H - 3 - tailHeight); // v3 §03: 4 dock rows below
|
|
330
|
+
const writeRow = Math.max(1, tailTop - 1); // the frozen area's bottom row
|
|
331
|
+
let scrolled = 0;
|
|
332
|
+
for (let i = this.#nextFrozen; i < nextFrozen; i += 1) {
|
|
333
|
+
for (const line of this.#cellLines(this.#cells[i], W)) {
|
|
334
|
+
if (this.#frozenRows < writeRow) {
|
|
335
|
+
this.#frozenRows += 1;
|
|
336
|
+
out.push(`\x1b[${this.#frozenRows};1H\x1b[0K${line}`);
|
|
298
337
|
}
|
|
299
338
|
else {
|
|
300
|
-
|
|
339
|
+
out.push(`\x1b[${H};1H\n`); // the REAL LF — the whole screen scrolls
|
|
340
|
+
out.push(`\x1b[${writeRow};1H\x1b[0K${line}`);
|
|
341
|
+
scrolled += 1;
|
|
301
342
|
}
|
|
302
343
|
}
|
|
303
344
|
this.#nextFrozen += 1;
|
|
304
345
|
}
|
|
305
|
-
// 2. the active tail — clear its old area
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
for (let row = clearFrom; row <= regionBottom; row += 1) {
|
|
346
|
+
// 2. the active tail — clear its old area (shifted up by the freeze
|
|
347
|
+
// scrolls) and the current area, draw the cells at the body's bottom.
|
|
348
|
+
out.push("\x1b[?2026h");
|
|
349
|
+
const clearFrom = Math.min(this.#oldTailTop === 0 ? tailTop : this.#oldTailTop - scrolled, tailTop);
|
|
350
|
+
for (let row = clearFrom; row <= H - 4; row += 1) {
|
|
311
351
|
out.push(`\x1b[${row};1H\x1b[0K`);
|
|
312
352
|
}
|
|
313
353
|
let row = tailTop;
|
|
@@ -317,24 +357,29 @@ export class Body {
|
|
|
317
357
|
row += 1;
|
|
318
358
|
}
|
|
319
359
|
}
|
|
320
|
-
this.#oldTailTop = tailTop;
|
|
321
360
|
// 3. the cursor home — the input line's edit column.
|
|
322
361
|
out.push(`\x1b[${H};${this.#opts.editCol()}H`);
|
|
323
362
|
out.push("\x1b[?2026l");
|
|
324
363
|
this.#write(out.join(""));
|
|
364
|
+
// 4. the dock rows — the freeze scrolls shifted them; redraw (the
|
|
365
|
+
// dock's own redraw re-pins the cursor at the edit position).
|
|
366
|
+
this.#opts.onDock?.();
|
|
325
367
|
}
|
|
326
368
|
// ---- cell → lines ----
|
|
327
369
|
#cellLines(cell, W) {
|
|
328
370
|
const p = palette();
|
|
329
371
|
switch (cell.kind) {
|
|
330
372
|
case "user":
|
|
331
|
-
|
|
373
|
+
// v3 §02: the user message is a SGR BACKGROUND block, no
|
|
374
|
+
// prefix — every line carries the block's background
|
|
375
|
+
// (multi-line whole; resize-safe). Pipes stay plain.
|
|
376
|
+
return cell.text.split("\n").map((l) => `${p.bg}${escapeTerminal(l)}${p.reset}`);
|
|
332
377
|
case "thinking": {
|
|
333
378
|
const block = cell.text;
|
|
334
379
|
const trimmed = escapeTerminal(block.trim());
|
|
335
380
|
if (trimmed.length <= 100)
|
|
336
381
|
return [`${p.dim}…${trimmed}${p.reset}`];
|
|
337
|
-
return [`${p.dim}…${trimmed.slice(0, 100)} (
|
|
382
|
+
return [`${p.dim}…${trimmed.slice(0, 100)} (${block.length} chars · /think)${p.reset}`];
|
|
338
383
|
}
|
|
339
384
|
case "tool": {
|
|
340
385
|
const name = escapeTerminal(cell.name);
|
|
@@ -345,10 +390,25 @@ export class Body {
|
|
|
345
390
|
const err = escapeTerminal(cell.resultText.split("\n")[0].slice(0, 60));
|
|
346
391
|
return [`${p.red}✗ ${name} (${err}, ${elapsed}s)${p.reset}`];
|
|
347
392
|
}
|
|
348
|
-
|
|
393
|
+
const delta = cell.added + cell.removed > 0 ? `, +${cell.added} -${cell.removed}` : "";
|
|
394
|
+
return [`${p.blue}✓ ${name}${p.reset} (${summary}${delta}, ${elapsed}s)`];
|
|
395
|
+
}
|
|
396
|
+
if (cell.state === "approval") {
|
|
397
|
+
const lines = [`→ ${name} ${summary} ${p.blue}⏸${p.reset}`];
|
|
398
|
+
// v2e: the mini-diff — ▎ blue edge (the brick motif), - red /
|
|
399
|
+
// + green / context dim; NO_COLOR keeps the ± prefixes plain.
|
|
400
|
+
if (cell.diff !== null) {
|
|
401
|
+
for (const d of cell.diff) {
|
|
402
|
+
const body = d.kind === "-"
|
|
403
|
+
? `${p.red}- ${escapeTerminal(d.text)}${p.reset}`
|
|
404
|
+
: d.kind === "+"
|
|
405
|
+
? `${p.green}+ ${escapeTerminal(d.text)}${p.reset}`
|
|
406
|
+
: `${p.dim} ${escapeTerminal(d.text)}${p.reset}`;
|
|
407
|
+
lines.push(`${p.blue}▎${p.reset}${body}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return lines;
|
|
349
411
|
}
|
|
350
|
-
if (cell.state === "approval")
|
|
351
|
-
return [`→ ${name} ${summary} ${p.blue}⏸${p.reset}`];
|
|
352
412
|
if (cell.state === "running") {
|
|
353
413
|
const elapsed = cell.startedAt !== null ? Math.max(1, Math.round((Date.now() - cell.startedAt) / 1000)) : 1;
|
|
354
414
|
return [`→ ${name} ${summary} ${p.blue}${SPINNER[this.#spinnerI % SPINNER.length]}${p.reset} ${elapsed}s`];
|
|
@@ -401,6 +461,16 @@ export class Body {
|
|
|
401
461
|
const i = this.#toolCells.get(callId);
|
|
402
462
|
return i === undefined ? null : (this.#cells[i] ?? null);
|
|
403
463
|
}
|
|
464
|
+
/** Close an open TEXT cell when a new cell starts — the runtime emits
|
|
465
|
+
* no text_end (it is an adapter-level event), so the stream's next
|
|
466
|
+
* cell is the close signal; without it the freeze blocks behind the
|
|
467
|
+
* open text and everything after it re-renders in the tail forever
|
|
468
|
+
* (the #13 flood reproduced the overwrite). */
|
|
469
|
+
#closeOpenText() {
|
|
470
|
+
const last = this.#cells[this.#cells.length - 1];
|
|
471
|
+
if (last !== undefined && last.kind === "text" && !last.done)
|
|
472
|
+
last.done = true;
|
|
473
|
+
}
|
|
404
474
|
/** Close an open thinking cell when a new cell starts (the block's
|
|
405
475
|
* fold freezes at the transition). */
|
|
406
476
|
#closeOpenThinking() {
|
package/dist/diff.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2e — the diff renderer: edit/write changes as inline ± lines, zero
|
|
3
|
+
* dependencies, no syntax highlighting (the spec's scope line). Shown at
|
|
4
|
+
* the approval moment ONLY — the frozen summary stays one line (v2d's
|
|
5
|
+
* anti-leak principle), /last has the full data.
|
|
6
|
+
*
|
|
7
|
+
* edit_file diffs IN PLACE (the search→replace windows are known — no
|
|
8
|
+
* general engine needed); write_file does a row-level LCS over the old
|
|
9
|
+
* file (small files are the target). Context: 2 rows each side. The
|
|
10
|
+
* RENDERER truncates (18 head + 18 tail + "… N lines"); the stats come
|
|
11
|
+
* from the full diff.
|
|
12
|
+
*/
|
|
13
|
+
/** The diff block's per-row kind. */
|
|
14
|
+
export type DiffLine = {
|
|
15
|
+
kind: "-" | "+" | " ";
|
|
16
|
+
text: string;
|
|
17
|
+
};
|
|
18
|
+
export interface DiffResult {
|
|
19
|
+
/** The FULL diff (with context, not truncated) — the display truncates. */
|
|
20
|
+
lines: DiffLine[];
|
|
21
|
+
added: number;
|
|
22
|
+
removed: number;
|
|
23
|
+
}
|
|
24
|
+
/** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
|
|
25
|
+
export declare function truncateDiff(diff: DiffLine[]): DiffLine[];
|
|
26
|
+
/** edit_file: the search→replace windows replace in place — the changed
|
|
27
|
+
* region is KNOWN, so the diff is the old window vs the new window,
|
|
28
|
+
* context from the surrounding file. */
|
|
29
|
+
export declare function editFileDiff(oldContent: string, search: string, replace: string): DiffResult;
|
|
30
|
+
/** write_file: a new file is all +; an existing file diffs row-level
|
|
31
|
+
* against its old content. */
|
|
32
|
+
export declare function writeFileDiff(oldContent: string | null, newContent: string): DiffResult;
|
package/dist/diff.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2e — the diff renderer: edit/write changes as inline ± lines, zero
|
|
3
|
+
* dependencies, no syntax highlighting (the spec's scope line). Shown at
|
|
4
|
+
* the approval moment ONLY — the frozen summary stays one line (v2d's
|
|
5
|
+
* anti-leak principle), /last has the full data.
|
|
6
|
+
*
|
|
7
|
+
* edit_file diffs IN PLACE (the search→replace windows are known — no
|
|
8
|
+
* general engine needed); write_file does a row-level LCS over the old
|
|
9
|
+
* file (small files are the target). Context: 2 rows each side. The
|
|
10
|
+
* RENDERER truncates (18 head + 18 tail + "… N lines"); the stats come
|
|
11
|
+
* from the full diff.
|
|
12
|
+
*/
|
|
13
|
+
/** A line-level LCS diff — the classic two-row DP, ~small inputs. */
|
|
14
|
+
function lcsDiff(oldLines, newLines) {
|
|
15
|
+
const n = oldLines.length;
|
|
16
|
+
const m = newLines.length;
|
|
17
|
+
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
18
|
+
for (let i = n - 1; i >= 0; i -= 1) {
|
|
19
|
+
for (let j = m - 1; j >= 0; j -= 1) {
|
|
20
|
+
dp[i][j] = oldLines[i] === newLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const out = [];
|
|
24
|
+
let i = 0;
|
|
25
|
+
let j = 0;
|
|
26
|
+
while (i < n && j < m) {
|
|
27
|
+
if (oldLines[i] === newLines[j]) {
|
|
28
|
+
out.push({ kind: " ", text: oldLines[i] });
|
|
29
|
+
i += 1;
|
|
30
|
+
j += 1;
|
|
31
|
+
}
|
|
32
|
+
else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
33
|
+
out.push({ kind: "-", text: oldLines[i] });
|
|
34
|
+
i += 1;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
out.push({ kind: "+", text: newLines[j] });
|
|
38
|
+
j += 1;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
while (i < n) {
|
|
42
|
+
out.push({ kind: "-", text: oldLines[i] });
|
|
43
|
+
i += 1;
|
|
44
|
+
}
|
|
45
|
+
while (j < m) {
|
|
46
|
+
out.push({ kind: "+", text: newLines[j] });
|
|
47
|
+
j += 1;
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/** Keep 2 context rows around each change — the unified-style window. */
|
|
52
|
+
function withContext(diff) {
|
|
53
|
+
const out = [];
|
|
54
|
+
let lastAdded = -10;
|
|
55
|
+
for (let k = 0; k < diff.length; k += 1) {
|
|
56
|
+
if (diff[k].kind === " ")
|
|
57
|
+
continue;
|
|
58
|
+
const from = Math.max(0, k - 2);
|
|
59
|
+
const to = Math.min(diff.length - 1, k + 2);
|
|
60
|
+
for (let c = from; c <= to; c += 1) {
|
|
61
|
+
if (c > lastAdded) {
|
|
62
|
+
out.push(diff[c]);
|
|
63
|
+
lastAdded = c;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
lastAdded = to;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
const MAX_DIFF_LINES = 40; // the RENDERED cap
|
|
71
|
+
const TRUNCATE_KEEP = 18;
|
|
72
|
+
/** The RENDERER's truncation: head + "… N lines (/last for full)" + tail. */
|
|
73
|
+
export function truncateDiff(diff) {
|
|
74
|
+
if (diff.length <= MAX_DIFF_LINES)
|
|
75
|
+
return diff;
|
|
76
|
+
const omitted = diff.length - 2 * TRUNCATE_KEEP;
|
|
77
|
+
return [
|
|
78
|
+
...diff.slice(0, TRUNCATE_KEEP),
|
|
79
|
+
{ kind: " ", text: `… ${omitted} lines (/last for full)` },
|
|
80
|
+
...diff.slice(diff.length - TRUNCATE_KEEP),
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
function stats(diff) {
|
|
84
|
+
let added = 0;
|
|
85
|
+
let removed = 0;
|
|
86
|
+
for (const d of diff) {
|
|
87
|
+
if (d.kind === "+")
|
|
88
|
+
added += 1;
|
|
89
|
+
else if (d.kind === "-")
|
|
90
|
+
removed += 1;
|
|
91
|
+
}
|
|
92
|
+
return { added, removed };
|
|
93
|
+
}
|
|
94
|
+
/** edit_file: the search→replace windows replace in place — the changed
|
|
95
|
+
* region is KNOWN, so the diff is the old window vs the new window,
|
|
96
|
+
* context from the surrounding file. */
|
|
97
|
+
export function editFileDiff(oldContent, search, replace) {
|
|
98
|
+
const oldLines = oldContent.split("\n");
|
|
99
|
+
const searchLines = search.split("\n");
|
|
100
|
+
const replaceLines = replace.split("\n");
|
|
101
|
+
// Locate the search window (the first occurrence — the edit tool's own
|
|
102
|
+
// semantics); no occurrence → the whole file is the old side.
|
|
103
|
+
let at = -1;
|
|
104
|
+
for (let i = 0; i + searchLines.length <= oldLines.length; i += 1) {
|
|
105
|
+
if (oldLines.slice(i, i + searchLines.length).join("\n") === search) {
|
|
106
|
+
at = i;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const lines = at < 0 ? withContext(lcsDiff(oldLines, replaceLines)) : withContext(lcsDiff(oldLines, [...oldLines.slice(0, at), ...replaceLines, ...oldLines.slice(at + searchLines.length)]));
|
|
111
|
+
return { lines, ...stats(lines) };
|
|
112
|
+
}
|
|
113
|
+
/** write_file: a new file is all +; an existing file diffs row-level
|
|
114
|
+
* against its old content. */
|
|
115
|
+
export function writeFileDiff(oldContent, newContent) {
|
|
116
|
+
if (oldContent === null) {
|
|
117
|
+
const lines = newContent.split("\n").map((text) => ({ kind: "+", text }));
|
|
118
|
+
return { lines, added: lines.length, removed: 0 };
|
|
119
|
+
}
|
|
120
|
+
const lines = withContext(lcsDiff(oldContent.split("\n"), newContent.split("\n")));
|
|
121
|
+
return { lines, ...stats(lines) };
|
|
122
|
+
}
|
package/dist/dock.d.ts
CHANGED
|
@@ -4,14 +4,16 @@
|
|
|
4
4
|
* implementation: zero dependencies, line-level ANSI, no differential
|
|
5
5
|
* renderer.
|
|
6
6
|
*
|
|
7
|
-
* Layout (H = terminal height): rows 1..H-
|
|
8
|
-
* streams and scrolls here, never touching the bottom), row H-
|
|
9
|
-
* dotted separator (╌), row H-
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
7
|
+
* Layout (H = terminal height): rows 1..H-4 = the scroll region (the body
|
|
8
|
+
* streams and scrolls here, never touching the bottom), row H-3 = the
|
|
9
|
+
* upper dim dotted separator (╌), row H-2 = the input line (the blue
|
|
10
|
+
* brick ▌you> + the v2c editor's row — readline is gone from the TTY
|
|
11
|
+
* path), row H-1 = the lower dotted separator, row H = the live status
|
|
12
|
+
* bar (v3 §03: idle "▸ <mode> · /mode to switch · …", running
|
|
13
|
+
* "▖ working Ns · …"; a takeover question replaces it). Bottom redraws
|
|
14
|
+
* are wrapped in CSI 2026 (synchronized output) to avoid flicker — the
|
|
15
|
+
* pi trick. The visual identity is the kiso brick motif — ▌ half-block,
|
|
16
|
+
* dotted separators — deliberately NOT the CC rounded frame nor the pi
|
|
15
17
|
* editor (ADR-0039 Amendment 2).
|
|
16
18
|
*
|
|
17
19
|
* Pipes / NO_COLOR: the dock never activates; the v2a line mode stays
|
|
@@ -27,7 +29,11 @@ export declare class Dock {
|
|
|
27
29
|
line: string;
|
|
28
30
|
cursor: number;
|
|
29
31
|
}, prompt: string): void;
|
|
30
|
-
/** Enter docked mode:
|
|
32
|
+
/** Enter docked mode: draw the chrome. #13 (P1): the DECSTBM scroll
|
|
33
|
+
* region is GONE — v2d-B (ADR-0040): the body uses plain LF scrolling
|
|
34
|
+
* so frozen lines enter the native scrollback deterministically
|
|
35
|
+
* (region-scrolled lines are terminal-dependent — some terminals drop
|
|
36
|
+
* them). The dock rows are redrawn by the body after every scroll. A
|
|
31
37
|
* TTY without a real window size (rows < 4) stays in the v2a line
|
|
32
38
|
* mode — the bottom three rows need room to exist. */
|
|
33
39
|
enter(): void;
|
|
@@ -36,21 +42,15 @@ export declare class Dock {
|
|
|
36
42
|
* from main's finally on EVERY exit path (kill -9 excepted — README:
|
|
37
43
|
* `reset` saves it). */
|
|
38
44
|
exit(): void;
|
|
39
|
-
/** SIGWINCH: recompute the
|
|
45
|
+
/** SIGWINCH: recompute the size, redraw the chrome. */
|
|
40
46
|
onResize(): void;
|
|
41
|
-
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
* cosmetics: readline tracks its cursor internally and NEVER
|
|
49
|
-
* re-syncs after an external move — a body write that left the
|
|
50
|
-
* cursor at column 1 made the next keystroke overwrite the prompt
|
|
51
|
-
* (probe-confirmed; the dock's redraw self-repaired ~200ms later,
|
|
52
|
-
* which read the user as cursor drift). */
|
|
53
|
-
writeBody(text: string): void;
|
|
47
|
+
/** v3 §04: bind the editor's slash-command menu state — the menu rows
|
|
48
|
+
* render ABOVE the chrome (over the body's bottom rows; the menu
|
|
49
|
+
* opens while the buffer is a "/" prefix, when no tail is live). */
|
|
50
|
+
bindMenu(state: () => {
|
|
51
|
+
items: readonly import("./editor.js").MenuItem[];
|
|
52
|
+
selected: number;
|
|
53
|
+
} | null): void;
|
|
54
54
|
/** The input line's edit column — prompt width + cursor + 1. The
|
|
55
55
|
* dock's redraw and the body's cursor return both end here, so the
|
|
56
56
|
* ACTUAL cursor always equals what the editor tracks. The width is
|
|
@@ -66,11 +66,10 @@ export declare class Dock {
|
|
|
66
66
|
* input line by the caller's readline); clearQuestion() restores. */
|
|
67
67
|
showQuestion(question: string): void;
|
|
68
68
|
clearQuestion(): void;
|
|
69
|
-
/** The bottom
|
|
69
|
+
/** The bottom four rows, wrapped in CSI 2026 (synchronized output —
|
|
70
70
|
* the pi trick against flicker). The cursor ends at the input line's
|
|
71
|
-
* edit position.
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* brick ▌you> + the editor's visible slice. */
|
|
71
|
+
* edit position. v3 §03: the upper ╌ row, the input row, the lower
|
|
72
|
+
* ╌ row, the status row — the status is dim (blue accents inside
|
|
73
|
+
* come from the CLI's composition). */
|
|
75
74
|
redraw(): void;
|
|
76
75
|
}
|