@vincemakes/kiso-code 0.1.14 → 0.1.15
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 +118 -0
- package/dist/body.js +419 -0
- package/dist/dock.d.ts +7 -0
- package/dist/dock.js +5 -1
- package/dist/index.js +108 -122
- package/package.json +7 -7
package/dist/body.d.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2d — the body renderer: ONE writer for the stdout scroll region.
|
|
3
|
+
*
|
|
4
|
+
* The v2b/v2c body writes streamed directly (each event wrote its bytes),
|
|
5
|
+
* so the tool lines, thinking folds, and text deltas could interleave in
|
|
6
|
+
* the same frame — and a tool's life was scattered across several writes
|
|
7
|
+
* (the "leak"). v2d: every event handler ONLY mutates cell state; the
|
|
8
|
+
* Body's render loop is the only thing that writes the region.
|
|
9
|
+
*
|
|
10
|
+
* Frozen semantics: a completed cell prints its final form into the
|
|
11
|
+
* scroll region ONCE and is never touched again. The ACTIVE TAIL — the
|
|
12
|
+
* unfinished cells — renders at the region's bottom (between the frozen
|
|
13
|
+
* area and the dock) and redraws in place, CSI 2026 wrapped. State
|
|
14
|
+
* changes coalesce to ≥16ms frames; a 200ms heartbeat drives the running
|
|
15
|
+
* spinners and elapsed timers. An over-height tail (rare) overflows to
|
|
16
|
+
* freeze by completion order.
|
|
17
|
+
*
|
|
18
|
+
* Pipes / NO_COLOR: the Body runs in PASSTHROUGH — every mutation writes
|
|
19
|
+
* the v2b/v2c line-mode bytes immediately, byte-for-byte (the existing
|
|
20
|
+
* e2e guards it). The cell renderer never activates.
|
|
21
|
+
*
|
|
22
|
+
* The cell model is ours (UserCell / ThinkingCell / ToolCell / TextCell /
|
|
23
|
+
* NoticeCell / raw block) — deliberately NOT pi's Component interface
|
|
24
|
+
* shape (ADR-0040).
|
|
25
|
+
*/
|
|
26
|
+
/** One completed-or-active body line. The renderer's only state. */
|
|
27
|
+
export type BodyCell = {
|
|
28
|
+
kind: "user";
|
|
29
|
+
text: string;
|
|
30
|
+
done: true;
|
|
31
|
+
} | {
|
|
32
|
+
kind: "thinking";
|
|
33
|
+
text: string;
|
|
34
|
+
done: boolean;
|
|
35
|
+
} | {
|
|
36
|
+
kind: "tool";
|
|
37
|
+
name: string;
|
|
38
|
+
input: string;
|
|
39
|
+
state: "pending" | "approval" | "running" | "done";
|
|
40
|
+
isError: boolean;
|
|
41
|
+
resultText: string;
|
|
42
|
+
startedAt: number | null;
|
|
43
|
+
doneAt: number | null;
|
|
44
|
+
done: boolean;
|
|
45
|
+
} | {
|
|
46
|
+
kind: "text";
|
|
47
|
+
text: string;
|
|
48
|
+
done: boolean;
|
|
49
|
+
} | {
|
|
50
|
+
kind: "notice";
|
|
51
|
+
text: string;
|
|
52
|
+
done: true;
|
|
53
|
+
} | {
|
|
54
|
+
kind: "raw";
|
|
55
|
+
lines: string[];
|
|
56
|
+
done: true;
|
|
57
|
+
} | {
|
|
58
|
+
kind: "terminal";
|
|
59
|
+
label: string;
|
|
60
|
+
line: string;
|
|
61
|
+
done: true;
|
|
62
|
+
};
|
|
63
|
+
export interface BodyOptions {
|
|
64
|
+
/** Is the cell renderer live? A color TTY with a real size — checked
|
|
65
|
+
* per mutation (the TIOCSWINSZ can land after main constructs us). */
|
|
66
|
+
active: () => boolean;
|
|
67
|
+
/** The terminal height (rows) — live, for the region geometry. */
|
|
68
|
+
height: () => number;
|
|
69
|
+
/** The terminal width (cols) — live, for wrap estimates. */
|
|
70
|
+
width: () => number;
|
|
71
|
+
/** The input line's edit column — the render's cursor home. */
|
|
72
|
+
editCol: () => number;
|
|
73
|
+
/** The dock's redraw (the bottom three rows) — the body never writes
|
|
74
|
+
* below the region, but the frame may call it to re-pin the chrome. */
|
|
75
|
+
onDock?: () => void;
|
|
76
|
+
/** The stdout writer — injectable for unit tests (default: stdout). */
|
|
77
|
+
write?: (s: string) => void;
|
|
78
|
+
}
|
|
79
|
+
export declare class Body {
|
|
80
|
+
#private;
|
|
81
|
+
constructor(opts: BodyOptions);
|
|
82
|
+
/** Teardown — flush a pending frame, stop the timers. */
|
|
83
|
+
close(): void;
|
|
84
|
+
/** The last COMPLETE thinking block, for /think. */
|
|
85
|
+
lastThinking(): string | null;
|
|
86
|
+
/** The last completed tool call, for /last. */
|
|
87
|
+
lastTool(): {
|
|
88
|
+
name: string;
|
|
89
|
+
input: Record<string, unknown>;
|
|
90
|
+
result: {
|
|
91
|
+
content: string;
|
|
92
|
+
isError: boolean;
|
|
93
|
+
};
|
|
94
|
+
} | null;
|
|
95
|
+
userLine(text: string): void;
|
|
96
|
+
thinkingAppend(text: string): void;
|
|
97
|
+
thinkingEnd(): void;
|
|
98
|
+
toolStart(name: string, callId: string, input: Record<string, unknown>): void;
|
|
99
|
+
toolApproval(callId: string): void;
|
|
100
|
+
toolRunning(callId: string): void;
|
|
101
|
+
toolSucceeded(callId: string): void;
|
|
102
|
+
toolFailed(callId: string, error: string): void;
|
|
103
|
+
toolResult(callId: string, result: {
|
|
104
|
+
content: string;
|
|
105
|
+
isError: boolean;
|
|
106
|
+
}): void;
|
|
107
|
+
textAppend(text: string): void;
|
|
108
|
+
textEnd(): void;
|
|
109
|
+
/** The terminal's status line + the rhythm gap (one blank). */
|
|
110
|
+
terminal(label: string, statusLine: string): void;
|
|
111
|
+
notice(text: string): void;
|
|
112
|
+
/** A pre-rendered block (the banner, the session line, slash-command
|
|
113
|
+
* outputs) — frozen immediately. */
|
|
114
|
+
raw(lines: string[]): void;
|
|
115
|
+
/** The one writer. Frozen cells print once; the tail redraws in place,
|
|
116
|
+
* CSI 2026 wrapped; the cursor lands at the input edit column. */
|
|
117
|
+
render(): void;
|
|
118
|
+
}
|
package/dist/body.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2d — the body renderer: ONE writer for the stdout scroll region.
|
|
3
|
+
*
|
|
4
|
+
* The v2b/v2c body writes streamed directly (each event wrote its bytes),
|
|
5
|
+
* so the tool lines, thinking folds, and text deltas could interleave in
|
|
6
|
+
* the same frame — and a tool's life was scattered across several writes
|
|
7
|
+
* (the "leak"). v2d: every event handler ONLY mutates cell state; the
|
|
8
|
+
* Body's render loop is the only thing that writes the region.
|
|
9
|
+
*
|
|
10
|
+
* Frozen semantics: a completed cell prints its final form into the
|
|
11
|
+
* scroll region ONCE and is never touched again. The ACTIVE TAIL — the
|
|
12
|
+
* unfinished cells — renders at the region's bottom (between the frozen
|
|
13
|
+
* area and the dock) and redraws in place, CSI 2026 wrapped. State
|
|
14
|
+
* changes coalesce to ≥16ms frames; a 200ms heartbeat drives the running
|
|
15
|
+
* spinners and elapsed timers. An over-height tail (rare) overflows to
|
|
16
|
+
* freeze by completion order.
|
|
17
|
+
*
|
|
18
|
+
* Pipes / NO_COLOR: the Body runs in PASSTHROUGH — every mutation writes
|
|
19
|
+
* the v2b/v2c line-mode bytes immediately, byte-for-byte (the existing
|
|
20
|
+
* e2e guards it). The cell renderer never activates.
|
|
21
|
+
*
|
|
22
|
+
* The cell model is ours (UserCell / ThinkingCell / ToolCell / TextCell /
|
|
23
|
+
* NoticeCell / raw block) — deliberately NOT pi's Component interface
|
|
24
|
+
* shape (ADR-0040).
|
|
25
|
+
*/
|
|
26
|
+
import { displayWidth } from "./editor.js";
|
|
27
|
+
import { escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, } from "./render.js";
|
|
28
|
+
/** The spinner glyphs, cycled by the heartbeat. */
|
|
29
|
+
const SPINNER = ["◐", "◓", "◑", "◒"];
|
|
30
|
+
const TOOL_SUMMARY_MAX = 60; // the tool line's parameter summary, chars
|
|
31
|
+
const FRAME_MS = 16; // state changes coalesce to ≥16ms frames
|
|
32
|
+
const HEARTBEAT_MS = 200; // spinner / elapsed cadence
|
|
33
|
+
export class Body {
|
|
34
|
+
#active;
|
|
35
|
+
#opts;
|
|
36
|
+
#cells = [];
|
|
37
|
+
#nextFrozen = 0; // index of the first not-yet-printed cell
|
|
38
|
+
#frozenRows = 0; // rows of the region occupied by printed cells
|
|
39
|
+
#oldTailTop = 0; // the tail's previous first row — for the clear pass
|
|
40
|
+
#frameTimer = null;
|
|
41
|
+
#heartbeat = null;
|
|
42
|
+
#dirty = false;
|
|
43
|
+
#spinnerI = 0;
|
|
44
|
+
#lastThinking = null;
|
|
45
|
+
#lastTool = null;
|
|
46
|
+
#pendingCalls = new Map();
|
|
47
|
+
#pipeBuf = ""; // the passthrough's thinking buffer — the cell model needs no buffer
|
|
48
|
+
#toolCells = new Map(); // callId → cell index (parallel tools)
|
|
49
|
+
#write;
|
|
50
|
+
constructor(opts) {
|
|
51
|
+
this.#opts = opts;
|
|
52
|
+
this.#write = opts.write ?? ((s) => process.stdout.write(s));
|
|
53
|
+
this.#active = opts.active();
|
|
54
|
+
if (this.#isActive()) {
|
|
55
|
+
this.#heartbeat = setInterval(() => {
|
|
56
|
+
this.#spinnerI = (this.#spinnerI + 1) % SPINNER.length;
|
|
57
|
+
this.#dirty = true; // the running cells' glyph/elapsed advance
|
|
58
|
+
this.#scheduleFrame();
|
|
59
|
+
}, HEARTBEAT_MS);
|
|
60
|
+
this.#heartbeat.unref();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Live re-check — a TTY whose size lands after construction flips in. */
|
|
64
|
+
#isActive() {
|
|
65
|
+
this.#active = this.#opts.active();
|
|
66
|
+
return this.#active;
|
|
67
|
+
}
|
|
68
|
+
/** Teardown — flush a pending frame, stop the timers. */
|
|
69
|
+
close() {
|
|
70
|
+
if (this.#frameTimer !== null) {
|
|
71
|
+
clearTimeout(this.#frameTimer);
|
|
72
|
+
this.#frameTimer = null;
|
|
73
|
+
}
|
|
74
|
+
if (this.#heartbeat !== null) {
|
|
75
|
+
clearInterval(this.#heartbeat);
|
|
76
|
+
this.#heartbeat = null;
|
|
77
|
+
}
|
|
78
|
+
if (this.#dirty)
|
|
79
|
+
this.render();
|
|
80
|
+
}
|
|
81
|
+
/** The last COMPLETE thinking block, for /think. */
|
|
82
|
+
lastThinking() {
|
|
83
|
+
return this.#lastThinking;
|
|
84
|
+
}
|
|
85
|
+
/** The last completed tool call, for /last. */
|
|
86
|
+
lastTool() {
|
|
87
|
+
return this.#lastTool;
|
|
88
|
+
}
|
|
89
|
+
// ---- mutations (the ONLY way the CLI touches the body) ----
|
|
90
|
+
userLine(text) {
|
|
91
|
+
if (!this.#isActive()) {
|
|
92
|
+
this.#closeOpenThinking();
|
|
93
|
+
const p = palette();
|
|
94
|
+
this.#write(`${p.blue}you> ${escapeTerminal(text)}${p.reset}\n`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.#closeOpenThinking();
|
|
98
|
+
this.#cells.push({ kind: "user", text, done: true });
|
|
99
|
+
this.#mark();
|
|
100
|
+
}
|
|
101
|
+
thinkingAppend(text) {
|
|
102
|
+
if (!this.#isActive()) {
|
|
103
|
+
this.#pipeBuf += text; // buffered; the fold prints at the block's end
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const last = this.#cells[this.#cells.length - 1];
|
|
107
|
+
if (last !== undefined && last.kind === "thinking" && !last.done) {
|
|
108
|
+
last.text += text;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
this.#cells.push({ kind: "thinking", text, done: false });
|
|
112
|
+
}
|
|
113
|
+
this.#mark();
|
|
114
|
+
}
|
|
115
|
+
thinkingEnd() {
|
|
116
|
+
const last = this.#cells[this.#cells.length - 1];
|
|
117
|
+
if (last !== undefined && last.kind === "thinking" && !last.done) {
|
|
118
|
+
last.done = true;
|
|
119
|
+
this.#lastThinking = last.text;
|
|
120
|
+
if (!this.#isActive())
|
|
121
|
+
process.stdout.write(foldThinking(last.text));
|
|
122
|
+
this.#mark();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
toolStart(name, callId, input) {
|
|
126
|
+
const summary = JSON.stringify(input).slice(0, TOOL_SUMMARY_MAX);
|
|
127
|
+
// Registered BEFORE the passthrough branch — /last and the pipe
|
|
128
|
+
// summary need the call on BOTH paths.
|
|
129
|
+
this.#pendingCalls.set(callId, { name, input, result: { content: "", isError: false } });
|
|
130
|
+
if (!this.#isActive()) {
|
|
131
|
+
this.#closeOpenThinking();
|
|
132
|
+
process.stdout.write(`→ ${escapeTerminal(name)}(${escapeTerminal(JSON.stringify(input).slice(0, 200))})\n`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
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 });
|
|
137
|
+
this.#mark();
|
|
138
|
+
}
|
|
139
|
+
toolApproval(callId) {
|
|
140
|
+
if (!this.#isActive())
|
|
141
|
+
return;
|
|
142
|
+
const cell = this.#toolCell(callId);
|
|
143
|
+
if (cell !== null && cell.kind === "tool" && !cell.done)
|
|
144
|
+
cell.state = "approval";
|
|
145
|
+
this.#mark();
|
|
146
|
+
}
|
|
147
|
+
toolRunning(callId) {
|
|
148
|
+
if (!this.#isActive()) {
|
|
149
|
+
const p = palette();
|
|
150
|
+
process.stdout.write(`${p.dim} running…${p.reset}\n`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const cell = this.#toolCell(callId);
|
|
154
|
+
if (cell !== null && cell.kind === "tool" && !cell.done) {
|
|
155
|
+
cell.state = "running";
|
|
156
|
+
cell.startedAt = Date.now();
|
|
157
|
+
}
|
|
158
|
+
this.#mark();
|
|
159
|
+
}
|
|
160
|
+
toolSucceeded(callId) {
|
|
161
|
+
if (!this.#isActive()) {
|
|
162
|
+
process.stdout.write(` ok\n`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
toolFailed(callId, error) {
|
|
166
|
+
if (!this.#isActive()) {
|
|
167
|
+
const p = palette();
|
|
168
|
+
process.stdout.write(`${p.red} failed: ${escapeTerminal(error.slice(0, 160))}${p.reset}\n`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
toolResult(callId, result) {
|
|
172
|
+
const call = this.#pendingCalls.get(callId);
|
|
173
|
+
if (call !== undefined) {
|
|
174
|
+
call.result = result;
|
|
175
|
+
this.#lastTool = { name: call.name, input: call.input, result };
|
|
176
|
+
this.#pendingCalls.delete(callId);
|
|
177
|
+
}
|
|
178
|
+
if (!this.#isActive()) {
|
|
179
|
+
// The v2b/v2c pipe bytes: the summary line + the [result] line.
|
|
180
|
+
const p = palette();
|
|
181
|
+
process.stdout.write(`${renderToolSummary(call?.name ?? "?", call?.input ?? {}, result)}\n` +
|
|
182
|
+
`${p.dim}${result.isError ? p.red : p.dim} [result${result.isError ? " ✗" : ""}] ${foldResult(result.content)}${p.reset}\n`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const cell = this.#toolCell(callId);
|
|
186
|
+
this.#toolCells.delete(callId);
|
|
187
|
+
if (cell !== null && cell.kind === "tool" && !cell.done) {
|
|
188
|
+
cell.state = "done";
|
|
189
|
+
cell.isError = result.isError;
|
|
190
|
+
cell.resultText = result.content;
|
|
191
|
+
cell.doneAt = Date.now();
|
|
192
|
+
cell.done = true;
|
|
193
|
+
}
|
|
194
|
+
this.#mark();
|
|
195
|
+
}
|
|
196
|
+
textAppend(text) {
|
|
197
|
+
if (!this.#isActive()) {
|
|
198
|
+
this.#closeOpenThinking();
|
|
199
|
+
process.stdout.write(escapeTerminal(text));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const last = this.#cells[this.#cells.length - 1];
|
|
203
|
+
if (last !== undefined && last.kind === "text" && !last.done) {
|
|
204
|
+
last.text += text;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
this.#closeOpenThinking();
|
|
208
|
+
this.#cells.push({ kind: "text", text, done: false });
|
|
209
|
+
}
|
|
210
|
+
this.#mark();
|
|
211
|
+
}
|
|
212
|
+
textEnd() {
|
|
213
|
+
if (!this.#isActive()) {
|
|
214
|
+
process.stdout.write("\n");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const last = this.#cells[this.#cells.length - 1];
|
|
218
|
+
if (last !== undefined && last.kind === "text" && !last.done)
|
|
219
|
+
last.done = true;
|
|
220
|
+
this.#mark();
|
|
221
|
+
}
|
|
222
|
+
/** The terminal's status line + the rhythm gap (one blank). */
|
|
223
|
+
terminal(label, statusLine) {
|
|
224
|
+
if (!this.#isActive()) {
|
|
225
|
+
this.#closeOpenThinking();
|
|
226
|
+
// the v2c bytes: the terminal label (\ndone\n) + the status gap.
|
|
227
|
+
process.stdout.write(label + renderTerminalGap(statusLine));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
this.#closeOpenThinking();
|
|
231
|
+
this.#cells.push({ kind: "terminal", label: label.trim(), line: statusLine, done: true });
|
|
232
|
+
this.#mark();
|
|
233
|
+
}
|
|
234
|
+
notice(text) {
|
|
235
|
+
if (!this.#isActive()) {
|
|
236
|
+
this.#closeOpenThinking();
|
|
237
|
+
process.stdout.write(`${text}\n`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
this.#closeOpenThinking();
|
|
241
|
+
this.#cells.push({ kind: "notice", text, done: true });
|
|
242
|
+
this.#mark();
|
|
243
|
+
}
|
|
244
|
+
/** A pre-rendered block (the banner, the session line, slash-command
|
|
245
|
+
* outputs) — frozen immediately. */
|
|
246
|
+
raw(lines) {
|
|
247
|
+
if (!this.#isActive()) {
|
|
248
|
+
this.#closeOpenThinking();
|
|
249
|
+
for (const line of lines)
|
|
250
|
+
process.stdout.write(`${line}\n`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
this.#closeOpenThinking();
|
|
254
|
+
this.#cells.push({ kind: "raw", lines, done: true });
|
|
255
|
+
this.#mark();
|
|
256
|
+
}
|
|
257
|
+
// ---- rendering ----
|
|
258
|
+
#mark() {
|
|
259
|
+
if (!this.#isActive())
|
|
260
|
+
return;
|
|
261
|
+
this.#dirty = true;
|
|
262
|
+
this.#scheduleFrame();
|
|
263
|
+
}
|
|
264
|
+
#scheduleFrame() {
|
|
265
|
+
if (this.#frameTimer !== null)
|
|
266
|
+
return;
|
|
267
|
+
this.#frameTimer = setTimeout(() => {
|
|
268
|
+
this.#frameTimer = null;
|
|
269
|
+
if (this.#dirty) {
|
|
270
|
+
this.#dirty = false;
|
|
271
|
+
this.render();
|
|
272
|
+
}
|
|
273
|
+
}, FRAME_MS);
|
|
274
|
+
this.#frameTimer.unref();
|
|
275
|
+
}
|
|
276
|
+
/** The one writer. Frozen cells print once; the tail redraws in place,
|
|
277
|
+
* CSI 2026 wrapped; the cursor lands at the input edit column. */
|
|
278
|
+
render() {
|
|
279
|
+
if (!this.#isActive())
|
|
280
|
+
return;
|
|
281
|
+
const H = this.#opts.height();
|
|
282
|
+
const W = this.#opts.width();
|
|
283
|
+
const regionBottom = H - 3;
|
|
284
|
+
if (regionBottom < 1)
|
|
285
|
+
return;
|
|
286
|
+
const out = [];
|
|
287
|
+
out.push("\x1b[?2026h");
|
|
288
|
+
// 1. freeze completed cells — print their final form at the frozen
|
|
289
|
+
// area's next rows. The terminal scrolls the region when full.
|
|
290
|
+
while (this.#nextFrozen < this.#cells.length && this.#cells[this.#nextFrozen].done) {
|
|
291
|
+
const cell = this.#cells[this.#nextFrozen];
|
|
292
|
+
const lines = this.#cellLines(cell, W);
|
|
293
|
+
for (const line of lines) {
|
|
294
|
+
const row = Math.min(this.#frozenRows + 1, regionBottom);
|
|
295
|
+
out.push(`\x1b[${row};1H\x1b[0K${line}`);
|
|
296
|
+
if (this.#frozenRows + 1 > regionBottom) {
|
|
297
|
+
out.push("\n"); // the region scrolls — the frozen rows shift up
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
this.#frozenRows += 1;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
this.#nextFrozen += 1;
|
|
304
|
+
}
|
|
305
|
+
// 2. the active tail — clear its old area, draw the cells.
|
|
306
|
+
const tail = this.#cells.slice(this.#nextFrozen);
|
|
307
|
+
const tailHeight = tail.reduce((n, c) => n + this.#cellHeight(c, W), 0);
|
|
308
|
+
const tailTop = Math.max(1, regionBottom - tailHeight + 1);
|
|
309
|
+
const clearFrom = Math.min(this.#oldTailTop === 0 ? tailTop : this.#oldTailTop, tailTop);
|
|
310
|
+
for (let row = clearFrom; row <= regionBottom; row += 1) {
|
|
311
|
+
out.push(`\x1b[${row};1H\x1b[0K`);
|
|
312
|
+
}
|
|
313
|
+
let row = tailTop;
|
|
314
|
+
for (const cell of tail) {
|
|
315
|
+
for (const line of this.#cellLines(cell, W)) {
|
|
316
|
+
out.push(`\x1b[${row};1H${line}`);
|
|
317
|
+
row += 1;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
this.#oldTailTop = tailTop;
|
|
321
|
+
// 3. the cursor home — the input line's edit column.
|
|
322
|
+
out.push(`\x1b[${H};${this.#opts.editCol()}H`);
|
|
323
|
+
out.push("\x1b[?2026l");
|
|
324
|
+
this.#write(out.join(""));
|
|
325
|
+
}
|
|
326
|
+
// ---- cell → lines ----
|
|
327
|
+
#cellLines(cell, W) {
|
|
328
|
+
const p = palette();
|
|
329
|
+
switch (cell.kind) {
|
|
330
|
+
case "user":
|
|
331
|
+
return [`${p.blue}you> ${escapeTerminal(cell.text)}${p.reset}`];
|
|
332
|
+
case "thinking": {
|
|
333
|
+
const block = cell.text;
|
|
334
|
+
const trimmed = escapeTerminal(block.trim());
|
|
335
|
+
if (trimmed.length <= 100)
|
|
336
|
+
return [`${p.dim}…${trimmed}${p.reset}`];
|
|
337
|
+
return [`${p.dim}…${trimmed.slice(0, 100)} (… ${block.length} chars · /think shows full)${p.reset}`];
|
|
338
|
+
}
|
|
339
|
+
case "tool": {
|
|
340
|
+
const name = escapeTerminal(cell.name);
|
|
341
|
+
const summary = escapeTerminal(cell.input);
|
|
342
|
+
if (cell.state === "done") {
|
|
343
|
+
const elapsed = cell.startedAt !== null && cell.doneAt !== null ? ((cell.doneAt - cell.startedAt) / 1000).toFixed(1) : "?";
|
|
344
|
+
if (cell.isError) {
|
|
345
|
+
const err = escapeTerminal(cell.resultText.split("\n")[0].slice(0, 60));
|
|
346
|
+
return [`${p.red}✗ ${name} (${err}, ${elapsed}s)${p.reset}`];
|
|
347
|
+
}
|
|
348
|
+
return [`${p.blue}✓ ${name}${p.reset} (${summary}, ${elapsed}s)`];
|
|
349
|
+
}
|
|
350
|
+
if (cell.state === "approval")
|
|
351
|
+
return [`→ ${name} ${summary} ${p.blue}⏸${p.reset}`];
|
|
352
|
+
if (cell.state === "running") {
|
|
353
|
+
const elapsed = cell.startedAt !== null ? Math.max(1, Math.round((Date.now() - cell.startedAt) / 1000)) : 1;
|
|
354
|
+
return [`→ ${name} ${summary} ${p.blue}${SPINNER[this.#spinnerI % SPINNER.length]}${p.reset} ${elapsed}s`];
|
|
355
|
+
}
|
|
356
|
+
return [`→ ${name} ${summary}`];
|
|
357
|
+
}
|
|
358
|
+
case "text": {
|
|
359
|
+
const text = escapeTerminal(cell.text);
|
|
360
|
+
const wrapped = this.#wrap(text, W);
|
|
361
|
+
return wrapped.length > 0 ? wrapped : [""];
|
|
362
|
+
}
|
|
363
|
+
case "notice":
|
|
364
|
+
return [escapeTerminal(cell.text)];
|
|
365
|
+
case "raw":
|
|
366
|
+
return cell.lines.map((l) => escapeTerminal(l));
|
|
367
|
+
case "terminal":
|
|
368
|
+
// the honest label (done / aborted / error) + the status + the
|
|
369
|
+
// rhythm gap blank
|
|
370
|
+
return [cell.label, cell.line, ""];
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
#cellHeight(cell, W) {
|
|
374
|
+
const lines = this.#cellLines(cell, W);
|
|
375
|
+
return Math.max(1, lines.length);
|
|
376
|
+
}
|
|
377
|
+
/** Wrap a body text by display width (the terminal's own wrapping,
|
|
378
|
+
* approximated — documented; the region clamp keeps the dock safe). */
|
|
379
|
+
#wrap(text, W) {
|
|
380
|
+
if (W < 4)
|
|
381
|
+
return [text];
|
|
382
|
+
const out = [];
|
|
383
|
+
let current = "";
|
|
384
|
+
let width = 0;
|
|
385
|
+
for (const ch of text) {
|
|
386
|
+
const cw = displayWidth(ch);
|
|
387
|
+
if (ch === "\n" || width + cw > W) {
|
|
388
|
+
out.push(current);
|
|
389
|
+
current = "";
|
|
390
|
+
width = 0;
|
|
391
|
+
if (ch === "\n")
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
current += ch;
|
|
395
|
+
width += cw;
|
|
396
|
+
}
|
|
397
|
+
out.push(current);
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
400
|
+
#toolCell(callId) {
|
|
401
|
+
const i = this.#toolCells.get(callId);
|
|
402
|
+
return i === undefined ? null : (this.#cells[i] ?? null);
|
|
403
|
+
}
|
|
404
|
+
/** Close an open thinking cell when a new cell starts (the block's
|
|
405
|
+
* fold freezes at the transition). */
|
|
406
|
+
#closeOpenThinking() {
|
|
407
|
+
if (!this.#isActive() && this.#pipeBuf !== "") {
|
|
408
|
+
this.#lastThinking = this.#pipeBuf;
|
|
409
|
+
process.stdout.write(foldThinking(this.#pipeBuf));
|
|
410
|
+
this.#pipeBuf = "";
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const last = this.#cells[this.#cells.length - 1];
|
|
414
|
+
if (last !== undefined && last.kind === "thinking" && !last.done) {
|
|
415
|
+
last.done = true;
|
|
416
|
+
this.#lastThinking = last.text;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
package/dist/dock.d.ts
CHANGED
|
@@ -51,6 +51,13 @@ export declare class Dock {
|
|
|
51
51
|
* (probe-confirmed; the dock's redraw self-repaired ~200ms later,
|
|
52
52
|
* which read the user as cursor drift). */
|
|
53
53
|
writeBody(text: string): void;
|
|
54
|
+
/** The input line's edit column — prompt width + cursor + 1. The
|
|
55
|
+
* dock's redraw and the body's cursor return both end here, so the
|
|
56
|
+
* ACTUAL cursor always equals what the editor tracks. The width is
|
|
57
|
+
* DISPLAY width (the editor's cursor column is already width-based —
|
|
58
|
+
* the CJK drift root cause, editor.ts). v2d: public — the Body's
|
|
59
|
+
* render loop ends at this column. */
|
|
60
|
+
editCol(): number;
|
|
54
61
|
/** The status bar's base text (usage, ctx, session, …). */
|
|
55
62
|
setStatus(text: string): void;
|
|
56
63
|
/** The live tail — the spinner glyph or "running <tool> Ns". */
|
package/dist/dock.js
CHANGED
|
@@ -122,7 +122,11 @@ export class Dock {
|
|
|
122
122
|
* dock's redraw and the body's cursor return both end here, so the
|
|
123
123
|
* ACTUAL cursor always equals what the editor tracks. The width is
|
|
124
124
|
* DISPLAY width (the editor's cursor column is already width-based —
|
|
125
|
-
* the CJK drift root cause, editor.ts).
|
|
125
|
+
* the CJK drift root cause, editor.ts). v2d: public — the Body's
|
|
126
|
+
* render loop ends at this column. */
|
|
127
|
+
editCol() {
|
|
128
|
+
return this.#inputCol();
|
|
129
|
+
}
|
|
126
130
|
#inputCol() {
|
|
127
131
|
const inp = this.#inputState();
|
|
128
132
|
const promptWidth = displayWidth(this.#inputPrompt.replace(/\x1b\[[0-9;]*m/g, ""));
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
19
19
|
import { createInterface } from "node:readline";
|
|
20
|
+
import { Body } from "./body.js";
|
|
20
21
|
import { Editor, PROMPT as EDITOR_PROMPT } from "./editor.js";
|
|
21
22
|
import { homedir, tmpdir } from "node:os";
|
|
22
23
|
import { dirname, join } from "node:path";
|
|
@@ -209,17 +210,15 @@ function makeLineInput() {
|
|
|
209
210
|
/** v2b: the bottom-anchored UI — docked only on a color TTY; pipes and
|
|
210
211
|
* NO_COLOR stay the v2a line mode byte-for-byte. */
|
|
211
212
|
const dock = new Dock();
|
|
212
|
-
/**
|
|
213
|
-
* the
|
|
214
|
-
*
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
process.stdout.write(text);
|
|
220
|
-
}
|
|
213
|
+
/** v2d: the body renderer — the ONE writer of the stdout scroll region
|
|
214
|
+
* (the frozen area + the active tail). Pipes run it in passthrough (the
|
|
215
|
+
* v2b/v2c line-mode bytes, byte-for-byte). Created in main; closed on
|
|
216
|
+
* every exit path. */
|
|
217
|
+
let body;
|
|
218
|
+
/** v2d: body output routes through the cell renderer — the single writer.
|
|
219
|
+
* bodyLog adds the trailing newline; internal newlines are preserved. */
|
|
221
220
|
function bodyLog(text) {
|
|
222
|
-
|
|
221
|
+
body.raw(text.split("\n"));
|
|
223
222
|
}
|
|
224
223
|
/** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
|
|
225
224
|
* is gone) — docked only, 200ms rotation between the request and the
|
|
@@ -700,43 +699,16 @@ const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
|
700
699
|
* line's form; `liveInput` (non-null only in interactive chat) carries the
|
|
701
700
|
* last line THIS process's readline consumed — the double-echo filter.
|
|
702
701
|
*/
|
|
703
|
-
async function consumeRun(session, run, input, turnNo,
|
|
702
|
+
async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb) {
|
|
704
703
|
let last;
|
|
705
|
-
// B 区: tool_call_end → (name, input) for the summary; tool_result →
|
|
706
|
-
// one summary line. Usage events feed the status line.
|
|
707
|
-
const pendingCalls = new Map();
|
|
708
704
|
let usage = { in: null, out: null, cache: null, known: false };
|
|
709
|
-
// v2b: thinking blocks buffer and fold to ONE dim line at the block's
|
|
710
|
-
// end (foldThinking); the FULL text goes to /think.
|
|
711
|
-
let thinkingBuf = "";
|
|
712
|
-
const flushThinking = () => {
|
|
713
|
-
if (thinkingBuf === "")
|
|
714
|
-
return;
|
|
715
|
-
lastThinking.current = thinkingBuf;
|
|
716
|
-
bodyWrite(foldThinking(thinkingBuf));
|
|
717
|
-
thinkingBuf = "";
|
|
718
|
-
};
|
|
719
|
-
let thinkingOpen = false;
|
|
720
|
-
// v2b: liveness merged into the status bar (docked); a running timer
|
|
721
|
-
// shows "running <tool> Ns" during a tool execution.
|
|
722
|
-
const stopSpinner = startStatusSpinner();
|
|
723
|
-
let stopRunning = null;
|
|
724
|
-
let firstEvent = true;
|
|
725
705
|
try {
|
|
726
706
|
for await (const ev of run) {
|
|
727
|
-
if (firstEvent) {
|
|
728
|
-
firstEvent = false;
|
|
729
|
-
stopSpinner();
|
|
730
|
-
}
|
|
731
707
|
last = ev;
|
|
732
|
-
// v2a (双回显): the interactive
|
|
733
|
-
//
|
|
734
|
-
// Replayed history (recovery/resume — nobody typed) keeps the event
|
|
735
|
-
// render. Deterministic: exact content match with the consumed line,
|
|
736
|
-
// on a TTY (the only place an echo exists to hand over).
|
|
708
|
+
// v2a (双回显): the interactive echo was already rendered by the
|
|
709
|
+
// input source — rendering the event again is the double echo.
|
|
737
710
|
// v2b: DOCKED — the echo lives in the input row (H), NOT the body;
|
|
738
|
-
// the body render is the ONLY visible copy of the sent line.
|
|
739
|
-
// user's typed text must not vanish after Enter.
|
|
711
|
+
// the body render is the ONLY visible copy of the sent line.
|
|
740
712
|
if (ev.type === "user_input" &&
|
|
741
713
|
liveInput !== null &&
|
|
742
714
|
liveInput.current === (typeof ev.content === "string" ? ev.content : "") &&
|
|
@@ -744,78 +716,87 @@ async function consumeRun(session, run, input, turnNo, lastToolRef, faux, liveIn
|
|
|
744
716
|
!dock.active) {
|
|
745
717
|
continue;
|
|
746
718
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
719
|
+
// v2d: EVERY event only mutates a cell — the Body is the single
|
|
720
|
+
// writer of the scroll region, so interleaving is impossible by
|
|
721
|
+
// construction (ADR-0040).
|
|
722
|
+
switch (ev.type) {
|
|
723
|
+
case "user_input":
|
|
724
|
+
body.userLine(typeof ev.content === "string" ? ev.content : "");
|
|
725
|
+
break;
|
|
726
|
+
case "thinking":
|
|
727
|
+
body.thinkingAppend(ev.text);
|
|
728
|
+
break;
|
|
729
|
+
case "tool_call_end":
|
|
730
|
+
body.toolStart(ev.name, ev.callId, ev.input ?? {});
|
|
731
|
+
break;
|
|
732
|
+
case "tool_execution_started":
|
|
733
|
+
body.toolRunning(ev.callId);
|
|
734
|
+
break;
|
|
735
|
+
case "tool_execution_succeeded":
|
|
736
|
+
body.toolSucceeded(ev.callId);
|
|
737
|
+
break;
|
|
738
|
+
case "tool_execution_failed":
|
|
739
|
+
body.toolFailed(ev.callId, ev.error);
|
|
740
|
+
break;
|
|
741
|
+
case "tool_result": {
|
|
767
742
|
const text = typeof ev.content === "string" ? ev.content : "";
|
|
768
|
-
|
|
769
|
-
|
|
743
|
+
body.toolResult(ev.callId, { content: text, isError: ev.isError });
|
|
744
|
+
break;
|
|
770
745
|
}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
746
|
+
case "text_delta":
|
|
747
|
+
body.textAppend(ev.text);
|
|
748
|
+
break;
|
|
749
|
+
case "text_end":
|
|
750
|
+
body.textEnd();
|
|
751
|
+
break;
|
|
752
|
+
case "usage":
|
|
753
|
+
usage = { in: ev.inputTokens, out: ev.outputTokens, cache: ev.cacheRead, known: ev.known };
|
|
754
|
+
statusCb?.(usage, estimateCtxRatio(session));
|
|
755
|
+
break;
|
|
756
|
+
case "uncertain_pending":
|
|
757
|
+
// 裁决 #12 (ADR-0038): the ⚠ line is pure INFORMATION now — the
|
|
758
|
+
// approval chain guards retries, and the human question belongs
|
|
759
|
+
// only to the crash window's recovery flow (resolveUncertains).
|
|
760
|
+
body.notice(`⚠ ${escapeTerminal(ev.name)} FAILED — the side effect may have applied. ${escapeTerminal(ev.error)}`);
|
|
761
|
+
break;
|
|
762
|
+
case "permission_requested": {
|
|
763
|
+
// v2d: the ToolCell shows the ⏸ badge; the question takes over
|
|
764
|
+
// the dock status position; the answer lands at the input line.
|
|
765
|
+
body.toolApproval(ev.callId);
|
|
766
|
+
const decisionId = ev.decisionId;
|
|
767
|
+
const name = ev.name;
|
|
768
|
+
const answer = await ask(input, `approve ${escapeTerminal(name)}? (y/n) `);
|
|
769
|
+
if (answer === CANCELLED) {
|
|
770
|
+
// 十: a cancellation is a CONSERVATIVE denial, explicitly
|
|
771
|
+
// distinguished from the user typing "n".
|
|
772
|
+
body.notice("[approval cancelled — treated as a denial]");
|
|
773
|
+
await session.approve(decisionId, false);
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
await session.approve(decisionId, answer.trim().toLowerCase().startsWith("y"));
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
case "terminal":
|
|
780
|
+
statusCb?.(usage, estimateCtxRatio(session));
|
|
781
|
+
// v2a rhythm: the honest label (\ndone\n — the completed
|
|
782
|
+
// marker), the status line hugging it, then EXACTLY one blank
|
|
783
|
+
// line before the next prompt.
|
|
784
|
+
body.terminal(renderEvent(ev).text, renderStatusLine(turnNo, usage, estimateCtxRatio(session), faux) ?? "");
|
|
785
|
+
break;
|
|
786
|
+
default: {
|
|
787
|
+
// Events without a cell (stop, …) — the generic render, byte-
|
|
788
|
+
// preserved for the pipe path.
|
|
789
|
+
const rendered = renderEvent(ev);
|
|
790
|
+
if (rendered.text !== "") {
|
|
791
|
+
body.raw(rendered.text.replace(/\n$/, "").split("\n"));
|
|
792
|
+
}
|
|
793
|
+
break;
|
|
800
794
|
}
|
|
801
|
-
await session.approve(decisionId, answer.trim().toLowerCase().startsWith("y"));
|
|
802
|
-
}
|
|
803
|
-
else {
|
|
804
|
-
bodyWrite(rendered.text);
|
|
805
|
-
}
|
|
806
|
-
if (ev.type === "terminal") {
|
|
807
|
-
statusCb?.(usage, estimateCtxRatio(session));
|
|
808
|
-
// v2a rhythm: the status line hugs the terminal (有什么显什么 —
|
|
809
|
-
// null = nothing to show), then EXACTLY one blank line before
|
|
810
|
-
// the next prompt.
|
|
811
|
-
bodyWrite(renderTerminalGap(renderStatusLine(turnNo, usage, estimateCtxRatio(session), faux)));
|
|
812
795
|
}
|
|
813
796
|
}
|
|
814
|
-
|
|
797
|
+
body.thinkingEnd(); // a trailing thinking block folds at the run's end
|
|
815
798
|
}
|
|
816
799
|
finally {
|
|
817
|
-
stopSpinner();
|
|
818
|
-
stopRunning?.();
|
|
819
800
|
}
|
|
820
801
|
return last;
|
|
821
802
|
}
|
|
@@ -861,7 +842,7 @@ async function chat(session, faux, input) {
|
|
|
861
842
|
(async () => {
|
|
862
843
|
let last;
|
|
863
844
|
try {
|
|
864
|
-
last = await consumeRun(session, run, input, myTurn,
|
|
845
|
+
last = await consumeRun(session, run, input, myTurn, faux, liveInput, statusCb);
|
|
865
846
|
currentRun = null;
|
|
866
847
|
// 八: a faux script that ran out of declared turns exits
|
|
867
848
|
// loudly with a non-zero status — never a silent status 0.
|
|
@@ -935,14 +916,12 @@ async function chat(session, faux, input) {
|
|
|
935
916
|
let chain = Promise.resolve();
|
|
936
917
|
let replReady = false;
|
|
937
918
|
const queuedLines = [];
|
|
938
|
-
// B 区: user-turn counter for the status line
|
|
919
|
+
// B 区: user-turn counter for the status line. /last and /think read
|
|
920
|
+
// the body (the ToolCell / ThinkingCell final states).
|
|
939
921
|
let turnNo = 0;
|
|
940
|
-
const lastToolRef = { current: null };
|
|
941
922
|
// v2a: the last line THIS process's readline consumed — the double-echo
|
|
942
923
|
// filter (see consumeRun). Only interactive chat sets it.
|
|
943
924
|
const liveInput = { current: null };
|
|
944
|
-
// v2b: the last complete thinking block, for /think.
|
|
945
|
-
const lastThinking = { current: null };
|
|
946
925
|
// v2c: turns submitted while another runs are QUEUED on the chain — the
|
|
947
926
|
// live count rides the status bar (+N queued).
|
|
948
927
|
let queued = 0;
|
|
@@ -976,10 +955,10 @@ async function chat(session, faux, input) {
|
|
|
976
955
|
return;
|
|
977
956
|
}
|
|
978
957
|
if (trimmed === "/think") {
|
|
979
|
-
// v2b: print the last COMPLETE thinking block —
|
|
980
|
-
//
|
|
958
|
+
// v2b/v2d: print the last COMPLETE thinking block — the body holds
|
|
959
|
+
// it (the ThinkingCell's fold closes at the block's end).
|
|
981
960
|
chain = chain.then(async () => {
|
|
982
|
-
const t = lastThinking
|
|
961
|
+
const t = body.lastThinking();
|
|
983
962
|
if (t === null) {
|
|
984
963
|
bodyLog("[no thinking yet]");
|
|
985
964
|
}
|
|
@@ -991,11 +970,11 @@ async function chat(session, faux, input) {
|
|
|
991
970
|
return;
|
|
992
971
|
}
|
|
993
972
|
if (trimmed === "/last") {
|
|
994
|
-
// B
|
|
995
|
-
//
|
|
996
|
-
//
|
|
973
|
+
// B 区/v2d: print the FULL input/output of the most recent tool
|
|
974
|
+
// call — the body holds it (the ToolCell's final state). Runs on
|
|
975
|
+
// the chain: after any in-flight turn completes.
|
|
997
976
|
chain = chain.then(async () => {
|
|
998
|
-
const tool =
|
|
977
|
+
const tool = body.lastTool();
|
|
999
978
|
if (tool === null) {
|
|
1000
979
|
bodyLog("[no tool call yet]");
|
|
1001
980
|
}
|
|
@@ -1049,7 +1028,7 @@ async function chat(session, faux, input) {
|
|
|
1049
1028
|
const recoveryRun = session.resume();
|
|
1050
1029
|
currentRun = recoveryRun;
|
|
1051
1030
|
turnNo += 1;
|
|
1052
|
-
const last = await consumeRun(session, recoveryRun, input, turnNo,
|
|
1031
|
+
const last = await consumeRun(session, recoveryRun, input, turnNo, faux, liveInput, statusCb);
|
|
1053
1032
|
currentRun = null;
|
|
1054
1033
|
failOnFauxExhaustion(last, faux, input);
|
|
1055
1034
|
}
|
|
@@ -1084,8 +1063,6 @@ async function resume(session, prompt, faux, input) {
|
|
|
1084
1063
|
let currentRun = null;
|
|
1085
1064
|
let cancelled = false;
|
|
1086
1065
|
let turnNo = 0;
|
|
1087
|
-
const lastToolRef = { current: null };
|
|
1088
|
-
const lastThinking = { current: null };
|
|
1089
1066
|
// v2b: the live status bar (docked only).
|
|
1090
1067
|
const statusCb = (u, ctx) => {
|
|
1091
1068
|
if (!dock.active)
|
|
@@ -1097,7 +1074,7 @@ async function resume(session, prompt, faux, input) {
|
|
|
1097
1074
|
currentRun = run;
|
|
1098
1075
|
try {
|
|
1099
1076
|
turnNo += 1;
|
|
1100
|
-
const last = await consumeRun(session, run, input, turnNo,
|
|
1077
|
+
const last = await consumeRun(session, run, input, turnNo, faux, null, statusCb);
|
|
1101
1078
|
failOnFauxExhaustion(last, faux, input);
|
|
1102
1079
|
}
|
|
1103
1080
|
finally {
|
|
@@ -1160,6 +1137,14 @@ async function main() {
|
|
|
1160
1137
|
// readline elsewhere. The trust question, chat, and resume all read
|
|
1161
1138
|
// through it; main's finally closes it on every exit path.
|
|
1162
1139
|
const input = makeLineInput();
|
|
1140
|
+
// v2d: the body renderer — active only where the dock is (a color
|
|
1141
|
+
// TTY with a real size); pipes run it in passthrough, byte-for-byte.
|
|
1142
|
+
body = new Body({
|
|
1143
|
+
active: () => process.stdin.isTTY && palette().blue !== "" && (process.stdout.rows ?? 0) >= 4,
|
|
1144
|
+
height: () => process.stdout.rows ?? 24,
|
|
1145
|
+
width: () => process.stdout.columns ?? 80,
|
|
1146
|
+
editCol: () => dock.editCol(),
|
|
1147
|
+
});
|
|
1163
1148
|
try {
|
|
1164
1149
|
switch (command) {
|
|
1165
1150
|
case "chat": {
|
|
@@ -1224,6 +1209,7 @@ async function main() {
|
|
|
1224
1209
|
}
|
|
1225
1210
|
}
|
|
1226
1211
|
finally {
|
|
1212
|
+
body.close(); // flush the pending frame, stop the heartbeat
|
|
1227
1213
|
input.close();
|
|
1228
1214
|
// E 组: every normal and abnormal exit releases the fds and writer
|
|
1229
1215
|
// locks — no lock file is left behind.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-code",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "kiso CLI — the coding-agent reference product: kiso chat / kiso resume / kiso sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,12 +18,12 @@
|
|
|
18
18
|
"test": "vitest run"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@vincemakes/kiso-core": "0.1.
|
|
22
|
-
"@vincemakes/kiso-evals": "0.1.
|
|
23
|
-
"@vincemakes/kiso-provider-anthropic": "0.1.
|
|
24
|
-
"@vincemakes/kiso-provider-openai": "0.1.
|
|
25
|
-
"@vincemakes/kiso-runtime": "0.1.
|
|
26
|
-
"@vincemakes/kiso-tools-node": "0.1.
|
|
21
|
+
"@vincemakes/kiso-core": "0.1.15",
|
|
22
|
+
"@vincemakes/kiso-evals": "0.1.15",
|
|
23
|
+
"@vincemakes/kiso-provider-anthropic": "0.1.15",
|
|
24
|
+
"@vincemakes/kiso-provider-openai": "0.1.15",
|
|
25
|
+
"@vincemakes/kiso-runtime": "0.1.15",
|
|
26
|
+
"@vincemakes/kiso-tools-node": "0.1.15"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^26.1.2",
|