klyro 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/agent/custom-agents.d.ts +3 -0
- package/dist/agent/custom-agents.js +96 -0
- package/dist/agent/orchestrator.d.ts +22 -2
- package/dist/agent/orchestrator.js +30 -4
- package/dist/agent/runtime.d.ts +5 -0
- package/dist/agent/runtime.js +174 -51
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +20 -0
- package/dist/cli/auth.js +11 -3
- package/dist/cli/completion.js +63 -10
- package/dist/cli/config.d.ts +4 -4
- package/dist/cli/doctor.js +13 -0
- package/dist/cli/eval.d.ts +15 -1
- package/dist/cli/eval.js +34 -2
- package/dist/cli/hooks.d.ts +54 -5
- package/dist/cli/hooks.js +85 -6
- package/dist/cli/init.d.ts +6 -0
- package/dist/cli/init.js +60 -0
- package/dist/cli/repl.js +261 -34
- package/dist/cli/run.d.ts +7 -1
- package/dist/cli/run.js +97 -41
- package/dist/cli/slash/custom.d.ts +25 -0
- package/dist/cli/slash/custom.js +166 -0
- package/dist/cli/slash/parser.d.ts +16 -2
- package/dist/cli/slash/parser.js +67 -18
- package/dist/cli/update.d.ts +8 -4
- package/dist/cli/update.js +50 -7
- package/dist/context/accounting.d.ts +8 -0
- package/dist/context/accounting.js +18 -1
- package/dist/context/compaction.d.ts +1 -0
- package/dist/context/compaction.js +2 -1
- package/dist/context/memory.js +18 -1
- package/dist/eval/harness.d.ts +21 -3
- package/dist/eval/harness.js +31 -3
- package/dist/eval/judge.d.ts +32 -0
- package/dist/eval/judge.js +63 -0
- package/dist/eval/tasks.js +134 -0
- package/dist/index.js +225 -132
- package/dist/mcp/client.d.ts +15 -0
- package/dist/mcp/client.js +42 -2
- package/dist/mcp/config.d.ts +10 -1
- package/dist/mcp/config.js +64 -1
- package/dist/mcp/registry.d.ts +13 -0
- package/dist/mcp/registry.js +47 -5
- package/dist/mcp/remote.d.ts +29 -0
- package/dist/mcp/remote.js +153 -0
- package/dist/mcp/serve.d.ts +23 -0
- package/dist/mcp/serve.js +111 -0
- package/dist/policy/approval.d.ts +15 -1
- package/dist/policy/approval.js +8 -0
- package/dist/policy/engine.d.ts +11 -1
- package/dist/policy/engine.js +14 -1
- package/dist/policy/secret-redactor.js +4 -1
- package/dist/providers/endpoints.d.ts +43 -0
- package/dist/providers/endpoints.js +104 -0
- package/dist/providers.js +13 -10
- package/dist/shared/error-map.d.ts +19 -0
- package/dist/shared/error-map.js +58 -0
- package/dist/tools/lsp/diagnostics.d.ts +35 -4
- package/dist/tools/lsp/diagnostics.js +88 -9
- package/dist/tools/normalize.d.ts +3 -0
- package/dist/tools/normalize.js +8 -5
- package/dist/tools/search/dependencies.d.ts +2 -2
- package/dist/tools/shell/background.d.ts +6 -0
- package/dist/tools/shell/background.js +17 -0
- package/dist/tools/symbols/find-symbol.d.ts +1 -1
- package/dist/tools/symbols/find-symbol.js +8 -6
- package/dist/tools/types.d.ts +8 -1
- package/dist/tui/app.d.ts +2 -0
- package/dist/tui/app.js +454 -53
- package/dist/tui/app.test.js +66 -3
- package/dist/tui/approval.js +53 -1
- package/dist/tui/markdown.js +9 -0
- package/dist/tui/mouse.d.ts +26 -1
- package/dist/tui/mouse.js +104 -6
- package/dist/tui/scroll-flow.test.js +3 -1
- package/dist/tui/tokens.d.ts +6 -6
- package/dist/tui/tokens.js +9 -6
- package/package.json +1 -1
package/dist/tui/app.test.js
CHANGED
|
@@ -261,8 +261,13 @@ describe('App', () => {
|
|
|
261
261
|
stdin.write('/c');
|
|
262
262
|
await new Promise((r) => setTimeout(r, 30));
|
|
263
263
|
const withSuggest = lastFrame() ?? '';
|
|
264
|
-
expect(withSuggest).toMatch(/\/clear/);
|
|
265
264
|
expect(withSuggest).toMatch(/tab to complete/i);
|
|
265
|
+
// Prefix dominance: every suggestion for '/c' still starts with c.
|
|
266
|
+
const sugLines = withSuggest.split('\n').filter((l) => l.includes('/') && /clear|compact|cost|config|context|commands|copy|cancel/.test(l));
|
|
267
|
+
expect(sugLines.length).toBeGreaterThan(0);
|
|
268
|
+
// Unambiguous fuzzy prefix completes deterministically.
|
|
269
|
+
stdin.write('le');
|
|
270
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
266
271
|
stdin.write('\t');
|
|
267
272
|
await new Promise((r) => setTimeout(r, 30));
|
|
268
273
|
expect(lastFrame() ?? '').toContain('/clear ');
|
|
@@ -315,9 +320,15 @@ describe('App', () => {
|
|
|
315
320
|
const kinds = onSlash.mock.calls.map((c) => c[0].kind);
|
|
316
321
|
expect(kinds).toEqual(['cancel', 'quit']);
|
|
317
322
|
});
|
|
318
|
-
it('running
|
|
323
|
+
it('running Esc×2 with empty queue cancels streaming (double-Esc convention)', async () => {
|
|
319
324
|
const onSlash = vi.fn(async () => { });
|
|
320
|
-
const { stdin } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: onSlash, initialStatus: { status: 'running' } }));
|
|
325
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: onSlash, initialStatus: { status: 'running' } }));
|
|
326
|
+
// First Esc only arms (hint shows, no cancel yet).
|
|
327
|
+
stdin.write('\x1b');
|
|
328
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
329
|
+
expect(onSlash).not.toHaveBeenCalled();
|
|
330
|
+
await waitForMatch(lastFrame, /press esc again to cancel/);
|
|
331
|
+
// Second Esc within the window cancels.
|
|
321
332
|
stdin.write('\x1b');
|
|
322
333
|
await new Promise((r) => setTimeout(r, 50));
|
|
323
334
|
expect(onSlash).toHaveBeenCalled();
|
|
@@ -519,6 +530,58 @@ describe('App', () => {
|
|
|
519
530
|
expect(frame).not.toContain('{"path"');
|
|
520
531
|
expect(frame).not.toContain('queued:');
|
|
521
532
|
});
|
|
533
|
+
it('scrubs secret shapes at the render boundary (never paints raw)', async () => {
|
|
534
|
+
let hooks = null;
|
|
535
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: false, onMounted: (h) => {
|
|
536
|
+
hooks = { append: h.append };
|
|
537
|
+
} }));
|
|
538
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
539
|
+
hooks.append({ id: 'sec-1', kind: 'text', text: 'deploy with password=hunter2-hunter2', role: 'assistant' });
|
|
540
|
+
const frame = await waitForMatch(lastFrame, /REDACTED/);
|
|
541
|
+
expect(frame).not.toMatch(/hunter2-hunter2/);
|
|
542
|
+
});
|
|
543
|
+
it('vim counts, word motions, and dd edit the buffer', async () => {
|
|
544
|
+
let hooks = null;
|
|
545
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: false, onMounted: (h) => {
|
|
546
|
+
hooks = { setVimMode: h.setVimMode };
|
|
547
|
+
} }));
|
|
548
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
549
|
+
stdin.write('abcdef');
|
|
550
|
+
await waitForMatch(lastFrame, /abcdef/);
|
|
551
|
+
hooks.setVimMode('normal');
|
|
552
|
+
await waitForMatch(lastFrame, /--NORMAL--/);
|
|
553
|
+
// 0 then 3x deletes three chars → 'def'
|
|
554
|
+
stdin.write('0');
|
|
555
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
556
|
+
stdin.write('3');
|
|
557
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
558
|
+
stdin.write('x');
|
|
559
|
+
await waitForMatch(lastFrame, /def/);
|
|
560
|
+
expect(lastFrame() ?? '').not.toMatch(/abcdef/);
|
|
561
|
+
// dd clears the single-line buffer
|
|
562
|
+
stdin.write('d');
|
|
563
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
564
|
+
stdin.write('d');
|
|
565
|
+
await waitForMatch(lastFrame, /Message Klyro/);
|
|
566
|
+
});
|
|
567
|
+
it('vim normal mode swallows typing, shows mode, i returns to insert', async () => {
|
|
568
|
+
let hooks = null;
|
|
569
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: false, onMounted: (h) => {
|
|
570
|
+
hooks = { setVimMode: h.setVimMode };
|
|
571
|
+
} }));
|
|
572
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
573
|
+
stdin.write('ok');
|
|
574
|
+
await waitForMatch(lastFrame, /ok/);
|
|
575
|
+
hooks.setVimMode('normal');
|
|
576
|
+
await waitForMatch(lastFrame, /--NORMAL--/);
|
|
577
|
+
stdin.write('zzz');
|
|
578
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
579
|
+
expect(lastFrame() ?? '').not.toMatch(/zzz/);
|
|
580
|
+
stdin.write('i');
|
|
581
|
+
await waitForAbsent(lastFrame, /--NORMAL--/);
|
|
582
|
+
stdin.write('!');
|
|
583
|
+
await waitForMatch(lastFrame, /ok!/);
|
|
584
|
+
});
|
|
522
585
|
it('aborted run leaves no thinking behind (only response may remain)', async () => {
|
|
523
586
|
let hooks = null;
|
|
524
587
|
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialStatus: { status: 'running' }, onMounted: (h) => {
|
package/dist/tui/approval.js
CHANGED
|
@@ -74,13 +74,65 @@ export function ApprovalModal({ bridge }) {
|
|
|
74
74
|
}, [bridge]);
|
|
75
75
|
const [explain, setExplain] = useState(false);
|
|
76
76
|
const [full, setFull] = useState(false);
|
|
77
|
+
// Inline edit mode (`e`): buffer holds the JSON input being edited.
|
|
78
|
+
// Enter submits (re-validated + re-prompted by the runtime), Esc cancels
|
|
79
|
+
// back to the choice keys. Reset whenever a new prompt arrives.
|
|
80
|
+
const [editing, setEditing] = useState(null);
|
|
81
|
+
const [editError, setEditError] = useState(null);
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
setEditing(null);
|
|
84
|
+
setEditError(null);
|
|
85
|
+
}, [pending]);
|
|
77
86
|
useInput((inputStr, key) => {
|
|
78
87
|
if (!pending)
|
|
79
88
|
return;
|
|
89
|
+
if (editing !== null) {
|
|
90
|
+
if (key.return) {
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(editing);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
setEditError(`invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
100
|
+
setEditError('edited input must be a JSON object');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
bridge.resolve({ kind: 'edit', editedInput: parsed });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (key.escape) {
|
|
107
|
+
setEditing(null);
|
|
108
|
+
setEditError(null);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (key.backspace || key.delete) {
|
|
112
|
+
setEditing((v) => (v ?? '').slice(0, -1));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!key.ctrl && !key.meta)
|
|
116
|
+
setEditing((v) => (v ?? '') + inputStr.replace(/\r/g, '\n'));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
80
119
|
if (key.return) {
|
|
81
120
|
bridge.resolve('deny');
|
|
82
121
|
return;
|
|
83
122
|
}
|
|
123
|
+
// `e` enters inline edit mode (real edit-and-retry); the stdin prompt
|
|
124
|
+
// keeps `e` as deny since it has no editor. Case-sensitive mapping for
|
|
125
|
+
// the rest lives in approvalChoiceForKey.
|
|
126
|
+
if (inputStr === 'e') {
|
|
127
|
+
try {
|
|
128
|
+
setEditing(JSON.stringify(pending.input ?? {}, null, 2));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
setEditing('{}');
|
|
132
|
+
}
|
|
133
|
+
setEditError(null);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
84
136
|
// Case-sensitive mapping lives in approvalChoiceForKey: 'A' persists,
|
|
85
137
|
// 'a' is session-only (the old inline toLowerCase-first code collapsed
|
|
86
138
|
// them, silently downgrading persistence).
|
|
@@ -107,5 +159,5 @@ export function ApprovalModal({ bridge }) {
|
|
|
107
159
|
const clean = sanitizeForPrompt(s);
|
|
108
160
|
return !full && clean.length > n ? clean.slice(0, n) + '…' : clean;
|
|
109
161
|
};
|
|
110
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: "yellow", paddingX: 1, marginY: 1, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["\u26A0 approval needed \u2014 ", pending.toolName] }), _jsxs(Text, { color: "gray", children: [" reason: \"", trunc(pending.reason), "\""] }), pending.summary ? _jsxs(Text, { children: [" \"", trunc(pending.summary), "\""] }) : null, explain ? _jsxs(Text, { color: "cyan", children: [" Explain: This tool will ", pending.toolName, " with the shown args. [y] once, [a] session, [A] always\u2192settings, [n] deny, [e] edit, [f] full text, [?] toggle help."] }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "green", children: "[y] once" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[a] session" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[A] always" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "red", children: "[n] deny" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "yellow", children: "[e] edit" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "cyan", children: "[f] full" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "cyan", children: "[?] explain" }), _jsx(Text, { color: "gray", children: " (Enter = deny)" })] })] }));
|
|
162
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: "yellow", paddingX: 1, marginY: 1, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["\u26A0 approval needed \u2014 ", pending.toolName] }), _jsxs(Text, { color: "gray", children: [" reason: \"", trunc(pending.reason), "\""] }), pending.summary ? _jsxs(Text, { children: [" \"", trunc(pending.summary), "\""] }) : null, editing !== null ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: " editing input JSON (Enter = submit, Esc = back):" }), _jsxs(Text, { wrap: "wrap", children: [" ", (editing.length > 1500 ? editing.slice(0, 1500) + '…' : editing) + '▌'] }), editError ? _jsxs(Text, { color: "red", children: [" ", editError] }) : null] })) : null, explain ? _jsxs(Text, { color: "cyan", children: [" Explain: This tool will ", pending.toolName, " with the shown args. [y] once, [a] session, [A] always\u2192settings, [n] deny, [e] edit, [f] full text, [?] toggle help."] }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "green", children: "[y] once" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[a] session" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[A] always" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "red", children: "[n] deny" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "yellow", children: "[e] edit" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "cyan", children: "[f] full" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "cyan", children: "[?] explain" }), _jsx(Text, { color: "gray", children: " (Enter = deny)" })] })] }));
|
|
111
163
|
}
|
package/dist/tui/markdown.js
CHANGED
|
@@ -103,6 +103,14 @@ export function highlightCodeLine(line, lang) {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
if (STRING_DELIMS.includes(ch)) {
|
|
106
|
+
// Contraction guard: a single-quote mid-word (it's, don't, users')
|
|
107
|
+
// is prose punctuation, not a string delimiter — only open on `'`
|
|
108
|
+
// after start-of-line or a non-word char. Other delims open anywhere.
|
|
109
|
+
if (ch === "'" && i > 0 && /[A-Za-z0-9_)}\]]/.test(line[i - 1])) {
|
|
110
|
+
buf += ch;
|
|
111
|
+
i++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
106
114
|
flush();
|
|
107
115
|
inString = true;
|
|
108
116
|
strDelim = ch;
|
|
@@ -115,6 +123,7 @@ export function highlightCodeLine(line, lang) {
|
|
|
115
123
|
while (j < line.length && /[A-Za-z0-9_$]/.test(line[j]))
|
|
116
124
|
j++;
|
|
117
125
|
const tok = line.slice(i, j);
|
|
126
|
+
flush(); // pending separators (spaces, punctuation) precede the token
|
|
118
127
|
flushKw(tok);
|
|
119
128
|
i = j;
|
|
120
129
|
continue;
|
package/dist/tui/mouse.d.ts
CHANGED
|
@@ -34,6 +34,28 @@ export declare class MouseFilter {
|
|
|
34
34
|
}
|
|
35
35
|
export declare const MOUSE_ENABLE = "\u001B[?1000h\u001B[?1006h";
|
|
36
36
|
export declare const MOUSE_DISABLE = "\u001B[?1000l\u001B[?1006l";
|
|
37
|
+
export declare const PASTE_START = "\u001B[200~";
|
|
38
|
+
export declare const PASTE_END = "\u001B[201~";
|
|
39
|
+
export declare const PASTE_ENABLE = "\u001B[?2004h";
|
|
40
|
+
export declare const PASTE_DISABLE = "\u001B[?2004l";
|
|
41
|
+
/**
|
|
42
|
+
* Bracketed-paste splitter. Strips the `ESC[200~` / `ESC[201~` markers and
|
|
43
|
+
* delivers pasted content as one atomic string so the TUI can bulk-insert
|
|
44
|
+
* it (no per-char autocomplete/history side effects). Content split across
|
|
45
|
+
* stdin chunks is reassembled; an unterminated paste is held (up to 1 MiB,
|
|
46
|
+
* then force-flushed) so a partial escape never leaks into the input.
|
|
47
|
+
*/
|
|
48
|
+
export declare class PasteFilter {
|
|
49
|
+
private pending;
|
|
50
|
+
private inPaste;
|
|
51
|
+
private chunks;
|
|
52
|
+
private size;
|
|
53
|
+
push(chunk: Buffer): {
|
|
54
|
+
kept: Buffer;
|
|
55
|
+
pastes: string[];
|
|
56
|
+
};
|
|
57
|
+
reset(): void;
|
|
58
|
+
}
|
|
37
59
|
/**
|
|
38
60
|
* stdin.read() wrapper implementing the tap (see repl.ts installMouseTap).
|
|
39
61
|
* Ink 7 consumes stdin via paused-mode read() calls, so filtering happens
|
|
@@ -43,4 +65,7 @@ export declare const MOUSE_DISABLE = "\u001B[?1000l\u001B[?1006l";
|
|
|
43
65
|
* mouse sequences are swallowed (wheels dispatched); everything else is
|
|
44
66
|
* returned byte-identical in its original string/Buffer shape.
|
|
45
67
|
*/
|
|
46
|
-
export declare function createReadWrapper(origRead: (size?: number) => unknown, filter: MouseFilter, onWheel: (delta: number) => void
|
|
68
|
+
export declare function createReadWrapper(origRead: (size?: number) => unknown, filter: MouseFilter, onWheel: (delta: number) => void, opts?: {
|
|
69
|
+
onPaste?: (text: string) => void;
|
|
70
|
+
pasteFilter?: PasteFilter;
|
|
71
|
+
}): (size?: number) => unknown;
|
package/dist/tui/mouse.js
CHANGED
|
@@ -92,6 +92,92 @@ export class MouseFilter {
|
|
|
92
92
|
}
|
|
93
93
|
export const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1006h'; // button events + SGR coords
|
|
94
94
|
export const MOUSE_DISABLE = '\x1b[?1000l\x1b[?1006l';
|
|
95
|
+
export const PASTE_START = '\x1b[200~';
|
|
96
|
+
export const PASTE_END = '\x1b[201~';
|
|
97
|
+
export const PASTE_ENABLE = '\x1b[?2004h'; // bracketed paste: terminal wraps pastes
|
|
98
|
+
export const PASTE_DISABLE = '\x1b[?2004l';
|
|
99
|
+
const PASTE_START_BUF = Buffer.from(PASTE_START, 'latin1');
|
|
100
|
+
const PASTE_END_BUF = Buffer.from(PASTE_END, 'latin1');
|
|
101
|
+
const MAX_PASTE_BYTES = 1024 * 1024;
|
|
102
|
+
/**
|
|
103
|
+
* Bracketed-paste splitter. Strips the `ESC[200~` / `ESC[201~` markers and
|
|
104
|
+
* delivers pasted content as one atomic string so the TUI can bulk-insert
|
|
105
|
+
* it (no per-char autocomplete/history side effects). Content split across
|
|
106
|
+
* stdin chunks is reassembled; an unterminated paste is held (up to 1 MiB,
|
|
107
|
+
* then force-flushed) so a partial escape never leaks into the input.
|
|
108
|
+
*/
|
|
109
|
+
export class PasteFilter {
|
|
110
|
+
pending = Buffer.alloc(0);
|
|
111
|
+
inPaste = false;
|
|
112
|
+
chunks = [];
|
|
113
|
+
size = 0;
|
|
114
|
+
push(chunk) {
|
|
115
|
+
const data = Buffer.concat([this.pending, chunk]);
|
|
116
|
+
this.pending = Buffer.alloc(0);
|
|
117
|
+
const kept = [];
|
|
118
|
+
const pastes = [];
|
|
119
|
+
let pos = 0;
|
|
120
|
+
const tailPartial = (from) => {
|
|
121
|
+
// Length of the trailing run that could be a split marker prefix.
|
|
122
|
+
const tail = data.subarray(from);
|
|
123
|
+
for (let len = Math.min(tail.length, 5); len >= 1; len--) {
|
|
124
|
+
const piece = tail.subarray(tail.length - len);
|
|
125
|
+
if (PASTE_START_BUF.subarray(0, len).equals(piece) || PASTE_END_BUF.subarray(0, len).equals(piece))
|
|
126
|
+
return len;
|
|
127
|
+
}
|
|
128
|
+
return 0;
|
|
129
|
+
};
|
|
130
|
+
while (pos < data.length) {
|
|
131
|
+
if (!this.inPaste) {
|
|
132
|
+
const idx = data.indexOf(PASTE_START_BUF, pos);
|
|
133
|
+
if (idx === -1) {
|
|
134
|
+
const hold = tailPartial(pos);
|
|
135
|
+
kept.push(data.subarray(pos, hold > 0 ? data.length - hold : data.length));
|
|
136
|
+
if (hold > 0)
|
|
137
|
+
this.pending = data.subarray(data.length - hold);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
kept.push(data.subarray(pos, idx));
|
|
141
|
+
pos = idx + PASTE_START_BUF.length;
|
|
142
|
+
this.inPaste = true;
|
|
143
|
+
this.chunks = [];
|
|
144
|
+
this.size = 0;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const idx = data.indexOf(PASTE_END_BUF, pos);
|
|
148
|
+
if (idx === -1) {
|
|
149
|
+
const hold = tailPartial(pos);
|
|
150
|
+
const end = hold > 0 ? data.length - hold : data.length;
|
|
151
|
+
if (end > pos) {
|
|
152
|
+
this.chunks.push(data.subarray(pos, end));
|
|
153
|
+
this.size += end - pos;
|
|
154
|
+
}
|
|
155
|
+
if (hold > 0)
|
|
156
|
+
this.pending = data.subarray(data.length - hold);
|
|
157
|
+
if (this.size >= MAX_PASTE_BYTES) {
|
|
158
|
+
pastes.push(Buffer.concat(this.chunks).toString('utf8'));
|
|
159
|
+
this.chunks = [];
|
|
160
|
+
this.size = 0;
|
|
161
|
+
this.inPaste = false; // force-flush: don't hold unbounded input
|
|
162
|
+
}
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
this.chunks.push(data.subarray(pos, idx));
|
|
166
|
+
pastes.push(Buffer.concat(this.chunks).toString('utf8'));
|
|
167
|
+
this.chunks = [];
|
|
168
|
+
this.size = 0;
|
|
169
|
+
this.inPaste = false;
|
|
170
|
+
pos = idx + PASTE_END_BUF.length;
|
|
171
|
+
}
|
|
172
|
+
return { kept: Buffer.concat(kept), pastes };
|
|
173
|
+
}
|
|
174
|
+
reset() {
|
|
175
|
+
this.pending = Buffer.alloc(0);
|
|
176
|
+
this.inPaste = false;
|
|
177
|
+
this.chunks = [];
|
|
178
|
+
this.size = 0;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
95
181
|
/**
|
|
96
182
|
* stdin.read() wrapper implementing the tap (see repl.ts installMouseTap).
|
|
97
183
|
* Ink 7 consumes stdin via paused-mode read() calls, so filtering happens
|
|
@@ -101,23 +187,35 @@ export const MOUSE_DISABLE = '\x1b[?1000l\x1b[?1006l';
|
|
|
101
187
|
* mouse sequences are swallowed (wheels dispatched); everything else is
|
|
102
188
|
* returned byte-identical in its original string/Buffer shape.
|
|
103
189
|
*/
|
|
104
|
-
export function createReadWrapper(origRead, filter, onWheel) {
|
|
190
|
+
export function createReadWrapper(origRead, filter, onWheel, opts) {
|
|
105
191
|
return (size) => {
|
|
106
192
|
if (size !== undefined)
|
|
107
193
|
return origRead(size);
|
|
108
194
|
const chunk = origRead();
|
|
109
195
|
if (chunk == null)
|
|
110
196
|
return chunk;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
197
|
+
let buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
198
|
+
if (opts?.pasteFilter) {
|
|
199
|
+
const split = opts.pasteFilter.push(buf);
|
|
200
|
+
for (const p of split.pastes) {
|
|
201
|
+
try {
|
|
202
|
+
opts.onPaste?.(p);
|
|
203
|
+
}
|
|
204
|
+
catch { /* ignore */ }
|
|
205
|
+
}
|
|
206
|
+
buf = split.kept;
|
|
207
|
+
if (buf.length === 0 && split.pastes.length > 0)
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
const msplit = filter.push(buf);
|
|
211
|
+
for (const w of msplit.wheels) {
|
|
114
212
|
try {
|
|
115
213
|
onWheel(w);
|
|
116
214
|
}
|
|
117
215
|
catch { /* ignore */ }
|
|
118
216
|
}
|
|
119
|
-
if (
|
|
217
|
+
if (msplit.kept.length === 0)
|
|
120
218
|
return null;
|
|
121
|
-
return typeof chunk === 'string' ?
|
|
219
|
+
return typeof chunk === 'string' ? msplit.kept.toString('utf8') : msplit.kept;
|
|
122
220
|
};
|
|
123
221
|
}
|
|
@@ -45,7 +45,9 @@ describe('scroll flow diagnostics', () => {
|
|
|
45
45
|
const chunk = 'STREAMCHUNK lorem ipsum dolor sit amet. ';
|
|
46
46
|
for (let i = 0; i < 12; i++) {
|
|
47
47
|
g.__klyroAppendDelta(`${chunk}#${i} `);
|
|
48
|
-
|
|
48
|
+
// Tick longer than the 64ms streaming render throttle so the flush
|
|
49
|
+
// has landed before asserting visibility (follow-tail, not latency).
|
|
50
|
+
await tick(90);
|
|
49
51
|
const frame = lastFrame() ?? '';
|
|
50
52
|
expect(rowsOf(frame)).toBeLessThanOrEqual(32);
|
|
51
53
|
// latest streamed chunk must be visible (follow-tail)
|
package/dist/tui/tokens.d.ts
CHANGED
|
@@ -12,9 +12,9 @@ export declare const tokens: {
|
|
|
12
12
|
readonly guide: "#3A3A3A";
|
|
13
13
|
readonly accent: "#FF6B1A";
|
|
14
14
|
readonly accentSoft: "#FFF1E6";
|
|
15
|
-
readonly ok: "#
|
|
15
|
+
readonly ok: "#2FA36B";
|
|
16
16
|
readonly err: "#E06C6C";
|
|
17
|
-
readonly warn: "#
|
|
17
|
+
readonly warn: "#D9A021";
|
|
18
18
|
readonly info: "#6FA8DC";
|
|
19
19
|
readonly diffAddBg: "#12250F";
|
|
20
20
|
readonly diffDelBg: "#2A1212";
|
|
@@ -26,15 +26,15 @@ export declare const tokens: {
|
|
|
26
26
|
readonly soft: "whiteBright";
|
|
27
27
|
readonly dim: "gray";
|
|
28
28
|
readonly guide: "gray";
|
|
29
|
-
readonly ok: "
|
|
29
|
+
readonly ok: "green";
|
|
30
30
|
readonly err: "red";
|
|
31
|
-
readonly warn: "
|
|
31
|
+
readonly warn: "yellow";
|
|
32
32
|
readonly info: "blue";
|
|
33
33
|
readonly border: "gray";
|
|
34
34
|
readonly muted: "gray";
|
|
35
|
-
readonly success: "
|
|
35
|
+
readonly success: "green";
|
|
36
36
|
readonly error: "red";
|
|
37
|
-
readonly warning: "
|
|
37
|
+
readonly warning: "yellow";
|
|
38
38
|
};
|
|
39
39
|
};
|
|
40
40
|
export declare const glyphs: {
|
package/dist/tui/tokens.js
CHANGED
|
@@ -12,9 +12,12 @@ export const tokens = {
|
|
|
12
12
|
guide: '#3A3A3A',
|
|
13
13
|
accent: '#FF6B1A',
|
|
14
14
|
accentSoft: '#FFF1E6',
|
|
15
|
-
|
|
15
|
+
// Status hues are deliberately distinct from the orange brand: ok reads
|
|
16
|
+
// green, warn reads amber, err reads red. (Previously ok/warn reused the
|
|
17
|
+
// accent hex and were indistinguishable in the transcript.)
|
|
18
|
+
ok: '#2FA36B',
|
|
16
19
|
err: '#E06C6C',
|
|
17
|
-
warn: '#
|
|
20
|
+
warn: '#D9A021',
|
|
18
21
|
info: '#6FA8DC',
|
|
19
22
|
diffAddBg: '#12250F',
|
|
20
23
|
diffDelBg: '#2A1212',
|
|
@@ -26,15 +29,15 @@ export const tokens = {
|
|
|
26
29
|
soft: 'whiteBright',
|
|
27
30
|
dim: 'gray',
|
|
28
31
|
guide: 'gray',
|
|
29
|
-
ok: '
|
|
32
|
+
ok: 'green',
|
|
30
33
|
err: 'red',
|
|
31
|
-
warn: '
|
|
34
|
+
warn: 'yellow',
|
|
32
35
|
info: 'blue',
|
|
33
36
|
border: 'gray',
|
|
34
37
|
muted: 'gray',
|
|
35
|
-
success: '
|
|
38
|
+
success: 'green',
|
|
36
39
|
error: 'red',
|
|
37
|
-
warning: '
|
|
40
|
+
warning: 'yellow',
|
|
38
41
|
},
|
|
39
42
|
};
|
|
40
43
|
export const glyphs = {
|