@vincemakes/kiso-code 0.1.13 → 0.1.14

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/dock.d.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  /**
2
- * v2b — the bottom-anchored UI (TTY + color only). Borrows pi-tui's IDEA —
3
- * a DECSTBM scroll region with a reserved bottom — without its
2
+ * v2b/v2c — the bottom-anchored UI (TTY + color only). Borrows pi-tui's
3
+ * IDEA — a DECSTBM scroll region with a reserved bottom — without its
4
4
  * implementation: zero dependencies, line-level ANSI, no differential
5
5
  * renderer.
6
6
  *
7
7
  * Layout (H = terminal height): rows 1..H-3 = the scroll region (the body
8
8
  * streams and scrolls here, never touching the bottom), row H-2 = the dim
9
- * separator, row H-1 = the live status bar (or a takeover question), row
10
- * H = the input line (blue you> + readline). Bottom redraws are wrapped in
11
- * CSI 2026 (synchronized output) to avoid flicker the pi trick.
9
+ * dotted separator (╌), row H-1 = the live status bar (or a takeover
10
+ * question), row H = the input line (the blue brick ▌you> + the v2c
11
+ * editor's row readline is gone from the TTY path). Bottom redraws are
12
+ * wrapped in CSI 2026 (synchronized output) to avoid flicker — the pi
13
+ * trick. The visual identity is the kiso brick motif — ▌ half-block,
14
+ * dotted separator — deliberately NOT the CC rounded frame nor the pi
15
+ * editor (ADR-0039 Amendment 2).
12
16
  *
13
17
  * Pipes / NO_COLOR: the dock never activates; the v2a line mode stays
14
18
  * byte-for-byte (the existing e2e assertions guard it).
@@ -36,9 +40,16 @@ export declare class Dock {
36
40
  onResize(): void;
37
41
  /** Body output: position the cursor inside the scroll region at the
38
42
  * body's tracked position, write, and hand the cursor back to the
39
- * input line. The row/col tracking is approximate for width-wrapped
40
- * and wide-char lines (documented) — the region clamp keeps the
41
- * bottom rows safe regardless. */
43
+ * input line's EDIT position. The row/col tracking is approximate for
44
+ * width-wrapped and wide-char lines (documented) — the region clamp
45
+ * keeps the bottom rows safe regardless.
46
+ *
47
+ * The edit-position return is a correctness requirement, not
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). */
42
53
  writeBody(text: string): void;
43
54
  /** The status bar's base text (usage, ctx, session, …). */
44
55
  setStatus(text: string): void;
@@ -50,6 +61,9 @@ export declare class Dock {
50
61
  clearQuestion(): void;
51
62
  /** The bottom three rows, wrapped in CSI 2026 (synchronized output —
52
63
  * the pi trick against flicker). The cursor ends at the input line's
53
- * edit position. */
64
+ * edit position. v2c: the separator is the dim dotted ╌ (a weaker
65
+ * presence than the solid ─), the status line is dim (blue accents
66
+ * inside come from the CLI's composition), the input row is the blue
67
+ * brick ▌you> + the editor's visible slice. */
54
68
  redraw(): void;
55
69
  }
package/dist/dock.js CHANGED
@@ -1,18 +1,23 @@
1
1
  /**
2
- * v2b — the bottom-anchored UI (TTY + color only). Borrows pi-tui's IDEA —
3
- * a DECSTBM scroll region with a reserved bottom — without its
2
+ * v2b/v2c — the bottom-anchored UI (TTY + color only). Borrows pi-tui's
3
+ * IDEA — a DECSTBM scroll region with a reserved bottom — without its
4
4
  * implementation: zero dependencies, line-level ANSI, no differential
5
5
  * renderer.
6
6
  *
7
7
  * Layout (H = terminal height): rows 1..H-3 = the scroll region (the body
8
8
  * streams and scrolls here, never touching the bottom), row H-2 = the dim
9
- * separator, row H-1 = the live status bar (or a takeover question), row
10
- * H = the input line (blue you> + readline). Bottom redraws are wrapped in
11
- * CSI 2026 (synchronized output) to avoid flicker the pi trick.
9
+ * dotted separator (╌), row H-1 = the live status bar (or a takeover
10
+ * question), row H = the input line (the blue brick ▌you> + the v2c
11
+ * editor's row readline is gone from the TTY path). Bottom redraws are
12
+ * wrapped in CSI 2026 (synchronized output) to avoid flicker — the pi
13
+ * trick. The visual identity is the kiso brick motif — ▌ half-block,
14
+ * dotted separator — deliberately NOT the CC rounded frame nor the pi
15
+ * editor (ADR-0039 Amendment 2).
12
16
  *
13
17
  * Pipes / NO_COLOR: the dock never activates; the v2a line mode stays
14
18
  * byte-for-byte (the existing e2e assertions guard it).
15
19
  */
20
+ import { displayWidth } from "./editor.js";
16
21
  import { palette } from "./render.js";
17
22
  export class Dock {
18
23
  #active = false;
@@ -83,9 +88,16 @@ export class Dock {
83
88
  }
84
89
  /** Body output: position the cursor inside the scroll region at the
85
90
  * body's tracked position, write, and hand the cursor back to the
86
- * input line. The row/col tracking is approximate for width-wrapped
87
- * and wide-char lines (documented) — the region clamp keeps the
88
- * bottom rows safe regardless. */
91
+ * input line's EDIT position. The row/col tracking is approximate for
92
+ * width-wrapped and wide-char lines (documented) — the region clamp
93
+ * keeps the bottom rows safe regardless.
94
+ *
95
+ * The edit-position return is a correctness requirement, not
96
+ * cosmetics: readline tracks its cursor internally and NEVER
97
+ * re-syncs after an external move — a body write that left the
98
+ * cursor at column 1 made the next keystroke overwrite the prompt
99
+ * (probe-confirmed; the dock's redraw self-repaired ~200ms later,
100
+ * which read the user as cursor drift). */
89
101
  writeBody(text) {
90
102
  if (!this.#active) {
91
103
  process.stdout.write(text);
@@ -104,7 +116,17 @@ export class Dock {
104
116
  this.#bodyCol += 1;
105
117
  }
106
118
  }
107
- process.stdout.write(`\x1b[${this.#height};1H`); // back to the input line
119
+ process.stdout.write(`\x1b[${this.#height};${this.#inputCol()}H`); // back to the edit position
120
+ }
121
+ /** The input line's edit column — prompt width + cursor + 1. The
122
+ * dock's redraw and the body's cursor return both end here, so the
123
+ * ACTUAL cursor always equals what the editor tracks. The width is
124
+ * DISPLAY width (the editor's cursor column is already width-based —
125
+ * the CJK drift root cause, editor.ts). */
126
+ #inputCol() {
127
+ const inp = this.#inputState();
128
+ const promptWidth = displayWidth(this.#inputPrompt.replace(/\x1b\[[0-9;]*m/g, ""));
129
+ return promptWidth + inp.cursor + 1;
108
130
  }
109
131
  /** The status bar's base text (usage, ctx, session, …). */
110
132
  setStatus(text) {
@@ -128,25 +150,30 @@ export class Dock {
128
150
  }
129
151
  /** The bottom three rows, wrapped in CSI 2026 (synchronized output —
130
152
  * the pi trick against flicker). The cursor ends at the input line's
131
- * edit position. */
153
+ * edit position. v2c: the separator is the dim dotted ╌ (a weaker
154
+ * presence than the solid ─), the status line is dim (blue accents
155
+ * inside come from the CLI's composition), the input row is the blue
156
+ * brick ▌you> + the editor's visible slice. */
132
157
  redraw() {
133
158
  if (!this.#active)
134
159
  return;
135
160
  const p = palette();
136
161
  const H = this.#height;
137
162
  const W = this.#width;
138
- const sep = `${p.dim}${"".repeat(W)}${p.reset}`;
163
+ const sep = `${p.dim}${"".repeat(W)}${p.reset}`;
139
164
  const status = `${this.#status}${this.#tail === "" ? "" : ` · ${this.#tail}`}`;
140
- const statusLine = this.#question ?? status;
165
+ const statusLine = this.#question ?? `${p.dim}${status}${p.reset}`;
141
166
  const inp = this.#inputState();
142
- const promptWidth = this.#inputPrompt.replace(/\x1b\[[0-9;]*m/g, "").length;
143
167
  const out = [];
144
- out.push("\x1b[2026h"); // synchronized output ON
168
+ // P3 (审查): the DEC private-mode SET/RESET needs the "?" prefix —
169
+ // \x1b[?2026h/l, the pi source's exact form. Without it terminals
170
+ // silently ignore the mode and the anti-flicker never engages.
171
+ out.push("\x1b[?2026h"); // synchronized output ON (DEC 2026)
145
172
  out.push(`\x1b[${H - 2};1H\x1b[0K${sep}`);
146
173
  out.push(`\x1b[${H - 1};1H\x1b[0K${statusLine}`);
147
174
  out.push(`\x1b[${H};1H\x1b[0K${this.#inputPrompt}${inp.line}`);
148
- out.push(`\x1b[${H};${promptWidth + inp.cursor + 1}H`); // back to the edit position
149
- out.push("\x1b[2026l"); // synchronized output OFF
175
+ out.push(`\x1b[${H};${this.#inputCol()}H`); // back to the edit position
176
+ out.push("\x1b[?2026l"); // synchronized output OFF
150
177
  process.stdout.write(out.join(""));
151
178
  }
152
179
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * v2c — the raw-mode single-line editor that REPLACES readline on the TTY
3
+ * path. Root cause of the v2b drift (plan 2026-08-05-tui-v2b §11): readline
4
+ * re-renders its line by CHARACTER count and assumes it owns the row
5
+ * exclusively — a CJK wide character (2 cells) shifts every following
6
+ * column, and the dock's redraws make the mismatch permanent. Patching
7
+ * readline is a dead end; the TTY path draws its own input row instead.
8
+ * Non-TTY paths keep readline untouched (pipe bytes unchanged).
9
+ *
10
+ * Zero dependencies. The eastAsianWidth table is a ~40-line subset (CJK
11
+ * ideographs/kana/hangul/fullwidth/common wide symbols = 2, everything
12
+ * else = 1). Known limitation, documented in the README: emoji ZWJ
13
+ * clusters (family emoji etc.) are not guaranteed perfect — each code
14
+ * point counts as its width.
15
+ *
16
+ * The editor is a SINGLE line: bracketed paste (?2004h) unwraps and
17
+ * inserts, internal newlines become spaces.
18
+ */
19
+ /** A code point's display width: 2 for the wide ranges, 1 otherwise. */
20
+ export declare function charWidth(cp: number): number;
21
+ /** Display width of a code-point array (cursor math, scrolling). */
22
+ export declare function widthOf(chars: readonly number[]): number;
23
+ /** Display width of a string. */
24
+ export declare function displayWidth(text: string): number;
25
+ export declare const PROMPT = "\u258Cyou> ";
26
+ export declare const PROMPT_WIDTH: number;
27
+ /**
28
+ * The editor. Raw mode + bracketed paste (?2004h) on enter, restored on
29
+ * exit. The input row is rendered by `onRender` (the CLI wires it to the
30
+ * dock's redraw when docked, the editor's own self-render otherwise) —
31
+ * the editor itself never writes while docked. The event handlers are
32
+ * settable — the chat/resume contexts wire them after construction.
33
+ */
34
+ export declare class Editor {
35
+ #private;
36
+ readonly closed: Promise<void>;
37
+ constructor(onRender: () => void);
38
+ onLine(cb: (line: string) => void): void;
39
+ onSigint(cb: () => void): void;
40
+ onEot(cb: () => void): void;
41
+ onEscape(cb: () => void): void;
42
+ /** The whole buffer as text (the CLI's line()/clearLine()). */
43
+ line(): string;
44
+ clearLine(): void;
45
+ /** The visible slice (dim "…" prefix when scrolled) + the cursor's
46
+ * display column within it — the dock's input-row state. */
47
+ dockState(): {
48
+ line: string;
49
+ cursor: number;
50
+ };
51
+ /** One-shot question mode: the NEXT submit answers, not a turn. */
52
+ question(_query: string, cb: (answer: string) => void): void;
53
+ /** Cancel a pending question — the buffer stays (its text becomes the
54
+ * next turn on Enter, the readline re-emit equivalent). */
55
+ cancelQuestion(): void;
56
+ enter(): void;
57
+ exit(): void;
58
+ /** The row's own render when the dock is inactive (a TTY without a
59
+ * real size): \r + clear + blue brick prompt + visible + cursor
60
+ * column. */
61
+ selfRender(): void;
62
+ /** Feed raw stdin bytes — the parser. Public for unit tests. */
63
+ feed(raw: Uint8Array): void;
64
+ }
package/dist/editor.js ADDED
@@ -0,0 +1,378 @@
1
+ /**
2
+ * v2c — the raw-mode single-line editor that REPLACES readline on the TTY
3
+ * path. Root cause of the v2b drift (plan 2026-08-05-tui-v2b §11): readline
4
+ * re-renders its line by CHARACTER count and assumes it owns the row
5
+ * exclusively — a CJK wide character (2 cells) shifts every following
6
+ * column, and the dock's redraws make the mismatch permanent. Patching
7
+ * readline is a dead end; the TTY path draws its own input row instead.
8
+ * Non-TTY paths keep readline untouched (pipe bytes unchanged).
9
+ *
10
+ * Zero dependencies. The eastAsianWidth table is a ~40-line subset (CJK
11
+ * ideographs/kana/hangul/fullwidth/common wide symbols = 2, everything
12
+ * else = 1). Known limitation, documented in the README: emoji ZWJ
13
+ * clusters (family emoji etc.) are not guaranteed perfect — each code
14
+ * point counts as its width.
15
+ *
16
+ * The editor is a SINGLE line: bracketed paste (?2004h) unwraps and
17
+ * inserts, internal newlines become spaces.
18
+ */
19
+ /** A code point's display width: 2 for the wide ranges, 1 otherwise. */
20
+ export function charWidth(cp) {
21
+ if (cp >= 0x1100 && cp <= 0x115f)
22
+ return 2; // hangul jamo
23
+ if (cp >= 0x2e80 && cp <= 0x303e)
24
+ return 2; // radicals .. CJK punctuation
25
+ if (cp >= 0x3041 && cp <= 0x33ff)
26
+ return 2; // kana, CJK compat
27
+ if (cp >= 0x3400 && cp <= 0x4dbf)
28
+ return 2; // CJK ext A
29
+ if (cp >= 0x4e00 && cp <= 0x9fff)
30
+ return 2; // CJK unified
31
+ if (cp >= 0xa000 && cp <= 0xa4cf)
32
+ return 2; // yi
33
+ if (cp >= 0xa960 && cp <= 0xa97f)
34
+ return 2; // hangul jamo ext
35
+ if (cp >= 0xac00 && cp <= 0xd7a3)
36
+ return 2; // hangul syllables
37
+ if (cp >= 0xf900 && cp <= 0xfaff)
38
+ return 2; // CJK compat ideographs
39
+ if (cp >= 0xfe10 && cp <= 0xfe19)
40
+ return 2; // vertical forms
41
+ if (cp >= 0xfe30 && cp <= 0xfe6f)
42
+ return 2; // CJK compat forms
43
+ if (cp >= 0xff00 && cp <= 0xff60)
44
+ return 2; // fullwidth forms
45
+ if (cp >= 0xffe0 && cp <= 0xffe6)
46
+ return 2; // fullwidth signs
47
+ if (cp >= 0x1f300 && cp <= 0x1f64f)
48
+ return 2; // emoji (misc + emoticons)
49
+ if (cp >= 0x1f900 && cp <= 0x1f9ff)
50
+ return 2; // supplemental emoji
51
+ if (cp >= 0x20000 && cp <= 0x3fffd)
52
+ return 2; // CJK ext B..G
53
+ return 1;
54
+ }
55
+ /** Display width of a code-point array (cursor math, scrolling). */
56
+ export function widthOf(chars) {
57
+ let w = 0;
58
+ for (const cp of chars)
59
+ w += charWidth(cp);
60
+ return w;
61
+ }
62
+ /** Display width of a string. */
63
+ export function displayWidth(text) {
64
+ let w = 0;
65
+ for (const ch of text)
66
+ w += charWidth(ch.codePointAt(0));
67
+ return w;
68
+ }
69
+ import { palette } from "./render.js";
70
+ export const PROMPT = "▌you> "; // the kiso brick motif: one blue half-block, then you>
71
+ export const PROMPT_WIDTH = displayWidth(PROMPT);
72
+ /**
73
+ * The editor. Raw mode + bracketed paste (?2004h) on enter, restored on
74
+ * exit. The input row is rendered by `onRender` (the CLI wires it to the
75
+ * dock's redraw when docked, the editor's own self-render otherwise) —
76
+ * the editor itself never writes while docked. The event handlers are
77
+ * settable — the chat/resume contexts wire them after construction.
78
+ */
79
+ export class Editor {
80
+ #chars = [];
81
+ #cursor = 0;
82
+ #scroll = 0; // chars scrolled off the left (width-based reflow)
83
+ #questionCb = null;
84
+ #pasting = false;
85
+ #lineCb = null;
86
+ #pendingLines = []; // submits before onLine is wired (startup) — never dropped
87
+ #sigintCb = null;
88
+ #eotCb = null;
89
+ #escapeCb = null;
90
+ #onRender;
91
+ #pending = ""; // an incomplete ESC/CSI prefix across chunks
92
+ #decoder = new TextDecoder();
93
+ #entered = false;
94
+ #onData;
95
+ #closedResolve;
96
+ closed;
97
+ constructor(onRender) {
98
+ this.#onRender = onRender;
99
+ this.#onData = (raw) => this.feed(raw);
100
+ this.closed = new Promise((resolve) => {
101
+ this.#closedResolve = resolve;
102
+ });
103
+ }
104
+ onLine(cb) {
105
+ this.#lineCb = cb;
106
+ // Flush submits that arrived before the handler was wired (typed
107
+ // during startup) — readline buffered these; the editor must too.
108
+ for (const line of this.#pendingLines)
109
+ cb(line);
110
+ this.#pendingLines.length = 0;
111
+ }
112
+ onSigint(cb) {
113
+ this.#sigintCb = cb;
114
+ }
115
+ onEot(cb) {
116
+ this.#eotCb = cb;
117
+ }
118
+ onEscape(cb) {
119
+ this.#escapeCb = cb;
120
+ }
121
+ /** The whole buffer as text (the CLI's line()/clearLine()). */
122
+ line() {
123
+ return String.fromCodePoint(...this.#chars);
124
+ }
125
+ clearLine() {
126
+ this.#chars = [];
127
+ this.#cursor = 0;
128
+ this.#scroll = 0;
129
+ this.#onRender();
130
+ }
131
+ /** The visible slice (dim "…" prefix when scrolled) + the cursor's
132
+ * display column within it — the dock's input-row state. */
133
+ dockState() {
134
+ const visible = String.fromCodePoint(...this.#chars.slice(this.#scroll));
135
+ const prefix = this.#scroll > 0 ? "\x1b[2m…\x1b[0m" : "";
136
+ const col = (this.#scroll > 0 ? 1 : 0) + widthOf(this.#chars.slice(this.#scroll, this.#cursor));
137
+ return { line: `${prefix}${visible}`, cursor: col };
138
+ }
139
+ /** One-shot question mode: the NEXT submit answers, not a turn. */
140
+ question(_query, cb) {
141
+ this.#questionCb = cb;
142
+ }
143
+ /** Cancel a pending question — the buffer stays (its text becomes the
144
+ * next turn on Enter, the readline re-emit equivalent). */
145
+ cancelQuestion() {
146
+ this.#questionCb = null;
147
+ }
148
+ enter() {
149
+ if (this.#entered)
150
+ return;
151
+ this.#entered = true;
152
+ process.stdin.setRawMode(true);
153
+ process.stdout.write("\x1b[?2004h"); // bracketed paste ON
154
+ process.stdin.on("data", this.#onData);
155
+ this.#onRender();
156
+ }
157
+ exit() {
158
+ if (!this.#entered)
159
+ return;
160
+ this.#entered = false;
161
+ process.stdin.off("data", this.#onData);
162
+ process.stdout.write("\x1b[?2004l"); // bracketed paste OFF
163
+ process.stdin.setRawMode(false);
164
+ this.#closedResolve();
165
+ }
166
+ /** The row's own render when the dock is inactive (a TTY without a
167
+ * real size): \r + clear + blue brick prompt + visible + cursor
168
+ * column. */
169
+ selfRender() {
170
+ const p = palette();
171
+ const st = this.dockState();
172
+ const W = (process.stdout.columns ?? 0) || 80; // a degenerate 0 size (no TIOCSWINSZ) falls back
173
+ const cursorCol = Math.min(1 + PROMPT_WIDTH + st.cursor, W);
174
+ process.stdout.write(`\r\x1b[0K${p.blue}${PROMPT}${p.reset}${st.line}\x1b[${cursorCol}G`);
175
+ }
176
+ // ---- input ----
177
+ /** Feed raw stdin bytes — the parser. Public for unit tests. */
178
+ feed(raw) {
179
+ const text = this.#pending + this.#decoder.decode(raw, { stream: true });
180
+ this.#pending = "";
181
+ let i = 0;
182
+ while (i < text.length) {
183
+ const c = text[i];
184
+ if (c === "\x1b") {
185
+ const rest = text.slice(i + 1);
186
+ if (rest.startsWith("[")) {
187
+ const m = rest.match(/^\[([0-9;?]*)([A-Za-z~])/);
188
+ if (m === null) {
189
+ this.#pending = text.slice(i); // incomplete CSI — wait for more
190
+ break;
191
+ }
192
+ this.#csi(m[1], m[2]);
193
+ i += m[0].length + 1;
194
+ }
195
+ else if (rest.startsWith("O")) {
196
+ i += 3; // SS3 (function keys) — ignored
197
+ }
198
+ else {
199
+ this.#escapeCb?.();
200
+ i += 1;
201
+ }
202
+ }
203
+ else if (c === "\x0d" || c === "\x0a") {
204
+ if (this.#pasting) {
205
+ this.#insert(0x20); // single-line editor: newlines become spaces
206
+ }
207
+ else {
208
+ this.#submit();
209
+ }
210
+ i += 1;
211
+ }
212
+ else if (c === "\x7f" || c === "\x08") {
213
+ this.#backspace();
214
+ i += 1;
215
+ }
216
+ else if (c === "\x03") {
217
+ this.#sigintCb?.();
218
+ i += 1;
219
+ }
220
+ else if (c === "\x04") {
221
+ this.#eotCb?.();
222
+ i += 1;
223
+ }
224
+ else if (c === "\x15") {
225
+ this.#killToStart();
226
+ i += 1;
227
+ }
228
+ else if (c === "\x0b") {
229
+ this.#killToEnd();
230
+ i += 1;
231
+ }
232
+ else if (c === "\x17") {
233
+ this.#killWord();
234
+ i += 1;
235
+ }
236
+ else if (c === "\x01") {
237
+ this.#cursor = 0;
238
+ this.#reflow();
239
+ this.#onRender();
240
+ i += 1;
241
+ }
242
+ else if (c === "\x05") {
243
+ this.#cursor = this.#chars.length;
244
+ this.#reflow();
245
+ this.#onRender();
246
+ i += 1;
247
+ }
248
+ else if (c !== undefined && c < " ") {
249
+ i += 1; // other control — ignored
250
+ }
251
+ else {
252
+ this.#insert(text.codePointAt(i));
253
+ i += c.length;
254
+ }
255
+ }
256
+ }
257
+ #csi(params, final) {
258
+ if (final === "~") {
259
+ const n = Number(params);
260
+ if (n === 3)
261
+ this.#delete();
262
+ else if (n === 200)
263
+ this.#pasting = true;
264
+ else if (n === 201) {
265
+ this.#pasting = false;
266
+ this.#onRender();
267
+ }
268
+ }
269
+ else if (final === "D") {
270
+ this.#move(-1);
271
+ }
272
+ else if (final === "C") {
273
+ this.#move(1);
274
+ }
275
+ else if (final === "H") {
276
+ this.#cursor = 0;
277
+ this.#reflow();
278
+ }
279
+ else if (final === "F") {
280
+ this.#cursor = this.#chars.length;
281
+ this.#reflow();
282
+ }
283
+ }
284
+ // ---- editing ----
285
+ #insert(cp) {
286
+ this.#chars.splice(this.#cursor, 0, cp);
287
+ this.#cursor += 1;
288
+ this.#reflow();
289
+ if (!this.#pasting)
290
+ this.#onRender();
291
+ }
292
+ #backspace() {
293
+ if (this.#cursor === 0)
294
+ return;
295
+ this.#chars.splice(this.#cursor - 1, 1);
296
+ this.#cursor -= 1;
297
+ this.#reflow();
298
+ if (!this.#pasting)
299
+ this.#onRender();
300
+ }
301
+ #delete() {
302
+ if (this.#cursor >= this.#chars.length)
303
+ return;
304
+ this.#chars.splice(this.#cursor, 1);
305
+ this.#reflow();
306
+ if (!this.#pasting)
307
+ this.#onRender();
308
+ }
309
+ #move(delta) {
310
+ this.#cursor = Math.max(0, Math.min(this.#chars.length, this.#cursor + delta));
311
+ this.#reflow();
312
+ if (!this.#pasting)
313
+ this.#onRender();
314
+ }
315
+ #killToStart() {
316
+ this.#chars.splice(0, this.#cursor);
317
+ this.#cursor = 0;
318
+ this.#reflow();
319
+ if (!this.#pasting)
320
+ this.#onRender();
321
+ }
322
+ #killToEnd() {
323
+ this.#chars.length = this.#cursor;
324
+ this.#reflow();
325
+ if (!this.#pasting)
326
+ this.#onRender();
327
+ }
328
+ #killWord() {
329
+ let i = this.#cursor;
330
+ while (i > 0 && this.#chars[i - 1] === 0x20)
331
+ i -= 1; // trailing spaces
332
+ while (i > 0 && this.#chars[i - 1] !== 0x20)
333
+ i -= 1; // the word
334
+ this.#chars.splice(i, this.#cursor - i);
335
+ this.#cursor = i;
336
+ this.#reflow();
337
+ }
338
+ #submit() {
339
+ const line = String.fromCodePoint(...this.#chars);
340
+ this.#chars = [];
341
+ this.#cursor = 0;
342
+ this.#scroll = 0;
343
+ const cb = this.#questionCb;
344
+ this.#questionCb = null;
345
+ if (cb !== null) {
346
+ cb(line);
347
+ }
348
+ else if (this.#lineCb !== null) {
349
+ this.#lineCb(line);
350
+ }
351
+ else {
352
+ this.#pendingLines.push(line); // nobody wired yet — hold it
353
+ }
354
+ this.#onRender();
355
+ }
356
+ // ---- width-based horizontal scroll ----
357
+ #reflow() {
358
+ const W = (process.stdout.columns ?? 0) || 80; // degenerate 0 falls back to 80
359
+ const maxW = Math.max(1, W - PROMPT_WIDTH - 1); // 1 col for the "…"
360
+ const curCol = widthOf(this.#chars.slice(0, this.#cursor));
361
+ const scrolledW = widthOf(this.#chars.slice(0, this.#scroll));
362
+ if (curCol < scrolledW) {
363
+ this.#scroll = this.#indexAtWidth(curCol);
364
+ }
365
+ else if (curCol >= scrolledW + maxW) {
366
+ this.#scroll = this.#indexAtWidth(Math.max(0, curCol - maxW + 1));
367
+ }
368
+ }
369
+ #indexAtWidth(target) {
370
+ let w = 0;
371
+ for (let i = 0; i < this.#chars.length; i += 1) {
372
+ if (w >= target)
373
+ return i;
374
+ w += charWidth(this.#chars[i]);
375
+ }
376
+ return this.#chars.length;
377
+ }
378
+ }
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 { Editor, PROMPT as EDITOR_PROMPT } from "./editor.js";
20
21
  import { homedir, tmpdir } from "node:os";
21
22
  import { dirname, join } from "node:path";
22
23
  import { fileURLToPath } from "node:url";
@@ -87,11 +88,124 @@ function startupBanner() {
87
88
  return `${p.dim}${LOGO_TOP}${p.blue}${TAGLINE}${p.reset}${p.dim}${LOGO_BOTTOM} v${VERSION}${names}${p.reset}\n`;
88
89
  }
89
90
  /** v2a: the interactive prompt — blue, the identity accent. readline owns
90
- * the echo of what the user types; we own the prompt's color. */
91
+ * the echo of what the user types; we own the prompt's color. (v2c: the
92
+ * readline prompt keeps "you> " — the brick ▌ is the dock's row only;
93
+ * pipe bytes must not change.) */
91
94
  function interactivePrompt() {
92
95
  const p = palette();
93
96
  return `${p.blue}you> ${p.reset}`;
94
97
  }
98
+ /** The v2b behavior, unchanged: readline owns the line, SIGINT, and the
99
+ * prompt. Only ever constructed when stdin is NOT a TTY. The rl starts
100
+ * consuming stdin at construction (main), so 'line' events are buffered
101
+ * until chat() wires the handler — pipe input must never be dropped. */
102
+ function readlineInput(rl) {
103
+ let lineCb = null;
104
+ const pending = [];
105
+ rl.on("line", (line) => {
106
+ if (lineCb === null)
107
+ pending.push(line);
108
+ else
109
+ lineCb(line);
110
+ });
111
+ return {
112
+ onLine(cb) {
113
+ lineCb = cb;
114
+ for (const line of pending)
115
+ cb(line);
116
+ pending.length = 0;
117
+ },
118
+ onSigint(cb) {
119
+ rl.on("SIGINT", cb);
120
+ },
121
+ onEot() {
122
+ /* readline's Ctrl+D on an empty line is EOF → 'close' — the
123
+ * exit path is the close, nothing to wire here. */
124
+ },
125
+ onEscape() {
126
+ /* readline has no bare-Esc semantics — ignored. */
127
+ },
128
+ question(query, cb) {
129
+ rl.question(query, cb);
130
+ },
131
+ cancelQuestion() {
132
+ /* the rl.question stays pending; the settled branch re-emits
133
+ * the answer as a new line. */
134
+ },
135
+ emitLine(line) {
136
+ rl.emit("line", line);
137
+ },
138
+ line() {
139
+ return rl.line;
140
+ },
141
+ clearLine() {
142
+ /* readline: Ctrl+C is exit/abort only — nothing to clear. */
143
+ },
144
+ prompt() {
145
+ rl.setPrompt(interactivePrompt());
146
+ rl.prompt();
147
+ },
148
+ close() {
149
+ rl.close();
150
+ },
151
+ closed: new Promise((resolve) => rl.on("close", () => resolve())),
152
+ };
153
+ }
154
+ /** The v2c TTY path: the editor's events map 1:1 onto the interface; the
155
+ * input row renders on every state change (the CLI's onRender wiring). */
156
+ function editorInput(editor) {
157
+ return {
158
+ onLine(cb) {
159
+ editor.onLine(cb);
160
+ },
161
+ onSigint(cb) {
162
+ editor.onSigint(cb);
163
+ },
164
+ onEot(cb) {
165
+ editor.onEot(cb);
166
+ },
167
+ onEscape(cb) {
168
+ editor.onEscape(cb);
169
+ },
170
+ question(query, cb) {
171
+ editor.question(query, cb);
172
+ },
173
+ cancelQuestion() {
174
+ editor.cancelQuestion();
175
+ },
176
+ emitLine() {
177
+ /* the editor's buffer survives a cancelled question — its text
178
+ * becomes the next turn on Enter (the readline re-emit
179
+ * equivalent). */
180
+ },
181
+ line() {
182
+ return editor.line();
183
+ },
184
+ clearLine() {
185
+ editor.clearLine();
186
+ },
187
+ prompt() {
188
+ /* the editor renders on every state change — nothing to arm. */
189
+ },
190
+ close() {
191
+ editor.exit();
192
+ },
193
+ closed: editor.closed,
194
+ };
195
+ }
196
+ /** One input source per process: the raw-mode Editor on a TTY (entered
197
+ * here, bound to the dock once — the trust question, chat, and resume
198
+ * all read through it), readline elsewhere. */
199
+ function makeLineInput() {
200
+ if (process.stdin.isTTY) {
201
+ const editor = new Editor(() => (dock.active ? dock.redraw() : editor.selfRender()));
202
+ editor.enter();
203
+ const p = palette();
204
+ dock.bindInput(() => editor.dockState(), `${p.blue}${EDITOR_PROMPT}${p.reset}`);
205
+ return editorInput(editor);
206
+ }
207
+ return readlineInput(createInterface({ input: process.stdin, output: process.stdout }));
208
+ }
95
209
  /** v2b: the bottom-anchored UI — docked only on a color TTY; pipes and
96
210
  * NO_COLOR stay the v2a line mode byte-for-byte. */
97
211
  const dock = new Dock();
@@ -171,7 +285,7 @@ function extensionsBanner() {
171
285
  * record → only a HUMAN may decide, TTY only — non-TTY refuses with one
172
286
  * stderr line. Returns the artifacts on grant, null on anything else.
173
287
  */
174
- async function resolveProjectTrust() {
288
+ async function resolveProjectTrust(input) {
175
289
  const artifacts = await projectArtifacts(process.cwd());
176
290
  if (artifacts === null)
177
291
  return null; // no .kiso artifacts — nothing to gate
@@ -188,25 +302,19 @@ async function resolveProjectTrust() {
188
302
  console.error(`[project .kiso] found ${artifacts.files.length} artifact(s) in ${artifacts.root} — not trusted, not loaded (run kiso interactively once to decide)`);
189
303
  return null;
190
304
  }
191
- const rl = createInterface({ input: process.stdin, output: process.stdout });
192
- // v2b: docked the trust question takes over the status position.
193
- dock.bindInput(() => ({ line: rl.line, cursor: rl.cursor }), interactivePrompt());
194
- try {
195
- bodyLog(`[project .kiso] ${artifacts.root}`);
196
- for (const f of artifacts.files) {
197
- bodyLog(` ${f.path} (${f.digest.slice(0, 6)})`);
198
- }
199
- const answer = await ask(rl, `trust this project's .kiso? (y/n) `);
200
- const granted = answer !== CANCELLED && answer.trim().toLowerCase().startsWith("y");
201
- recordTrust({ root: artifacts.root, digest: artifacts.digest, decision: granted ? "granted" : "refused" });
202
- if (!granted)
203
- return null;
204
- applyProjectMerges(artifacts);
205
- return artifacts;
206
- }
207
- finally {
208
- rl.close();
305
+ // v2c: the shared input (the editor on a TTY) reads the answer; the
306
+ // dock shows the question at the status position.
307
+ bodyLog(`[project .kiso] ${artifacts.root}`);
308
+ for (const f of artifacts.files) {
309
+ bodyLog(` ${f.path} (${f.digest.slice(0, 6)})`);
209
310
  }
311
+ const answer = await ask(input, `trust this project's .kiso? (y/n) `);
312
+ const granted = answer !== CANCELLED && answer.trim().toLowerCase().startsWith("y");
313
+ recordTrust({ root: artifacts.root, digest: artifacts.digest, decision: granted ? "granted" : "refused" });
314
+ if (!granted)
315
+ return null;
316
+ applyProjectMerges(artifacts);
317
+ return artifacts;
210
318
  }
211
319
  /**
212
320
  * E3 — merge the project's mcp.json and skills into the env BEFORE the
@@ -368,12 +476,12 @@ function fauxSkip(id) {
368
476
  events.filter((e) => e.type === "stop" && e.reason === "end_turn").length +
369
477
  events.filter((e) => e.type === "tool_call_end" && !results.has(e.callId)).length);
370
478
  }
371
- async function makeAgent(fauxSkipTurns = 0) {
479
+ async function makeAgent(fauxSkipTurns = 0, input) {
372
480
  const store = new SessionStore(sessionsDir());
373
481
  // E3: the project-level trust gate runs BEFORE any extension load (the
374
482
  // mcp/skills merges must be in the env when the user-level extensions
375
483
  // load). Untrusted project capability is never loaded — never silently.
376
- const project = await resolveProjectTrust();
484
+ const project = input !== undefined ? await resolveProjectTrust(input) : await resolveProjectTrust(undefined);
377
485
  // E1: the startup extension scan — a broken extension fails the process
378
486
  // LOUDLY here (loadExtensions throws), never silently.
379
487
  userExtensions = await loadExtensions(extensionsDir());
@@ -508,15 +616,20 @@ const CANCELLED = Symbol("kiso-question-cancelled");
508
616
  * question.
509
617
  */
510
618
  let pendingAsk = null;
511
- function ask(rl, question) {
619
+ function ask(input, question) {
512
620
  if (!process.stdin.isTTY) {
513
621
  console.log(`[non-interactive — no human to ask: ${question}]`);
514
622
  return Promise.resolve("");
515
623
  }
516
624
  // v2b: docked — the question takes over the status position, the
517
- // answer lands at the input line.
518
- if (dock.active)
625
+ // answer lands at the input line. v2c: a TTY without a dock (rows < 4)
626
+ // prints the question into the body — the editor cannot show it.
627
+ if (dock.active) {
519
628
  dock.showQuestion(question);
629
+ }
630
+ else {
631
+ bodyLog(question);
632
+ }
520
633
  return new Promise((resolve) => {
521
634
  let settled = false;
522
635
  pendingAsk = () => {
@@ -524,12 +637,18 @@ function ask(rl, question) {
524
637
  return;
525
638
  settled = true;
526
639
  pendingAsk = null;
640
+ input.cancelQuestion();
527
641
  resolve(CANCELLED); // the run is aborting — the question is dead
528
642
  };
529
- rl.question(dock.active ? "" : question, (answer) => {
643
+ // v2b: docked — the question reads at the input line, whose prompt
644
+ // is the same blue you> (the editor's brick row; the readline path
645
+ // passes the plain question). An empty prompt would start readline
646
+ // at column 1 while the dock renders "you> " — the typed answer
647
+ // would land on the prompt and drift (probe-confirmed).
648
+ input.question(dock.active ? interactivePrompt() : question, (answer) => {
530
649
  if (settled) {
531
650
  // The question was cancelled; this line is a NEW user turn.
532
- rl.emit("line", answer);
651
+ input.emitLine(answer);
533
652
  return;
534
653
  }
535
654
  settled = true;
@@ -560,9 +679,9 @@ function estimateCtxRatio(session) {
560
679
  return chars / 4 / contextWindowTokens();
561
680
  }
562
681
  /** Decide every uncertain execution with the human (r)erun/(a)bandon. */
563
- async function resolveUncertains(session, rl, isCancelled) {
682
+ async function resolveUncertains(session, input, isCancelled) {
564
683
  for (const uncertain of session.uncertainExecutions()) {
565
- const answer = await ask(rl, `⚠ interrupted execution: ${escapeTerminal(uncertain.name)} (${uncertain.executionId}) — did it apply? (r)erun / (a)bandon: `);
684
+ const answer = await ask(input, `⚠ interrupted execution: ${escapeTerminal(uncertain.name)} (${uncertain.executionId}) — did it apply? (r)erun / (a)bandon: `);
566
685
  if (isCancelled() || answer === CANCELLED) {
567
686
  // 十: a cancellation NEVER records a verdict — the execution
568
687
  // stays uncertain and durable; no rerun/abandoned is fabricated.
@@ -581,7 +700,7 @@ const DEFAULT_CONTEXT_WINDOW = 200_000;
581
700
  * line's form; `liveInput` (non-null only in interactive chat) carries the
582
701
  * last line THIS process's readline consumed — the double-echo filter.
583
702
  */
584
- async function consumeRun(session, run, rl, turnNo, lastToolRef, faux, liveInput, lastThinking, statusCb) {
703
+ async function consumeRun(session, run, input, turnNo, lastToolRef, faux, liveInput, lastThinking, statusCb) {
585
704
  let last;
586
705
  // B 区: tool_call_end → (name, input) for the summary; tool_result →
587
706
  // one summary line. Usage events feed the status line.
@@ -615,7 +734,14 @@ async function consumeRun(session, run, rl, turnNo, lastToolRef, faux, liveInput
615
734
  // Replayed history (recovery/resume — nobody typed) keeps the event
616
735
  // render. Deterministic: exact content match with the consumed line,
617
736
  // on a TTY (the only place an echo exists to hand over).
618
- if (ev.type === "user_input" && liveInput !== null && liveInput.current === (typeof ev.content === "string" ? ev.content : "") && process.stdin.isTTY) {
737
+ // 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. The
739
+ // user's typed text must not vanish after Enter.
740
+ if (ev.type === "user_input" &&
741
+ liveInput !== null &&
742
+ liveInput.current === (typeof ev.content === "string" ? ev.content : "") &&
743
+ process.stdin.isTTY &&
744
+ !dock.active) {
619
745
  continue;
620
746
  }
621
747
  const prevThinking = thinkingOpen;
@@ -664,7 +790,7 @@ async function consumeRun(session, run, rl, turnNo, lastToolRef, faux, liveInput
664
790
  const decisionId = ev.decisionId;
665
791
  const name = ev.name;
666
792
  // 八: the tool name is model text — escaped on every output path.
667
- const answer = await ask(rl, `approve ${escapeTerminal(name)}? (y/n) `);
793
+ const answer = await ask(input, `approve ${escapeTerminal(name)}? (y/n) `);
668
794
  if (answer === CANCELLED) {
669
795
  // 十: a cancellation is a CONSERVATIVE denial, explicitly
670
796
  // distinguished from the user typing "n".
@@ -707,35 +833,35 @@ class FauxExhaustionError extends Error {
707
833
  this.name = "FauxExhaustionError";
708
834
  }
709
835
  }
710
- function failOnFauxExhaustion(last, faux, rl) {
836
+ function failOnFauxExhaustion(last, faux, input) {
711
837
  if (!faux)
712
838
  return;
713
839
  if (last?.type !== "terminal" || last.outcome.kind !== "error")
714
840
  return;
715
841
  const message = last.outcome.error.message;
716
- rl?.close(); // the REPL must not stay open waiting for a line
842
+ input?.close(); // the REPL must not stay open waiting for a line
717
843
  throw new FauxExhaustionError(message.startsWith("provider stream ended without a stop event")
718
844
  ? "[faux mode] the scripted demo turns are exhausted — set ANTHROPIC_API_KEY or OPENAI_API_KEY for a real model"
719
845
  : `[faux mode] the scripted model failed: ${escapeTerminal(message.slice(0, 200))}`);
720
846
  }
721
847
  /** Interactive REPL: stream events, pause for approvals, Ctrl+C aborts. */
722
- async function chat(session, faux) {
723
- const rl = createInterface({ input: process.stdin, output: process.stdout });
848
+ async function chat(session, faux, input) {
724
849
  let currentRun = null;
725
850
  let cancelled = false;
726
- const turn = (input) => new Promise((resolve, reject) => {
851
+ const turn = (text) => new Promise((resolve, reject) => {
852
+ queued = Math.max(0, queued - 1); // a queued turn starts
727
853
  // v2a: the echo filter compares the user_input event against THIS
728
854
  // turn's own input — lines that arrive ahead of their turn (piped
729
855
  // bursts, queued replays) must not overwrite the reference.
730
- liveInput.current = input;
731
- const run = session.run(input);
856
+ liveInput.current = text;
857
+ const run = session.run(text);
732
858
  currentRun = run;
733
859
  turnNo += 1;
734
860
  const myTurn = turnNo;
735
861
  (async () => {
736
862
  let last;
737
863
  try {
738
- last = await consumeRun(session, run, rl, myTurn, lastToolRef, faux, liveInput, lastThinking, statusCb);
864
+ last = await consumeRun(session, run, input, myTurn, lastToolRef, faux, liveInput, lastThinking, statusCb);
739
865
  currentRun = null;
740
866
  // 八: a faux script that ran out of declared turns exits
741
867
  // loudly with a non-zero status — never a silent status 0.
@@ -743,11 +869,10 @@ async function chat(session, faux) {
743
869
  // this turn's promise — it propagates through the chain to
744
870
  // chat to main's finally/catch, never an orphaned
745
871
  // unhandled rejection from the IIFE.
746
- failOnFauxExhaustion(last, faux, rl);
872
+ failOnFauxExhaustion(last, faux, input);
747
873
  // 八: after EVERY turn the prompt is re-armed — the human
748
874
  // never types blind after the first turn.
749
- rl.setPrompt(interactivePrompt());
750
- rl.prompt();
875
+ input.prompt();
751
876
  resolve();
752
877
  }
753
878
  catch (err) {
@@ -760,13 +885,12 @@ async function chat(session, faux) {
760
885
  }
761
886
  console.error(`\n[run failed] ${err instanceof Error ? err.message : String(err)}\n`);
762
887
  currentRun = null;
763
- rl.setPrompt(interactivePrompt());
764
- rl.prompt();
888
+ input.prompt();
765
889
  resolve();
766
890
  }
767
891
  })();
768
892
  });
769
- rl.on("SIGINT", () => {
893
+ input.onSigint(() => {
770
894
  if (currentRun) {
771
895
  // 八: Ctrl+C cancels BOTH the pending question (if one is
772
896
  // awaiting a line) and the run — the run then writes its unique
@@ -775,11 +899,30 @@ async function chat(session, faux) {
775
899
  pendingAsk?.();
776
900
  currentRun.abort();
777
901
  }
778
- else if (!cancelled) {
902
+ else if (pendingAsk !== null) {
903
+ pendingAsk?.(); // a startup/trust question — cancel it
904
+ }
905
+ else if (input.line() === "") {
779
906
  cancelled = true;
780
907
  console.log("\n[exit requested]");
781
- pendingAsk?.(); // unblock a startup question
782
- rl.close();
908
+ input.close();
909
+ }
910
+ else {
911
+ input.clearLine(); // v2c: Ctrl+C on a non-empty line clears it
912
+ }
913
+ });
914
+ input.onEot(() => {
915
+ if (!currentRun && pendingAsk === null && input.line() === "") {
916
+ cancelled = true;
917
+ console.log("\n[exit requested]");
918
+ input.close();
919
+ }
920
+ });
921
+ input.onEscape(() => {
922
+ if (currentRun) {
923
+ console.log("\n[aborting run]");
924
+ pendingAsk?.();
925
+ currentRun.abort();
783
926
  }
784
927
  });
785
928
  // 第五轮(P1-11): the PERSISTENT line listener is installed BEFORE the
@@ -800,15 +943,22 @@ async function chat(session, faux) {
800
943
  const liveInput = { current: null };
801
944
  // v2b: the last complete thinking block, for /think.
802
945
  const lastThinking = { current: null };
946
+ // v2c: turns submitted while another runs are QUEUED on the chain — the
947
+ // live count rides the status bar (+N queued).
948
+ let queued = 0;
803
949
  // v2b: the live status bar (docked only).
804
950
  const statusCb = (u, ctx) => {
805
951
  if (!dock.active)
806
952
  return;
807
953
  const st = renderStatusLine(turnNo, u, ctx, faux);
808
- dock.setStatus(st === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${st}`);
954
+ const base = st === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${st}`;
955
+ dock.setStatus(queued > 0 ? `${base} · +${queued} queued` : base);
809
956
  };
810
- dock.bindInput(() => ({ line: rl.line, cursor: rl.cursor }), interactivePrompt());
811
- rl.on("line", (line) => {
957
+ // The ONE dispatcher: slash commands, exit, and turns. The recovery
958
+ // replay routes through it too — a queued "/last" must never become a
959
+ // user turn (v2c: the rl lives in main, so lines arrive earlier and
960
+ // the queue is the common path).
961
+ const dispatch = (line) => {
812
962
  const trimmed = line.trim();
813
963
  if (trimmed === "/help") {
814
964
  // Prints the available commands with one-line descriptions.
@@ -821,8 +971,7 @@ async function chat(session, faux) {
821
971
  bodyLog(cmd("/last", "show the most recent tool call's input and output"));
822
972
  bodyLog(cmd("/status", "show session id, event count, and context estimate"));
823
973
  bodyLog(cmd("exit", "leave the session"));
824
- rl.setPrompt(interactivePrompt());
825
- rl.prompt();
974
+ input.prompt();
826
975
  });
827
976
  return;
828
977
  }
@@ -837,8 +986,7 @@ async function chat(session, faux) {
837
986
  else {
838
987
  bodyLog(escapeTerminal(t));
839
988
  }
840
- rl.setPrompt(interactivePrompt());
841
- rl.prompt();
989
+ input.prompt();
842
990
  });
843
991
  return;
844
992
  }
@@ -857,8 +1005,7 @@ async function chat(session, faux) {
857
1005
  bodyLog(`--- ${tool.name} output${tool.result.isError ? " (error)" : ""} ---`);
858
1006
  bodyLog(escapeTerminal(tool.result.content));
859
1007
  }
860
- rl.setPrompt(interactivePrompt());
861
- rl.prompt();
1008
+ input.prompt();
862
1009
  });
863
1010
  return;
864
1011
  }
@@ -872,49 +1019,58 @@ async function chat(session, faux) {
872
1019
  bodyLog(`session ${session.id}`);
873
1020
  bodyLog(`${session.log.all.length} events`);
874
1021
  bodyLog(`ctx ${ctx}`);
875
- rl.setPrompt(interactivePrompt());
876
- rl.prompt();
1022
+ input.prompt();
877
1023
  });
878
1024
  return;
879
1025
  }
880
1026
  if (trimmed === "exit" || trimmed === "") {
881
- rl.close();
1027
+ input.close();
882
1028
  return;
883
1029
  }
1030
+ // v2c: a turn submitted while another runs waits on the chain — the
1031
+ // live count rides the status bar (+N queued).
1032
+ queued += 1;
1033
+ chain = chain.then(() => turn(line));
1034
+ };
1035
+ input.onLine((line) => {
884
1036
  if (!replReady) {
885
1037
  queuedLines.push(line);
886
1038
  return;
887
1039
  }
888
- chain = chain.then(() => turn(line));
1040
+ dispatch(line);
889
1041
  });
890
1042
  // Recovery first: a session with a dangling pause or uncertain
891
1043
  // executions must resolve them BEFORE the REPL accepts new turns —
892
1044
  // otherwise the interrupted run dangles while a new one starts.
893
1045
  // 八: the startup resume is bound to currentRun — Ctrl+C during it
894
1046
  // aborts the recovery, exactly like the interactive turns.
895
- await resolveUncertains(session, rl, () => cancelled);
1047
+ await resolveUncertains(session, input, () => cancelled);
896
1048
  if (!cancelled) {
897
1049
  const recoveryRun = session.resume();
898
1050
  currentRun = recoveryRun;
899
1051
  turnNo += 1;
900
- const last = await consumeRun(session, recoveryRun, rl, turnNo, lastToolRef, faux, liveInput, lastThinking, statusCb);
1052
+ const last = await consumeRun(session, recoveryRun, input, turnNo, lastToolRef, faux, liveInput, lastThinking, statusCb);
901
1053
  currentRun = null;
902
- failOnFauxExhaustion(last, faux, rl);
1054
+ failOnFauxExhaustion(last, faux, input);
903
1055
  }
904
1056
  if (cancelled) {
905
- rl.close();
906
- await new Promise((resolve) => rl.on("close", () => resolve()));
1057
+ input.close();
1058
+ await input.closed;
907
1059
  return;
908
1060
  }
909
1061
  // The REPL is ready: replay anything that arrived during recovery.
910
1062
  replReady = true;
1063
+ // v2c: dispatch SYNCHRONOUSLY — each call appends its segment to the
1064
+ // chain variable; the final `await chain` then covers every replayed
1065
+ // turn. A chain.then(() => dispatch()) indirection would capture the
1066
+ // chain BEFORE the appends and the replayed turns would never be
1067
+ // awaited (the F-group regression).
911
1068
  for (const line of queuedLines) {
912
- chain = chain.then(() => turn(line));
1069
+ dispatch(line);
913
1070
  }
914
1071
  queuedLines.length = 0;
915
- rl.setPrompt(interactivePrompt());
916
- rl.prompt();
917
- await new Promise((resolve) => rl.on("close", () => resolve()));
1072
+ input.prompt();
1073
+ await input.closed;
918
1074
  await chain; // never exit while a turn is in flight
919
1075
  }
920
1076
  /**
@@ -924,8 +1080,7 @@ async function chat(session, faux) {
924
1080
  * E 组: SIGINT aborts the run being resumed; every exit path closes the
925
1081
  * session store so no lock is left behind.
926
1082
  */
927
- async function resume(session, prompt, faux) {
928
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1083
+ async function resume(session, prompt, faux, input) {
929
1084
  let currentRun = null;
930
1085
  let cancelled = false;
931
1086
  let turnNo = 0;
@@ -938,19 +1093,18 @@ async function resume(session, prompt, faux) {
938
1093
  const st = renderStatusLine(turnNo, u, ctx, faux);
939
1094
  dock.setStatus(st === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${st}`);
940
1095
  };
941
- dock.bindInput(() => ({ line: rl.line, cursor: rl.cursor }), interactivePrompt());
942
1096
  const withRun = async (run) => {
943
1097
  currentRun = run;
944
1098
  try {
945
1099
  turnNo += 1;
946
- const last = await consumeRun(session, run, rl, turnNo, lastToolRef, faux, null, lastThinking, statusCb);
947
- failOnFauxExhaustion(last, faux, rl);
1100
+ const last = await consumeRun(session, run, input, turnNo, lastToolRef, faux, null, lastThinking, statusCb);
1101
+ failOnFauxExhaustion(last, faux, input);
948
1102
  }
949
1103
  finally {
950
1104
  currentRun = null;
951
1105
  }
952
1106
  };
953
- rl.on("SIGINT", () => {
1107
+ input.onSigint(() => {
954
1108
  if (currentRun) {
955
1109
  // 八: Ctrl+C cancels the pending question AND the run.
956
1110
  console.log("\n[aborting run]");
@@ -965,11 +1119,25 @@ async function resume(session, prompt, faux) {
965
1119
  cancelled = true;
966
1120
  console.log("\n[exit requested]");
967
1121
  pendingAsk?.();
968
- rl.close();
1122
+ input.close();
1123
+ }
1124
+ });
1125
+ input.onEot(() => {
1126
+ if (!currentRun && !cancelled && input.line() === "") {
1127
+ cancelled = true;
1128
+ console.log("\n[exit requested]");
1129
+ input.close();
1130
+ }
1131
+ });
1132
+ input.onEscape(() => {
1133
+ if (currentRun) {
1134
+ console.log("\n[aborting run]");
1135
+ pendingAsk?.();
1136
+ currentRun.abort();
969
1137
  }
970
1138
  });
971
1139
  try {
972
- await resolveUncertains(session, rl, () => cancelled);
1140
+ await resolveUncertains(session, input, () => cancelled);
973
1141
  if (!cancelled) {
974
1142
  await withRun(session.resume());
975
1143
  if (prompt !== undefined && prompt !== "") {
@@ -978,7 +1146,7 @@ async function resume(session, prompt, faux) {
978
1146
  }
979
1147
  }
980
1148
  finally {
981
- rl.close();
1149
+ input.close();
982
1150
  }
983
1151
  }
984
1152
  async function main() {
@@ -987,6 +1155,11 @@ async function main() {
987
1155
  // exit non-zero, never masquerade as a successful provider run.
988
1156
  const faux = process.env.ANTHROPIC_API_KEY === undefined && process.env.OPENAI_API_KEY === undefined;
989
1157
  let agent;
1158
+ // v2c: ONE input source per process — the raw-mode editor on a TTY
1159
+ // (entered here, dock-bound, trusted before any extension loads),
1160
+ // readline elsewhere. The trust question, chat, and resume all read
1161
+ // through it; main's finally closes it on every exit path.
1162
+ const input = makeLineInput();
990
1163
  try {
991
1164
  switch (command) {
992
1165
  case "chat": {
@@ -996,11 +1169,11 @@ async function main() {
996
1169
  dock.enter();
997
1170
  // E 区: a resumed session continues the script at its durable
998
1171
  // position — never restarts it (fauxSkip).
999
- const agent = await makeAgent(fauxSkip(id));
1172
+ const agent = await makeAgent(fauxSkip(id), input);
1000
1173
  const session = await agent.session({ id });
1001
1174
  bodyLog(`session ${id}\n`);
1002
1175
  extensionsBanner();
1003
- await chat(session, faux);
1176
+ await chat(session, faux, input);
1004
1177
  break;
1005
1178
  }
1006
1179
  case "resume": {
@@ -1012,9 +1185,9 @@ async function main() {
1012
1185
  // (argv = [node, script, resume, id, prompt?]).
1013
1186
  const prompt = process.argv[4];
1014
1187
  dock.enter();
1015
- const agent = await makeAgent(fauxSkip(arg));
1188
+ const agent = await makeAgent(fauxSkip(arg), input);
1016
1189
  const session = await agent.session({ id: arg });
1017
- await resume(session, prompt, faux);
1190
+ await resume(session, prompt, faux, input);
1018
1191
  break;
1019
1192
  }
1020
1193
  case "sessions": {
@@ -1045,12 +1218,13 @@ async function main() {
1045
1218
  const session = await agent.session({ id });
1046
1219
  bodyLog(`session ${id}\n`);
1047
1220
  extensionsBanner();
1048
- await chat(session, faux);
1221
+ await chat(session, faux, input);
1049
1222
  break;
1050
1223
  }
1051
1224
  }
1052
1225
  }
1053
1226
  finally {
1227
+ input.close();
1054
1228
  // E 组: every normal and abnormal exit releases the fds and writer
1055
1229
  // locks — no lock file is left behind.
1056
1230
  agent?.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-code",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
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.13",
22
- "@vincemakes/kiso-evals": "0.1.13",
23
- "@vincemakes/kiso-provider-anthropic": "0.1.13",
24
- "@vincemakes/kiso-provider-openai": "0.1.13",
25
- "@vincemakes/kiso-runtime": "0.1.13",
26
- "@vincemakes/kiso-tools-node": "0.1.13"
21
+ "@vincemakes/kiso-core": "0.1.14",
22
+ "@vincemakes/kiso-evals": "0.1.14",
23
+ "@vincemakes/kiso-provider-anthropic": "0.1.14",
24
+ "@vincemakes/kiso-provider-openai": "0.1.14",
25
+ "@vincemakes/kiso-runtime": "0.1.14",
26
+ "@vincemakes/kiso-tools-node": "0.1.14"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^26.1.2",