@vincemakes/kiso-code 0.1.29 → 0.1.31
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/chat.d.ts +5 -5
- package/dist/chat.js +22 -22
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/dispatch.d.ts +1 -1
- package/dist/dispatch.js +4 -4
- package/dist/faux-glue.d.ts +5 -5
- package/dist/faux-glue.js +6 -6
- package/dist/index.d.ts +4 -4
- package/dist/index.js +20 -20
- package/dist/mode.d.ts +1 -1
- package/dist/mode.js +1 -1
- package/dist/resume.d.ts +2 -2
- package/dist/resume.js +5 -5
- package/dist/state.d.ts +7 -7
- package/dist/state.js +7 -7
- package/dist/trust-ui.d.ts +2 -2
- package/dist/trust-ui.js +4 -4
- package/package.json +8 -8
- package/dist/body.d.ts +0 -121
- package/dist/body.js +0 -491
- package/dist/dock.d.ts +0 -75
- package/dist/dock.js +0 -176
- package/dist/editor.d.ts +0 -75
- package/dist/editor.js +0 -444
- package/dist/render.d.ts +0 -135
- package/dist/render.js +0 -325
package/dist/dock.js
DELETED
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
/**
|
|
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
|
-
* implementation: zero dependencies, line-level ANSI, no differential
|
|
5
|
-
* renderer.
|
|
6
|
-
*
|
|
7
|
-
* Layout (H = terminal height): rows 1..H-4 = the scroll region (the body
|
|
8
|
-
* streams and scrolls here, never touching the bottom), row H-3 = the
|
|
9
|
-
* upper dim dotted separator (╌), row H-2 = the input line (the blue
|
|
10
|
-
* brick ▌you> + the v2c editor's row — readline is gone from the TTY
|
|
11
|
-
* path), row H-1 = the lower dotted separator, row H = the live status
|
|
12
|
-
* bar (v3 §03: idle "▸ <mode> · /mode to switch · …", running
|
|
13
|
-
* "▖ working Ns · …"; a takeover question replaces it). Bottom redraws
|
|
14
|
-
* are wrapped in CSI 2026 (synchronized output) to avoid flicker — the
|
|
15
|
-
* pi trick. The visual identity is the kiso brick motif — ▌ half-block,
|
|
16
|
-
* dotted separators — deliberately NOT the CC rounded frame nor the pi
|
|
17
|
-
* editor (ADR-0039 Amendment 2).
|
|
18
|
-
*
|
|
19
|
-
* Pipes / NO_COLOR: the dock never activates; the v2a line mode stays
|
|
20
|
-
* byte-for-byte (the existing e2e assertions guard it).
|
|
21
|
-
*/
|
|
22
|
-
import { displayWidth } from "./editor.js";
|
|
23
|
-
import { palette } from "./render.js";
|
|
24
|
-
export class Dock {
|
|
25
|
-
#active = false;
|
|
26
|
-
#height = 0;
|
|
27
|
-
#width = 0;
|
|
28
|
-
#status = "";
|
|
29
|
-
#tail = ""; // the live tail: the spinner glyph or "running <tool> Ns"
|
|
30
|
-
#question = null; // a takeover question shown at H-1
|
|
31
|
-
#inputState = () => ({ line: "", cursor: 0 });
|
|
32
|
-
#inputPrompt = "";
|
|
33
|
-
#bodyRow = 1; // the body's logical row inside the scroll region
|
|
34
|
-
#bodyCol = 1; // …and column (mid-line continuations survive cursor jumps)
|
|
35
|
-
#resizeHandler = null;
|
|
36
|
-
/** v2b: docked only on a color TTY — pipes and NO_COLOR stay v2a. */
|
|
37
|
-
get active() {
|
|
38
|
-
return this.#active;
|
|
39
|
-
}
|
|
40
|
-
/** Bind the CURRENT input line's state — called when a readline takes
|
|
41
|
-
* over (the chat REPL, the trust question's short-lived rl, resume). */
|
|
42
|
-
bindInput(state, prompt) {
|
|
43
|
-
this.#inputState = state;
|
|
44
|
-
this.#inputPrompt = prompt;
|
|
45
|
-
}
|
|
46
|
-
/** Enter docked mode: draw the chrome. #13 (P1): the DECSTBM scroll
|
|
47
|
-
* region is GONE — v2d-B (ADR-0040): the body uses plain LF scrolling
|
|
48
|
-
* so frozen lines enter the native scrollback deterministically
|
|
49
|
-
* (region-scrolled lines are terminal-dependent — some terminals drop
|
|
50
|
-
* them). The dock rows are redrawn by the body after every scroll. A
|
|
51
|
-
* TTY without a real window size (rows < 4) stays in the v2a line
|
|
52
|
-
* mode — the bottom three rows need room to exist. */
|
|
53
|
-
enter() {
|
|
54
|
-
const rows = process.stdout.rows ?? 0;
|
|
55
|
-
if (process.stdout.isTTY !== true || palette().blue === "" || rows < 4)
|
|
56
|
-
return;
|
|
57
|
-
this.#active = true;
|
|
58
|
-
this.#height = rows;
|
|
59
|
-
this.#width = process.stdout.columns ?? 80;
|
|
60
|
-
this.#bodyRow = 1;
|
|
61
|
-
this.#bodyCol = 1;
|
|
62
|
-
this.redraw();
|
|
63
|
-
this.#resizeHandler = () => this.onResize();
|
|
64
|
-
process.stdout.on("resize", this.#resizeHandler);
|
|
65
|
-
}
|
|
66
|
-
/** Teardown — CSI r resets the scroll region, the cursor lands at the
|
|
67
|
-
* input line, the bottom rows are cleared: no broken terminal. Called
|
|
68
|
-
* from main's finally on EVERY exit path (kill -9 excepted — README:
|
|
69
|
-
* `reset` saves it). */
|
|
70
|
-
exit() {
|
|
71
|
-
if (!this.#active)
|
|
72
|
-
return;
|
|
73
|
-
this.#active = false;
|
|
74
|
-
if (this.#resizeHandler !== null) {
|
|
75
|
-
process.stdout.off("resize", this.#resizeHandler);
|
|
76
|
-
this.#resizeHandler = null;
|
|
77
|
-
}
|
|
78
|
-
const H = this.#height;
|
|
79
|
-
process.stdout.write("\x1b[r"); // reset the scroll region
|
|
80
|
-
for (let row = H - 3; row <= H; row += 1) {
|
|
81
|
-
process.stdout.write(`\x1b[${row};1H\x1b[0K`); // clear the four rows
|
|
82
|
-
}
|
|
83
|
-
process.stdout.write(`\x1b[${H};1H`);
|
|
84
|
-
}
|
|
85
|
-
/** SIGWINCH: recompute the size, redraw the chrome. */
|
|
86
|
-
onResize() {
|
|
87
|
-
if (!this.#active)
|
|
88
|
-
return;
|
|
89
|
-
this.#height = process.stdout.rows ?? this.#height;
|
|
90
|
-
this.#width = process.stdout.columns ?? this.#width;
|
|
91
|
-
this.redraw();
|
|
92
|
-
}
|
|
93
|
-
#menuState = null;
|
|
94
|
-
/** v3 §04: bind the editor's slash-command menu state — the menu rows
|
|
95
|
-
* render ABOVE the chrome (over the body's bottom rows; the menu
|
|
96
|
-
* opens while the buffer is a "/" prefix, when no tail is live). */
|
|
97
|
-
bindMenu(state) {
|
|
98
|
-
this.#menuState = state;
|
|
99
|
-
}
|
|
100
|
-
/** The input line's edit column — prompt width + cursor + 1. The
|
|
101
|
-
* dock's redraw and the body's cursor return both end here, so the
|
|
102
|
-
* ACTUAL cursor always equals what the editor tracks. The width is
|
|
103
|
-
* DISPLAY width (the editor's cursor column is already width-based —
|
|
104
|
-
* the CJK drift root cause, editor.ts). v2d: public — the Body's
|
|
105
|
-
* render loop ends at this column. */
|
|
106
|
-
editCol() {
|
|
107
|
-
return this.#inputCol();
|
|
108
|
-
}
|
|
109
|
-
#inputCol() {
|
|
110
|
-
const inp = this.#inputState();
|
|
111
|
-
const promptWidth = displayWidth(this.#inputPrompt.replace(/\x1b\[[0-9;]*m/g, ""));
|
|
112
|
-
return promptWidth + inp.cursor + 1;
|
|
113
|
-
}
|
|
114
|
-
/** The status bar's base text (usage, ctx, session, …). */
|
|
115
|
-
setStatus(text) {
|
|
116
|
-
this.#status = text;
|
|
117
|
-
this.redraw();
|
|
118
|
-
}
|
|
119
|
-
/** The live tail — the spinner glyph or "running <tool> Ns". */
|
|
120
|
-
setTail(tail) {
|
|
121
|
-
this.#tail = tail;
|
|
122
|
-
this.redraw();
|
|
123
|
-
}
|
|
124
|
-
/** Show a takeover question at the status position (answered at the
|
|
125
|
-
* input line by the caller's readline); clearQuestion() restores. */
|
|
126
|
-
showQuestion(question) {
|
|
127
|
-
this.#question = question;
|
|
128
|
-
this.redraw();
|
|
129
|
-
}
|
|
130
|
-
clearQuestion() {
|
|
131
|
-
this.#question = null;
|
|
132
|
-
this.redraw();
|
|
133
|
-
}
|
|
134
|
-
/** The bottom four rows, wrapped in CSI 2026 (synchronized output —
|
|
135
|
-
* the pi trick against flicker). The cursor ends at the input line's
|
|
136
|
-
* edit position. v3 §03: the upper ╌ row, the input row, the lower
|
|
137
|
-
* ╌ row, the status row — the status is dim (blue accents inside
|
|
138
|
-
* come from the CLI's composition). */
|
|
139
|
-
redraw() {
|
|
140
|
-
if (!this.#active)
|
|
141
|
-
return;
|
|
142
|
-
const p = palette();
|
|
143
|
-
const H = this.#height;
|
|
144
|
-
const W = this.#width;
|
|
145
|
-
const sep = `${p.dim}${"╌".repeat(W)}${p.reset}`;
|
|
146
|
-
const status = `${this.#status}${this.#tail === "" ? "" : ` · ${this.#tail}`}`;
|
|
147
|
-
const statusLine = this.#question ?? `${p.dim}${status}${p.reset}`;
|
|
148
|
-
const inp = this.#inputState();
|
|
149
|
-
const out = [];
|
|
150
|
-
// P3 (审查): the DEC private-mode SET/RESET needs the "?" prefix —
|
|
151
|
-
// \x1b[?2026h/l, the pi source's exact form. Without it terminals
|
|
152
|
-
// silently ignore the mode and the anti-flicker never engages.
|
|
153
|
-
out.push("\x1b[?2026h"); // synchronized output ON (DEC 2026)
|
|
154
|
-
// v3 §04: the slash-command menu — above the chrome, one row per
|
|
155
|
-
// filtered command, the selection highlighted. Drawn first so the
|
|
156
|
-
// chrome rows repaint on top of any overlap.
|
|
157
|
-
const menu = this.#menuState?.();
|
|
158
|
-
if (menu !== null && menu !== undefined) {
|
|
159
|
-
for (let i = 0; i < menu.items.length; i += 1) {
|
|
160
|
-
const item = menu.items[i];
|
|
161
|
-
const row = H - 4 - (menu.items.length - 1 - i);
|
|
162
|
-
const text = i === menu.selected
|
|
163
|
-
? `${p.blue}▸ ${item.name}${p.reset} ${item.desc}`
|
|
164
|
-
: `${p.dim} ${item.name} ${item.desc}${p.reset}`;
|
|
165
|
-
out.push(`\x1b[${row};1H\x1b[0K${text}`);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
out.push(`\x1b[${H - 3};1H\x1b[0K${sep}`);
|
|
169
|
-
out.push(`\x1b[${H - 2};1H\x1b[0K${this.#inputPrompt}${inp.line}`);
|
|
170
|
-
out.push(`\x1b[${H - 1};1H\x1b[0K${sep}`);
|
|
171
|
-
out.push(`\x1b[${H};1H\x1b[0K${statusLine}`);
|
|
172
|
-
out.push(`\x1b[${H - 2};${this.#inputCol()}H`); // back to the edit position
|
|
173
|
-
out.push("\x1b[?2026l"); // synchronized output OFF
|
|
174
|
-
process.stdout.write(out.join(""));
|
|
175
|
-
}
|
|
176
|
-
}
|
package/dist/editor.d.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
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
|
-
/** v3 §04 — the slash-command menu's command table (English one-liners). */
|
|
28
|
-
export interface MenuItem {
|
|
29
|
-
readonly name: string;
|
|
30
|
-
readonly desc: string;
|
|
31
|
-
}
|
|
32
|
-
export declare const MENU_ITEMS: readonly MenuItem[];
|
|
33
|
-
/**
|
|
34
|
-
* The editor. Raw mode + bracketed paste (?2004h) on enter, restored on
|
|
35
|
-
* exit. The input row is rendered by `onRender` (the CLI wires it to the
|
|
36
|
-
* dock's redraw when docked, the editor's own self-render otherwise) —
|
|
37
|
-
* the editor itself never writes while docked. The event handlers are
|
|
38
|
-
* settable — the chat/resume contexts wire them after construction.
|
|
39
|
-
*/
|
|
40
|
-
export declare class Editor {
|
|
41
|
-
#private;
|
|
42
|
-
readonly closed: Promise<void>;
|
|
43
|
-
constructor(onRender: () => void);
|
|
44
|
-
onLine(cb: (line: string) => void): void;
|
|
45
|
-
onSigint(cb: () => void): void;
|
|
46
|
-
onEot(cb: () => void): void;
|
|
47
|
-
onEscape(cb: () => void): void;
|
|
48
|
-
/** The whole buffer as text (the CLI's line()/clearLine()). */
|
|
49
|
-
line(): string;
|
|
50
|
-
clearLine(): void;
|
|
51
|
-
/** The visible slice (dim "…" prefix when scrolled) + the cursor's
|
|
52
|
-
* display column within it — the dock's input-row state. */
|
|
53
|
-
dockState(): {
|
|
54
|
-
line: string;
|
|
55
|
-
cursor: number;
|
|
56
|
-
};
|
|
57
|
-
/** v3 §04: the menu's visible state for the dock — null when closed. */
|
|
58
|
-
menuState(): {
|
|
59
|
-
items: readonly MenuItem[];
|
|
60
|
-
selected: number;
|
|
61
|
-
} | null;
|
|
62
|
-
/** One-shot question mode: the NEXT submit answers, not a turn. */
|
|
63
|
-
question(_query: string, cb: (answer: string) => void): void;
|
|
64
|
-
/** Cancel a pending question — the buffer stays (its text becomes the
|
|
65
|
-
* next turn on Enter, the readline re-emit equivalent). */
|
|
66
|
-
cancelQuestion(): void;
|
|
67
|
-
enter(): void;
|
|
68
|
-
exit(): void;
|
|
69
|
-
/** The row's own render when the dock is inactive (a TTY without a
|
|
70
|
-
* real size): \r + clear + blue brick prompt + visible + cursor
|
|
71
|
-
* column. */
|
|
72
|
-
selfRender(): void;
|
|
73
|
-
/** Feed raw stdin bytes — the parser. Public for unit tests. */
|
|
74
|
-
feed(raw: Uint8Array): void;
|
|
75
|
-
}
|
package/dist/editor.js
DELETED
|
@@ -1,444 +0,0 @@
|
|
|
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
|
-
export const MENU_ITEMS = [
|
|
73
|
-
{ name: "/mode", desc: "switch the approval tier (manual/default/accept-edits/plan/bypass)" },
|
|
74
|
-
{ name: "/think", desc: "show the last full thinking block" },
|
|
75
|
-
{ name: "/last", desc: "show the most recent tool call's input and output" },
|
|
76
|
-
{ name: "/status", desc: "show session id, event count, and context estimate" },
|
|
77
|
-
{ name: "/help", desc: "print this list of commands" },
|
|
78
|
-
];
|
|
79
|
-
/**
|
|
80
|
-
* The editor. Raw mode + bracketed paste (?2004h) on enter, restored on
|
|
81
|
-
* exit. The input row is rendered by `onRender` (the CLI wires it to the
|
|
82
|
-
* dock's redraw when docked, the editor's own self-render otherwise) —
|
|
83
|
-
* the editor itself never writes while docked. The event handlers are
|
|
84
|
-
* settable — the chat/resume contexts wire them after construction.
|
|
85
|
-
*/
|
|
86
|
-
export class Editor {
|
|
87
|
-
#chars = [];
|
|
88
|
-
#cursor = 0;
|
|
89
|
-
#scroll = 0; // chars scrolled off the left (width-based reflow)
|
|
90
|
-
#questionCb = null;
|
|
91
|
-
#pasting = false;
|
|
92
|
-
#lineCb = null;
|
|
93
|
-
#pendingLines = []; // submits before onLine is wired (startup) — never dropped
|
|
94
|
-
#sigintCb = null;
|
|
95
|
-
#eotCb = null;
|
|
96
|
-
#escapeCb = null;
|
|
97
|
-
#onRender;
|
|
98
|
-
#menuOpen = false; // v3 §04: the slash-command menu
|
|
99
|
-
#menuSel = 0;
|
|
100
|
-
#pending = ""; // an incomplete ESC/CSI prefix across chunks
|
|
101
|
-
#decoder = new TextDecoder();
|
|
102
|
-
#entered = false;
|
|
103
|
-
#onData;
|
|
104
|
-
#closedResolve;
|
|
105
|
-
closed;
|
|
106
|
-
constructor(onRender) {
|
|
107
|
-
this.#onRender = onRender;
|
|
108
|
-
this.#onData = (raw) => this.feed(raw);
|
|
109
|
-
this.closed = new Promise((resolve) => {
|
|
110
|
-
this.#closedResolve = resolve;
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
onLine(cb) {
|
|
114
|
-
this.#lineCb = cb;
|
|
115
|
-
// Flush submits that arrived before the handler was wired (typed
|
|
116
|
-
// during startup) — readline buffered these; the editor must too.
|
|
117
|
-
for (const line of this.#pendingLines)
|
|
118
|
-
cb(line);
|
|
119
|
-
this.#pendingLines.length = 0;
|
|
120
|
-
}
|
|
121
|
-
onSigint(cb) {
|
|
122
|
-
this.#sigintCb = cb;
|
|
123
|
-
}
|
|
124
|
-
onEot(cb) {
|
|
125
|
-
this.#eotCb = cb;
|
|
126
|
-
}
|
|
127
|
-
onEscape(cb) {
|
|
128
|
-
this.#escapeCb = cb;
|
|
129
|
-
}
|
|
130
|
-
/** The whole buffer as text (the CLI's line()/clearLine()). */
|
|
131
|
-
line() {
|
|
132
|
-
return String.fromCodePoint(...this.#chars);
|
|
133
|
-
}
|
|
134
|
-
clearLine() {
|
|
135
|
-
this.#chars = [];
|
|
136
|
-
this.#cursor = 0;
|
|
137
|
-
this.#scroll = 0;
|
|
138
|
-
this.#onRender();
|
|
139
|
-
}
|
|
140
|
-
/** The visible slice (dim "…" prefix when scrolled) + the cursor's
|
|
141
|
-
* display column within it — the dock's input-row state. */
|
|
142
|
-
dockState() {
|
|
143
|
-
const visible = String.fromCodePoint(...this.#chars.slice(this.#scroll));
|
|
144
|
-
const prefix = this.#scroll > 0 ? "\x1b[2m…\x1b[0m" : "";
|
|
145
|
-
const col = (this.#scroll > 0 ? 1 : 0) + widthOf(this.#chars.slice(this.#scroll, this.#cursor));
|
|
146
|
-
return { line: `${prefix}${visible}`, cursor: col };
|
|
147
|
-
}
|
|
148
|
-
/** v3 §04: the menu's visible state for the dock — null when closed. */
|
|
149
|
-
menuState() {
|
|
150
|
-
if (!this.#menuOpen)
|
|
151
|
-
return null;
|
|
152
|
-
return { items: this.#menuFiltered(), selected: this.#menuSel };
|
|
153
|
-
}
|
|
154
|
-
/** v3 §04: the filtered command list for the current buffer — open
|
|
155
|
-
* only while the line is "/" + something (a bare "/" waits). */
|
|
156
|
-
#menuFiltered() {
|
|
157
|
-
const line = this.line();
|
|
158
|
-
if (!line.startsWith("/") || line === "/")
|
|
159
|
-
return [];
|
|
160
|
-
return MENU_ITEMS.filter((m) => m.name.startsWith(line));
|
|
161
|
-
}
|
|
162
|
-
#refreshMenu() {
|
|
163
|
-
const f = this.#menuFiltered();
|
|
164
|
-
this.#menuOpen = f.length > 0;
|
|
165
|
-
if (this.#menuSel >= f.length)
|
|
166
|
-
this.#menuSel = 0;
|
|
167
|
-
this.#onRender();
|
|
168
|
-
}
|
|
169
|
-
/** One-shot question mode: the NEXT submit answers, not a turn. */
|
|
170
|
-
question(_query, cb) {
|
|
171
|
-
this.#questionCb = cb;
|
|
172
|
-
}
|
|
173
|
-
/** Cancel a pending question — the buffer stays (its text becomes the
|
|
174
|
-
* next turn on Enter, the readline re-emit equivalent). */
|
|
175
|
-
cancelQuestion() {
|
|
176
|
-
this.#questionCb = null;
|
|
177
|
-
}
|
|
178
|
-
enter() {
|
|
179
|
-
if (this.#entered)
|
|
180
|
-
return;
|
|
181
|
-
this.#entered = true;
|
|
182
|
-
process.stdin.setRawMode(true);
|
|
183
|
-
process.stdout.write("\x1b[?2004h"); // bracketed paste ON
|
|
184
|
-
process.stdin.on("data", this.#onData);
|
|
185
|
-
this.#onRender();
|
|
186
|
-
}
|
|
187
|
-
exit() {
|
|
188
|
-
if (!this.#entered)
|
|
189
|
-
return;
|
|
190
|
-
this.#entered = false;
|
|
191
|
-
process.stdin.off("data", this.#onData);
|
|
192
|
-
process.stdout.write("\x1b[?2004l"); // bracketed paste OFF
|
|
193
|
-
process.stdin.setRawMode(false);
|
|
194
|
-
this.#closedResolve();
|
|
195
|
-
}
|
|
196
|
-
/** The row's own render when the dock is inactive (a TTY without a
|
|
197
|
-
* real size): \r + clear + blue brick prompt + visible + cursor
|
|
198
|
-
* column. */
|
|
199
|
-
selfRender() {
|
|
200
|
-
const p = palette();
|
|
201
|
-
const st = this.dockState();
|
|
202
|
-
const W = (process.stdout.columns ?? 0) || 80; // a degenerate 0 size (no TIOCSWINSZ) falls back
|
|
203
|
-
const cursorCol = Math.min(1 + PROMPT_WIDTH + st.cursor, W);
|
|
204
|
-
process.stdout.write(`\r\x1b[0K${p.blue}${PROMPT}${p.reset}${st.line}\x1b[${cursorCol}G`);
|
|
205
|
-
}
|
|
206
|
-
// ---- input ----
|
|
207
|
-
/** Feed raw stdin bytes — the parser. Public for unit tests. */
|
|
208
|
-
feed(raw) {
|
|
209
|
-
const text = this.#pending + this.#decoder.decode(raw, { stream: true });
|
|
210
|
-
this.#pending = "";
|
|
211
|
-
let i = 0;
|
|
212
|
-
while (i < text.length) {
|
|
213
|
-
const c = text[i];
|
|
214
|
-
if (c === "\x1b") {
|
|
215
|
-
const rest = text.slice(i + 1);
|
|
216
|
-
if (rest.startsWith("[")) {
|
|
217
|
-
const m = rest.match(/^\[([0-9;?]*)([A-Za-z~])/);
|
|
218
|
-
if (m === null) {
|
|
219
|
-
this.#pending = text.slice(i); // incomplete CSI — wait for more
|
|
220
|
-
break;
|
|
221
|
-
}
|
|
222
|
-
this.#csi(m[1], m[2]);
|
|
223
|
-
i += m[0].length + 1;
|
|
224
|
-
}
|
|
225
|
-
else if (rest.startsWith("O")) {
|
|
226
|
-
i += 3; // SS3 (function keys) — ignored
|
|
227
|
-
}
|
|
228
|
-
else if (this.#menuOpen) {
|
|
229
|
-
// v3 §04: Esc closes the menu and clears the buffer.
|
|
230
|
-
this.#chars = [];
|
|
231
|
-
this.#cursor = 0;
|
|
232
|
-
this.#scroll = 0;
|
|
233
|
-
this.#refreshMenu();
|
|
234
|
-
}
|
|
235
|
-
else {
|
|
236
|
-
this.#escapeCb?.();
|
|
237
|
-
i += 1;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
else if (c === "\x0d" || c === "\x0a") {
|
|
241
|
-
if (this.#pasting) {
|
|
242
|
-
this.#insert(0x20); // single-line editor: newlines become spaces
|
|
243
|
-
}
|
|
244
|
-
else {
|
|
245
|
-
this.#submit();
|
|
246
|
-
}
|
|
247
|
-
i += 1;
|
|
248
|
-
}
|
|
249
|
-
else if (c === "\x7f" || c === "\x08") {
|
|
250
|
-
this.#backspace();
|
|
251
|
-
i += 1;
|
|
252
|
-
}
|
|
253
|
-
else if (c === "\x03") {
|
|
254
|
-
this.#sigintCb?.();
|
|
255
|
-
i += 1;
|
|
256
|
-
}
|
|
257
|
-
else if (c === "\x04") {
|
|
258
|
-
this.#eotCb?.();
|
|
259
|
-
i += 1;
|
|
260
|
-
}
|
|
261
|
-
else if (c === "\x15") {
|
|
262
|
-
this.#killToStart();
|
|
263
|
-
i += 1;
|
|
264
|
-
}
|
|
265
|
-
else if (c === "\x0b") {
|
|
266
|
-
this.#killToEnd();
|
|
267
|
-
i += 1;
|
|
268
|
-
}
|
|
269
|
-
else if (c === "\x17") {
|
|
270
|
-
this.#killWord();
|
|
271
|
-
i += 1;
|
|
272
|
-
}
|
|
273
|
-
else if (c === "\x01") {
|
|
274
|
-
this.#cursor = 0;
|
|
275
|
-
this.#reflow();
|
|
276
|
-
this.#onRender();
|
|
277
|
-
i += 1;
|
|
278
|
-
}
|
|
279
|
-
else if (c === "\x05") {
|
|
280
|
-
this.#cursor = this.#chars.length;
|
|
281
|
-
this.#reflow();
|
|
282
|
-
this.#onRender();
|
|
283
|
-
i += 1;
|
|
284
|
-
}
|
|
285
|
-
else if (c === "\t" && this.#menuOpen) {
|
|
286
|
-
// v3 §04: Tab completes the buffer to the selected command.
|
|
287
|
-
const f = this.#menuFiltered();
|
|
288
|
-
const m = f[this.#menuSel];
|
|
289
|
-
if (m !== undefined) {
|
|
290
|
-
this.#chars = [...m.name].map((ch) => ch.codePointAt(0));
|
|
291
|
-
this.#cursor = this.#chars.length;
|
|
292
|
-
this.#reflow();
|
|
293
|
-
this.#refreshMenu();
|
|
294
|
-
}
|
|
295
|
-
i += 1;
|
|
296
|
-
}
|
|
297
|
-
else if (c !== undefined && c < " ") {
|
|
298
|
-
i += 1; // other control — ignored
|
|
299
|
-
}
|
|
300
|
-
else {
|
|
301
|
-
this.#insert(text.codePointAt(i));
|
|
302
|
-
i += c.length;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
#csi(params, final) {
|
|
307
|
-
if (final === "~") {
|
|
308
|
-
const n = Number(params);
|
|
309
|
-
if (n === 3)
|
|
310
|
-
this.#delete();
|
|
311
|
-
else if (n === 200)
|
|
312
|
-
this.#pasting = true;
|
|
313
|
-
else if (n === 201) {
|
|
314
|
-
this.#pasting = false;
|
|
315
|
-
this.#onRender();
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
else if (final === "A" && this.#menuOpen) {
|
|
319
|
-
// v3 §04: ↑↓ move the menu selection, never the cursor.
|
|
320
|
-
this.#menuSel = Math.max(0, this.#menuSel - 1);
|
|
321
|
-
this.#onRender();
|
|
322
|
-
}
|
|
323
|
-
else if (final === "B" && this.#menuOpen) {
|
|
324
|
-
this.#menuSel = Math.min(this.#menuFiltered().length - 1, this.#menuSel + 1);
|
|
325
|
-
this.#onRender();
|
|
326
|
-
}
|
|
327
|
-
else if (final === "D") {
|
|
328
|
-
this.#move(-1);
|
|
329
|
-
}
|
|
330
|
-
else if (final === "C") {
|
|
331
|
-
this.#move(1);
|
|
332
|
-
}
|
|
333
|
-
else if (final === "H") {
|
|
334
|
-
this.#cursor = 0;
|
|
335
|
-
this.#reflow();
|
|
336
|
-
}
|
|
337
|
-
else if (final === "F") {
|
|
338
|
-
this.#cursor = this.#chars.length;
|
|
339
|
-
this.#reflow();
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
// ---- editing ----
|
|
343
|
-
#insert(cp) {
|
|
344
|
-
this.#chars.splice(this.#cursor, 0, cp);
|
|
345
|
-
this.#cursor += 1;
|
|
346
|
-
this.#reflow();
|
|
347
|
-
if (!this.#pasting)
|
|
348
|
-
this.#refreshMenu();
|
|
349
|
-
}
|
|
350
|
-
#backspace() {
|
|
351
|
-
if (this.#cursor === 0)
|
|
352
|
-
return;
|
|
353
|
-
this.#chars.splice(this.#cursor - 1, 1);
|
|
354
|
-
this.#cursor -= 1;
|
|
355
|
-
this.#reflow();
|
|
356
|
-
if (!this.#pasting)
|
|
357
|
-
this.#refreshMenu();
|
|
358
|
-
}
|
|
359
|
-
#delete() {
|
|
360
|
-
if (this.#cursor >= this.#chars.length)
|
|
361
|
-
return;
|
|
362
|
-
this.#chars.splice(this.#cursor, 1);
|
|
363
|
-
this.#reflow();
|
|
364
|
-
if (!this.#pasting)
|
|
365
|
-
this.#refreshMenu();
|
|
366
|
-
}
|
|
367
|
-
#move(delta) {
|
|
368
|
-
this.#cursor = Math.max(0, Math.min(this.#chars.length, this.#cursor + delta));
|
|
369
|
-
this.#reflow();
|
|
370
|
-
if (!this.#pasting)
|
|
371
|
-
this.#onRender();
|
|
372
|
-
}
|
|
373
|
-
#killToStart() {
|
|
374
|
-
this.#chars.splice(0, this.#cursor);
|
|
375
|
-
this.#cursor = 0;
|
|
376
|
-
this.#reflow();
|
|
377
|
-
if (!this.#pasting)
|
|
378
|
-
this.#onRender();
|
|
379
|
-
}
|
|
380
|
-
#killToEnd() {
|
|
381
|
-
this.#chars.length = this.#cursor;
|
|
382
|
-
this.#reflow();
|
|
383
|
-
if (!this.#pasting)
|
|
384
|
-
this.#onRender();
|
|
385
|
-
}
|
|
386
|
-
#killWord() {
|
|
387
|
-
let i = this.#cursor;
|
|
388
|
-
while (i > 0 && this.#chars[i - 1] === 0x20)
|
|
389
|
-
i -= 1; // trailing spaces
|
|
390
|
-
while (i > 0 && this.#chars[i - 1] !== 0x20)
|
|
391
|
-
i -= 1; // the word
|
|
392
|
-
this.#chars.splice(i, this.#cursor - i);
|
|
393
|
-
this.#cursor = i;
|
|
394
|
-
this.#reflow();
|
|
395
|
-
}
|
|
396
|
-
#submit() {
|
|
397
|
-
let line = String.fromCodePoint(...this.#chars);
|
|
398
|
-
if (this.#menuOpen) {
|
|
399
|
-
// v3 §04: Enter submits the SELECTED command.
|
|
400
|
-
const m = this.#menuFiltered()[this.#menuSel];
|
|
401
|
-
if (m !== undefined)
|
|
402
|
-
line = m.name;
|
|
403
|
-
}
|
|
404
|
-
this.#chars = [];
|
|
405
|
-
this.#cursor = 0;
|
|
406
|
-
this.#scroll = 0;
|
|
407
|
-
this.#menuOpen = false;
|
|
408
|
-
this.#menuSel = 0;
|
|
409
|
-
const cb = this.#questionCb;
|
|
410
|
-
this.#questionCb = null;
|
|
411
|
-
if (cb !== null) {
|
|
412
|
-
cb(line);
|
|
413
|
-
}
|
|
414
|
-
else if (this.#lineCb !== null) {
|
|
415
|
-
this.#lineCb(line);
|
|
416
|
-
}
|
|
417
|
-
else {
|
|
418
|
-
this.#pendingLines.push(line); // nobody wired yet — hold it
|
|
419
|
-
}
|
|
420
|
-
this.#onRender();
|
|
421
|
-
}
|
|
422
|
-
// ---- width-based horizontal scroll ----
|
|
423
|
-
#reflow() {
|
|
424
|
-
const W = (process.stdout.columns ?? 0) || 80; // degenerate 0 falls back to 80
|
|
425
|
-
const maxW = Math.max(1, W - PROMPT_WIDTH - 1); // 1 col for the "…"
|
|
426
|
-
const curCol = widthOf(this.#chars.slice(0, this.#cursor));
|
|
427
|
-
const scrolledW = widthOf(this.#chars.slice(0, this.#scroll));
|
|
428
|
-
if (curCol < scrolledW) {
|
|
429
|
-
this.#scroll = this.#indexAtWidth(curCol);
|
|
430
|
-
}
|
|
431
|
-
else if (curCol >= scrolledW + maxW) {
|
|
432
|
-
this.#scroll = this.#indexAtWidth(Math.max(0, curCol - maxW + 1));
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
#indexAtWidth(target) {
|
|
436
|
-
let w = 0;
|
|
437
|
-
for (let i = 0; i < this.#chars.length; i += 1) {
|
|
438
|
-
if (w >= target)
|
|
439
|
-
return i;
|
|
440
|
-
w += charWidth(this.#chars[i]);
|
|
441
|
-
}
|
|
442
|
-
return this.#chars.length;
|
|
443
|
-
}
|
|
444
|
-
}
|