agent-sh 0.15.0 → 0.15.2

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.
Files changed (124) hide show
  1. package/dist/agent/agent-loop.js +11 -8
  2. package/dist/agent/events.d.ts +4 -0
  3. package/docs/README.md +14 -0
  4. package/docs/agent.md +398 -0
  5. package/docs/architecture.md +196 -0
  6. package/docs/context-management.md +200 -0
  7. package/docs/extensions.md +951 -0
  8. package/docs/library.md +84 -0
  9. package/docs/troubleshooting.md +65 -0
  10. package/docs/tui-composition.md +294 -0
  11. package/docs/usage.md +306 -0
  12. package/examples/extensions/ash-scheme/package.json +1 -1
  13. package/examples/extensions/ashi/EXTENDING.md +2 -2
  14. package/examples/extensions/ashi/README.md +2 -2
  15. package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
  16. package/examples/extensions/ashi/package.json +5 -3
  17. package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
  18. package/examples/extensions/ashi/src/cli.ts +9 -8
  19. package/examples/extensions/ashi/src/dialogs.ts +16 -1
  20. package/examples/extensions/ashi/src/events.ts +1 -0
  21. package/examples/extensions/ashi/src/frontend.ts +26 -6
  22. package/examples/extensions/ashi/src/renderer.ts +24 -4
  23. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
  24. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
  25. package/examples/extensions/ashi/src/ui.ts +11 -0
  26. package/examples/extensions/ashi-ink/package.json +2 -2
  27. package/examples/extensions/claude-code-bridge/package.json +1 -1
  28. package/examples/extensions/opencode-bridge/package.json +1 -1
  29. package/package.json +3 -1
  30. package/src/agent/agent-loop.ts +1566 -0
  31. package/src/agent/entry-format.ts +19 -0
  32. package/src/agent/events.ts +153 -0
  33. package/src/agent/extensions/rolling-history/constants.ts +1 -0
  34. package/src/agent/extensions/rolling-history/index.ts +202 -0
  35. package/src/agent/extensions/rolling-history/recall.ts +131 -0
  36. package/src/agent/extensions/rolling-history/strategy.ts +404 -0
  37. package/src/agent/host-types.ts +192 -0
  38. package/src/agent/index.ts +591 -0
  39. package/src/agent/live-view.ts +279 -0
  40. package/src/agent/llm-client.ts +111 -0
  41. package/src/agent/llm-facade.ts +43 -0
  42. package/src/agent/normalize-args.ts +61 -0
  43. package/src/agent/nuclear-form.ts +382 -0
  44. package/src/agent/providers/deepseek.ts +39 -0
  45. package/src/agent/providers/ollama.ts +92 -0
  46. package/src/agent/providers/openai-compatible.ts +36 -0
  47. package/src/agent/providers/openai.ts +52 -0
  48. package/src/agent/providers/opencode.ts +142 -0
  49. package/src/agent/providers/openrouter.ts +105 -0
  50. package/src/agent/providers/zai-coding-plan.ts +33 -0
  51. package/src/agent/session-store.ts +336 -0
  52. package/src/agent/skills.ts +228 -0
  53. package/src/agent/store.ts +310 -0
  54. package/src/agent/subagent.ts +305 -0
  55. package/src/agent/system-prompt.ts +151 -0
  56. package/src/agent/token-budget.ts +12 -0
  57. package/src/agent/tool-protocol.ts +722 -0
  58. package/src/agent/tool-registry.ts +66 -0
  59. package/src/agent/tools/bash.ts +95 -0
  60. package/src/agent/tools/edit-file.ts +154 -0
  61. package/src/agent/tools/expand-home.ts +7 -0
  62. package/src/agent/tools/glob.ts +108 -0
  63. package/src/agent/tools/grep.ts +228 -0
  64. package/src/agent/tools/list-skills.ts +37 -0
  65. package/src/agent/tools/ls.ts +81 -0
  66. package/src/agent/tools/pwsh.ts +140 -0
  67. package/src/agent/tools/read-file.ts +164 -0
  68. package/src/agent/tools/write-file.ts +72 -0
  69. package/src/agent/types.ts +149 -0
  70. package/src/cli/args.ts +91 -0
  71. package/src/cli/auth/cli.ts +244 -0
  72. package/src/cli/auth/discover.ts +52 -0
  73. package/src/cli/auth/keys.ts +143 -0
  74. package/src/cli/index.ts +295 -0
  75. package/src/cli/init.ts +74 -0
  76. package/src/cli/install.ts +439 -0
  77. package/src/cli/shell-env.ts +68 -0
  78. package/src/cli/subcommands.ts +24 -0
  79. package/src/core/event-bus.ts +252 -0
  80. package/src/core/extension-loader.ts +347 -0
  81. package/src/core/index.ts +152 -0
  82. package/src/core/settings.ts +398 -0
  83. package/src/core/types.ts +61 -0
  84. package/src/extensions/file-autocomplete.ts +71 -0
  85. package/src/extensions/index.ts +38 -0
  86. package/src/extensions/slash-commands/events.ts +14 -0
  87. package/src/extensions/slash-commands/index.ts +269 -0
  88. package/src/shell/events.ts +73 -0
  89. package/src/shell/host-types.ts +150 -0
  90. package/src/shell/index.ts +159 -0
  91. package/src/shell/input-handler.ts +505 -0
  92. package/src/shell/output-parser.ts +156 -0
  93. package/src/shell/shell-context.ts +193 -0
  94. package/src/shell/shell.ts +414 -0
  95. package/src/shell/strategies/bash.ts +83 -0
  96. package/src/shell/strategies/fish.ts +77 -0
  97. package/src/shell/strategies/index.ts +24 -0
  98. package/src/shell/strategies/types.ts +64 -0
  99. package/src/shell/strategies/zsh.ts +92 -0
  100. package/src/shell/terminal.ts +124 -0
  101. package/src/shell/tui-input-view.ts +222 -0
  102. package/src/shell/tui-renderer.ts +1126 -0
  103. package/src/utils/ansi.ts +140 -0
  104. package/src/utils/box-frame.ts +138 -0
  105. package/src/utils/compositor.ts +157 -0
  106. package/src/utils/diff-renderer.ts +829 -0
  107. package/src/utils/diff.ts +244 -0
  108. package/src/utils/executor.ts +305 -0
  109. package/src/utils/file-watcher.ts +110 -0
  110. package/src/utils/floating-panel.ts +1160 -0
  111. package/src/utils/handler-registry.ts +110 -0
  112. package/src/utils/line-editor.ts +636 -0
  113. package/src/utils/markdown.ts +437 -0
  114. package/src/utils/message-utils.ts +113 -0
  115. package/src/utils/package-version.ts +12 -0
  116. package/src/utils/palette.ts +64 -0
  117. package/src/utils/ref-counter.ts +9 -0
  118. package/src/utils/ripgrep-path.ts +17 -0
  119. package/src/utils/shell-output-spill.ts +76 -0
  120. package/src/utils/stream-transform.ts +292 -0
  121. package/src/utils/terminal-buffer.ts +213 -0
  122. package/src/utils/tool-display.ts +315 -0
  123. package/src/utils/tool-interactive.ts +71 -0
  124. package/src/utils/tty.ts +14 -0
@@ -0,0 +1,636 @@
1
+ /**
2
+ * Minimal line editor with readline-style keybindings.
3
+ *
4
+ * Pure logic — no I/O, no rendering, no event bus. Consumers feed raw
5
+ * terminal input bytes and receive high-level actions back.
6
+ *
7
+ * The internal buffer may contain PUA placeholder characters for pasted
8
+ * multi-line content. Consumers should use the typed accessors:
9
+ * - `text` — resolved content (pastes expanded), for submit/history/logic
10
+ * - `displayText` — display content (pastes collapsed to labels), for rendering
11
+ * - `displayCursor` — cursor column in display coordinates
12
+ * - `setText()` — replace buffer content (clears paste attachments)
13
+ */
14
+
15
+ import { charWidth } from "./ansi.js";
16
+
17
+ // ── Kitty protocol keycode → readable name ──────────────────────
18
+
19
+ const KITTY_KEY_NAMES: Record<number, string> = {
20
+ 9: "tab", 13: "enter", 27: "escape", 127: "backspace",
21
+ };
22
+
23
+ // ── Paste placeholder ───────────────────────────────────────────
24
+
25
+ /** First Unicode Private Use Area codepoint, used as paste placeholder. */
26
+ const PUA_BASE = 0xE000;
27
+
28
+ const PASTE_END = "\x1b[201~";
29
+
30
+ function isPUA(ch: string): boolean {
31
+ const code = ch.charCodeAt(0);
32
+ return code >= PUA_BASE && code <= 0xF8FF;
33
+ }
34
+
35
+ // ── Action types returned by feed() ─────────────────────────────
36
+
37
+ export type LineEditAction =
38
+ | { action: "changed" }
39
+ | { action: "submit"; buffer: string }
40
+ | { action: "cancel" }
41
+ | { action: "delete-empty" }
42
+ | { action: "tab" }
43
+ | { action: "shift+tab" }
44
+ | { action: "arrow-up" }
45
+ | { action: "arrow-down" };
46
+
47
+ // ── Line editor ─────────────────────────────────────────────────
48
+
49
+ export class LineEditor {
50
+ private _buf = "";
51
+ cursor = 0;
52
+ private pendingSeq = ""; // buffered incomplete escape sequence
53
+
54
+ // ── Bracket paste state ─────────────────────────────────────
55
+ private inPaste = false;
56
+ private pasteAccum = ""; // accumulates during bracket paste
57
+ private pastes = new Map<number, string>(); // id → pasted content
58
+ private pasteCounter = 0;
59
+
60
+ // ── History ──────────────────────────────────────────────────
61
+ private history: string[] = [];
62
+ private historyIndex = -1; // -1 = current input, 0..N = history entries (newest first)
63
+ private savedBuffer = ""; // saves current input when browsing history
64
+
65
+ // ── Public accessors ────────────────────────────────────────
66
+
67
+ /** Resolved text — paste placeholders expanded. For submit, history, logic. */
68
+ get text(): string {
69
+ let result = "";
70
+ for (const ch of this._buf) {
71
+ const paste = this.pastes.get(ch.charCodeAt(0) - PUA_BASE);
72
+ result += paste ?? ch;
73
+ }
74
+ return result;
75
+ }
76
+
77
+ /** Display text — paste placeholders replaced with labels. For rendering. */
78
+ get displayText(): string {
79
+ let result = "";
80
+ for (const ch of this._buf) {
81
+ const paste = this.pastes.get(ch.charCodeAt(0) - PUA_BASE);
82
+ if (paste) {
83
+ const n = paste.split("\n").length;
84
+ result += `[paste +${n} lines]`;
85
+ } else {
86
+ result += ch;
87
+ }
88
+ }
89
+ return result;
90
+ }
91
+
92
+ /** Cursor position mapped to display-text character offset. */
93
+ get displayCursor(): number {
94
+ let pos = 0;
95
+ for (let i = 0; i < this._buf.length && i < this.cursor; i++) {
96
+ const ch = this._buf[i]!;
97
+ const paste = this.pastes.get(ch.charCodeAt(0) - PUA_BASE);
98
+ if (paste) {
99
+ const n = paste.split("\n").length;
100
+ pos += `[paste +${n} lines]`.length;
101
+ } else {
102
+ pos++;
103
+ }
104
+ }
105
+ return pos;
106
+ }
107
+
108
+ /** Cursor position as visible terminal-column width (accounts for CJK etc.). */
109
+ get displayCursorWidth(): number {
110
+ let width = 0;
111
+ for (let i = 0; i < this._buf.length && i < this.cursor; i++) {
112
+ const ch = this._buf[i]!;
113
+ const paste = this.pastes.get(ch.charCodeAt(0) - PUA_BASE);
114
+ if (paste) {
115
+ const n = paste.split("\n").length;
116
+ width += `[paste +${n} lines]`.length; // ASCII-only, 1 col each
117
+ } else {
118
+ width += charWidth(ch.codePointAt(0) ?? 0);
119
+ }
120
+ }
121
+ return width;
122
+ }
123
+
124
+ /** Number of logical positions in the buffer. */
125
+ get length(): number {
126
+ return this._buf.length;
127
+ }
128
+
129
+ /** Replace buffer content. Clears paste attachments. */
130
+ setText(value: string): void {
131
+ this._buf = value;
132
+ this.pastes.clear();
133
+ this.pasteCounter = 0;
134
+ this.cursor = value.length;
135
+ }
136
+
137
+ // ── Input processing ────────────────────────────────────────
138
+
139
+ /** Process raw terminal input, return actions for the consumer. */
140
+ feed(data: string): LineEditAction[] {
141
+ // If we had a pending incomplete escape sequence, prepend it
142
+ if (this.pendingSeq) {
143
+ data = this.pendingSeq + data;
144
+ this.pendingSeq = "";
145
+ }
146
+
147
+ const actions: LineEditAction[] = [];
148
+ let i = 0;
149
+
150
+ // Heuristic for terminals that don't advertise bracketed paste (or where
151
+ // it's stripped by tmux/ssh): a multi-byte chunk with line breaks is a
152
+ // paste, since typed input arrives one keystroke per chunk in raw mode.
153
+ if (!this.inPaste && data.length > 1 && /[\r\n]/.test(data)
154
+ && data.indexOf("\x1b[200~") === -1) {
155
+ this.pasteAccum = data.replace(/\r\n?/g, "\n");
156
+ actions.push(...this.commitPaste());
157
+ return actions;
158
+ }
159
+
160
+ while (i < data.length) {
161
+ if (this.inPaste) {
162
+ const endIdx = this.consumePasteChunk(data.slice(i));
163
+ if (endIdx === -1) return actions;
164
+ i += endIdx + PASTE_END.length;
165
+ actions.push(...this.commitPaste());
166
+ continue;
167
+ }
168
+
169
+ const ch = data[i]!;
170
+
171
+ // ── Escape sequences ────────────────────────────────
172
+ if (ch === "\x1b") {
173
+ const next = data[i + 1];
174
+
175
+ // Incomplete escape — buffer and wait for next feed()
176
+ if (next == null) {
177
+ this.pendingSeq = "\x1b";
178
+ i++;
179
+ continue;
180
+ }
181
+
182
+ // CSI sequence: \x1b[...
183
+ if (next === "[") {
184
+ const { consumed, incomplete } = this.handleCSI(data, i, actions);
185
+ if (incomplete) {
186
+ this.pendingSeq = data.slice(i, i + consumed);
187
+ i += consumed;
188
+ } else {
189
+ i += consumed;
190
+ }
191
+ continue;
192
+ }
193
+
194
+ // SS3 sequence: \x1bO... (application cursor mode — arrow keys, Home, End)
195
+ if (next === "O") {
196
+ const ss3Final = data[i + 2];
197
+ if (ss3Final == null) {
198
+ // Incomplete — buffer for next feed()
199
+ this.pendingSeq = data.slice(i, i + 2);
200
+ i += 2;
201
+ continue;
202
+ }
203
+ i += 3; // consume \x1b O <final>
204
+ switch (ss3Final) {
205
+ case "A": actions.push({ action: "arrow-up" }); break;
206
+ case "B": actions.push({ action: "arrow-down" }); break;
207
+ case "C":
208
+ if (this.cursor < this._buf.length) { this.cursor++; actions.push({ action: "changed" }); }
209
+ break;
210
+ case "D":
211
+ if (this.cursor > 0) { this.cursor--; actions.push({ action: "changed" }); }
212
+ break;
213
+ case "H": // Home
214
+ if (this.cursor > 0) { this.cursor = 0; actions.push({ action: "changed" }); }
215
+ break;
216
+ case "F": // End
217
+ if (this.cursor < this._buf.length) { this.cursor = this._buf.length; actions.push({ action: "changed" }); }
218
+ break;
219
+ }
220
+ continue;
221
+ }
222
+
223
+ // Alt/Option + key: \x1b followed by char
224
+ i += 2; // consume \x1b + next byte
225
+ if (next === "\x7f") {
226
+ // Option+Backspace: delete word backward
227
+ if (this.deleteWordBackward()) actions.push({ action: "changed" });
228
+ } else if (next === "b") {
229
+ // Alt+B: word backward
230
+ if (this.wordBackward()) actions.push({ action: "changed" });
231
+ } else if (next === "f") {
232
+ // Alt+F: word forward
233
+ if (this.wordForward()) actions.push({ action: "changed" });
234
+ } else if (next === "d") {
235
+ // Alt+D: delete word forward
236
+ if (this.deleteWordForward()) actions.push({ action: "changed" });
237
+ }
238
+ // Other Alt+key — ignore
239
+ continue;
240
+ }
241
+
242
+ // ── Control characters ──────────────────────────────
243
+ if (ch.charCodeAt(0) < 0x20 || ch === "\x7f") {
244
+ const action = this.handleControl(ch);
245
+ if (action) actions.push(action);
246
+ i++;
247
+ continue;
248
+ }
249
+
250
+ // ── Printable character ─────────────────────────────
251
+ this._buf = this._buf.slice(0, this.cursor) + ch + this._buf.slice(this.cursor);
252
+ this.cursor++;
253
+ actions.push({ action: "changed" });
254
+ i++;
255
+ }
256
+
257
+ return actions;
258
+ }
259
+
260
+ /** Accumulate `data` into pasteAccum until PASTE_END appears.
261
+ * Returns the marker index, or -1 if not yet seen (a partial-suffix
262
+ * match is stashed in pendingSeq so the next feed() can complete it). */
263
+ private consumePasteChunk(data: string): number {
264
+ const endIdx = data.indexOf(PASTE_END);
265
+ if (endIdx !== -1) {
266
+ this.pasteAccum += data.slice(0, endIdx).replace(/\r/g, "");
267
+ return endIdx;
268
+ }
269
+ let suffixLen = 0;
270
+ for (let p = Math.min(PASTE_END.length - 1, data.length); p > 0; p--) {
271
+ if (data.endsWith(PASTE_END.slice(0, p))) { suffixLen = p; break; }
272
+ }
273
+ const safeEnd = data.length - suffixLen;
274
+ this.pasteAccum += data.slice(0, safeEnd).replace(/\r/g, "");
275
+ if (suffixLen > 0) this.pendingSeq = data.slice(safeEnd);
276
+ return -1;
277
+ }
278
+
279
+ private commitPaste(): LineEditAction[] {
280
+ this.inPaste = false;
281
+ const accum = this.pasteAccum;
282
+ this.pasteAccum = "";
283
+ if (!accum) return [];
284
+ if (accum.indexOf("\n") === -1) {
285
+ this._buf = this._buf.slice(0, this.cursor) + accum + this._buf.slice(this.cursor);
286
+ this.cursor += accum.length;
287
+ } else {
288
+ const id = this.pasteCounter++;
289
+ this.pastes.set(id, accum);
290
+ const placeholder = String.fromCharCode(PUA_BASE + id);
291
+ this._buf = this._buf.slice(0, this.cursor) + placeholder + this._buf.slice(this.cursor);
292
+ this.cursor++;
293
+ }
294
+ return [{ action: "changed" }];
295
+ }
296
+
297
+ /** Check if there's a pending incomplete escape sequence. */
298
+ hasPendingEscape(): boolean {
299
+ return this.pendingSeq.length > 0;
300
+ }
301
+
302
+ /** Flush a pending sequence — treat bare \x1b as cancel, discard incomplete CSI. */
303
+ flushPendingEscape(): LineEditAction[] {
304
+ if (!this.pendingSeq) return [];
305
+ const wasBarEscape = this.pendingSeq === "\x1b";
306
+ this.pendingSeq = "";
307
+ return wasBarEscape ? [{ action: "cancel" }] : [];
308
+ }
309
+
310
+ clear(): void {
311
+ this._buf = "";
312
+ this.cursor = 0;
313
+ this.pendingSeq = "";
314
+ this.inPaste = false;
315
+ this.pasteAccum = "";
316
+ this.pastes.clear();
317
+ this.pasteCounter = 0;
318
+ this.historyIndex = -1;
319
+ this.savedBuffer = "";
320
+ }
321
+
322
+ /** Add a line to history (most recent first). */
323
+ pushHistory(line: string): void {
324
+ if (!line.trim()) return;
325
+ if (this.history.length > 0 && this.history[0] === line) return;
326
+ this.history.unshift(line);
327
+ if (this.history.length > 100) this.history.pop();
328
+ }
329
+
330
+ /** Navigate to a previous history entry. Returns changed action or null. */
331
+ historyBack(): LineEditAction | null {
332
+ if (this.historyIndex + 1 >= this.history.length) return null;
333
+ if (this.historyIndex === -1) {
334
+ this.savedBuffer = this.text; // save resolved current input
335
+ }
336
+ this.historyIndex++;
337
+ this.setText(this.history[this.historyIndex]!);
338
+ return { action: "changed" };
339
+ }
340
+
341
+ /** Navigate to a more recent history entry. Returns changed action or null. */
342
+ historyForward(): LineEditAction | null {
343
+ if (this.historyIndex <= -1) return null;
344
+ this.historyIndex--;
345
+ if (this.historyIndex === -1) {
346
+ this.setText(this.savedBuffer);
347
+ } else {
348
+ this.setText(this.history[this.historyIndex]!);
349
+ }
350
+ return { action: "changed" };
351
+ }
352
+
353
+ // ── Key bindings ────────────────────────────────────────────
354
+ //
355
+ // Single source of truth for all keybindings. Both legacy control
356
+ // characters and kitty protocol sequences resolve to a key name
357
+ // and look it up here. To add a binding, add one entry.
358
+
359
+ private readonly bindings: Record<string, () => LineEditAction | null> = {
360
+ "enter": () => ({ action: "submit", buffer: this.text }),
361
+ "ctrl+c": () => ({ action: "cancel" }),
362
+ "tab": () => ({ action: "tab" }),
363
+ "backspace": () => this.deleteBackward(),
364
+ "ctrl+d": () => this._buf.length === 0 ? { action: "delete-empty" } : this.deleteForward(),
365
+ "ctrl+a": () => this.moveToLineStart(),
366
+ "ctrl+e": () => this.moveToLineEnd(),
367
+ "ctrl+b": () => this.moveTo(this.cursor - 1),
368
+ "ctrl+f": () => this.moveTo(this.cursor + 1),
369
+ "ctrl+u": () => this.deleteLineStart(),
370
+ "ctrl+k": () => this.deleteLineEnd(),
371
+ "ctrl+w": () => this.deleteWordBackward() ? { action: "changed" } : null,
372
+ "alt+f": () => this.wordForward() ? { action: "changed" } : null,
373
+ "alt+b": () => this.wordBackward() ? { action: "changed" } : null,
374
+ "alt+d": () => this.deleteWordForward() ? { action: "changed" } : null,
375
+ "alt+backspace": () => this.deleteWordBackward() ? { action: "changed" } : null,
376
+ "shift+enter": () => this.insertAt("\n"),
377
+ "shift+tab": () => ({ action: "shift+tab" as const }),
378
+ };
379
+
380
+ /** Resolve a key name from the bindings table and execute it. */
381
+ private dispatch(key: string): LineEditAction | null {
382
+ return this.bindings[key]?.() ?? null;
383
+ }
384
+
385
+ // ── Legacy control character mapping ───────────────────────
386
+
387
+ /** Map a legacy control character to a key name. */
388
+ private static readonly CTRL_MAP: Record<string, string> = {
389
+ "\r": "enter", "\x03": "ctrl+c", "\t": "tab",
390
+ "\x7f": "backspace", "\b": "backspace",
391
+ "\x01": "ctrl+a", "\x02": "ctrl+b", "\x04": "ctrl+d",
392
+ "\x05": "ctrl+e", "\x06": "ctrl+f", "\x0b": "ctrl+k",
393
+ "\x15": "ctrl+u", "\x17": "ctrl+w",
394
+ };
395
+
396
+ private handleControl(ch: string): LineEditAction | null {
397
+ const key = LineEditor.CTRL_MAP[ch];
398
+ return key ? this.dispatch(key) : null;
399
+ }
400
+
401
+ // ── Kitty keyboard protocol ────────────────────────────────
402
+
403
+ /** Handle a kitty protocol CSI u sequence. Params format: "keycode;modifier". */
404
+ private handleKittyKey(params: string): LineEditAction | null {
405
+ const [kc, mod] = params.split(";").map(Number);
406
+ const keycode = kc!;
407
+ const mods = (mod ?? 1) - 1; // kitty modifier bits
408
+
409
+ // Build key name from modifier + keycode
410
+ const modNames: string[] = [];
411
+ if (mods & 4) modNames.push("ctrl");
412
+ if (mods & 1) modNames.push("shift");
413
+ if (mods & 2) modNames.push("alt");
414
+
415
+ const keyName = KITTY_KEY_NAMES[keycode] ?? String.fromCharCode(keycode);
416
+ const fullName = [...modNames, keyName].join("+");
417
+
418
+ // Try exact binding first, then fall back to ctrl char mapping
419
+ return this.dispatch(fullName)
420
+ ?? ((mods & 4) && keycode >= 97 && keycode <= 122
421
+ ? this.dispatch(`ctrl+${String.fromCharCode(keycode)}`)
422
+ : null)
423
+ ?? (mods === 0 ? this.handleControl(String.fromCharCode(keycode)) : null);
424
+ }
425
+
426
+ // ── Editing primitives ─────────────────────────────────────
427
+
428
+ private insertAt(ch: string): LineEditAction {
429
+ this._buf = this._buf.slice(0, this.cursor) + ch + this._buf.slice(this.cursor);
430
+ this.cursor++;
431
+ return { action: "changed" };
432
+ }
433
+
434
+ private moveTo(pos: number): LineEditAction | null {
435
+ const clamped = Math.max(0, Math.min(pos, this._buf.length));
436
+ if (clamped === this.cursor) return null;
437
+ this.cursor = clamped;
438
+ return { action: "changed" };
439
+ }
440
+
441
+ /** Move cursor to start of the current logical line. */
442
+ private moveToLineStart(): LineEditAction | null {
443
+ const lineStart = this._buf.lastIndexOf("\n", this.cursor - 1) + 1;
444
+ return this.moveTo(lineStart);
445
+ }
446
+
447
+ /** Move cursor to end of the current logical line. */
448
+ private moveToLineEnd(): LineEditAction | null {
449
+ const nextNewline = this._buf.indexOf("\n", this.cursor);
450
+ const lineEnd = nextNewline === -1 ? this._buf.length : nextNewline;
451
+ return this.moveTo(lineEnd);
452
+ }
453
+
454
+ /** Delete from start of current logical line to cursor (Ctrl+U). */
455
+ private deleteLineStart(): LineEditAction | null {
456
+ const lineStart = this._buf.lastIndexOf("\n", this.cursor - 1) + 1;
457
+ return this.deleteRange(lineStart, this.cursor);
458
+ }
459
+
460
+ /** Delete from cursor to end of current logical line (Ctrl+K). */
461
+ private deleteLineEnd(): LineEditAction | null {
462
+ const nextNewline = this._buf.indexOf("\n", this.cursor);
463
+ const lineEnd = nextNewline === -1 ? this._buf.length : nextNewline;
464
+ return this.deleteRange(this.cursor, lineEnd);
465
+ }
466
+
467
+ private deleteBackward(): LineEditAction | null {
468
+ if (this._buf.length === 0) return { action: "delete-empty" };
469
+ if (this.cursor <= 0) return null;
470
+ // If deleting a paste placeholder, also remove the paste entry
471
+ const deleted = this._buf[this.cursor - 1]!;
472
+ if (isPUA(deleted)) {
473
+ this.pastes.delete(deleted.charCodeAt(0) - PUA_BASE);
474
+ }
475
+ this._buf = this._buf.slice(0, this.cursor - 1) + this._buf.slice(this.cursor);
476
+ this.cursor--;
477
+ return { action: "changed" };
478
+ }
479
+
480
+ private deleteForward(): LineEditAction | null {
481
+ if (this.cursor >= this._buf.length) return null;
482
+ const deleted = this._buf[this.cursor]!;
483
+ if (isPUA(deleted)) {
484
+ this.pastes.delete(deleted.charCodeAt(0) - PUA_BASE);
485
+ }
486
+ this._buf = this._buf.slice(0, this.cursor) + this._buf.slice(this.cursor + 1);
487
+ return { action: "changed" };
488
+ }
489
+
490
+ private deleteRange(start: number, end: number): LineEditAction | null {
491
+ if (start >= end) return null;
492
+ // Clean up any paste entries in the deleted range
493
+ for (let k = start; k < end; k++) {
494
+ const ch = this._buf[k]!;
495
+ if (isPUA(ch)) this.pastes.delete(ch.charCodeAt(0) - PUA_BASE);
496
+ }
497
+ this._buf = this._buf.slice(0, start) + this._buf.slice(end);
498
+ this.cursor = start;
499
+ return { action: "changed" };
500
+ }
501
+
502
+ // ── CSI sequence handling ───────────────────────────────────
503
+
504
+ /**
505
+ * Parse and handle a CSI sequence (\x1b[...) starting at `start`.
506
+ * Returns the number of bytes consumed and whether the sequence was incomplete.
507
+ */
508
+ private handleCSI(
509
+ data: string,
510
+ start: number,
511
+ actions: LineEditAction[],
512
+ ): { consumed: number; incomplete?: boolean } {
513
+ // Skip \x1b[
514
+ let j = start + 2;
515
+ // Accumulate parameter bytes (0x20-0x3F: digits, semicolons, etc.)
516
+ let params = "";
517
+ while (j < data.length && data.charCodeAt(j) >= 0x20 && data.charCodeAt(j) < 0x40) {
518
+ params += data[j];
519
+ j++;
520
+ }
521
+ // If we ran out of data before the final byte, sequence is incomplete
522
+ if (j >= data.length) {
523
+ return { consumed: j - start, incomplete: true };
524
+ }
525
+ const final = data[j]!;
526
+ const consumed = j - start + 1;
527
+
528
+ // Dispatch on final byte
529
+ switch (final) {
530
+ case "A": // Up arrow
531
+ actions.push({ action: "arrow-up" });
532
+ break;
533
+ case "B": // Down arrow
534
+ actions.push({ action: "arrow-down" });
535
+ break;
536
+ case "C": // Right (or modified right: 1;3C, 1;5C = word right)
537
+ if (params.includes(";")) {
538
+ if (this.wordForward()) actions.push({ action: "changed" });
539
+ } else {
540
+ if (this.cursor < this._buf.length) { this.cursor++; actions.push({ action: "changed" }); }
541
+ }
542
+ break;
543
+ case "D": // Left (or modified left: 1;3D, 1;5D = word left)
544
+ if (params.includes(";")) {
545
+ if (this.wordBackward()) actions.push({ action: "changed" });
546
+ } else {
547
+ if (this.cursor > 0) { this.cursor--; actions.push({ action: "changed" }); }
548
+ }
549
+ break;
550
+ case "H": // Home
551
+ if (this.cursor > 0) { this.cursor = 0; actions.push({ action: "changed" }); }
552
+ break;
553
+ case "F": // End
554
+ if (this.cursor < this._buf.length) { this.cursor = this._buf.length; actions.push({ action: "changed" }); }
555
+ break;
556
+ case "Z": // Shift+Tab (legacy CSI sequence)
557
+ actions.push({ action: "shift+tab" });
558
+ break;
559
+ case "u": { // Kitty keyboard protocol: \x1b[<keycode>;<modifier>u
560
+ const action = this.handleKittyKey(params);
561
+ if (action) actions.push(action);
562
+ break;
563
+ }
564
+ case "~": // Extended keys: Delete (3~), bracket paste (200~/201~), etc.
565
+ if (params === "3") {
566
+ // Delete key: delete char under cursor
567
+ if (this.cursor < this._buf.length) {
568
+ const deleted = this._buf[this.cursor]!;
569
+ if (isPUA(deleted)) this.pastes.delete(deleted.charCodeAt(0) - PUA_BASE);
570
+ this._buf = this._buf.slice(0, this.cursor) + this._buf.slice(this.cursor + 1);
571
+ actions.push({ action: "changed" });
572
+ }
573
+ } else if (params === "200") {
574
+ this.inPaste = true;
575
+ this.pasteAccum = "";
576
+ }
577
+ break;
578
+ // All other CSI sequences — silently ignored
579
+ }
580
+
581
+ return { consumed };
582
+ }
583
+
584
+ // ── Word movement / deletion helpers ────────────────────────
585
+
586
+ private wordBackward(): boolean {
587
+ if (this.cursor === 0) return false;
588
+ let pos = this.cursor;
589
+ // Skip PUA placeholders and spaces
590
+ while (pos > 0 && (this._buf[pos - 1] === " " || isPUA(this._buf[pos - 1]!))) pos--;
591
+ // Skip word chars
592
+ while (pos > 0 && this._buf[pos - 1] !== " " && !isPUA(this._buf[pos - 1]!)) pos--;
593
+ if (pos === this.cursor) return false;
594
+ this.cursor = pos;
595
+ return true;
596
+ }
597
+
598
+ private wordForward(): boolean {
599
+ if (this.cursor >= this._buf.length) return false;
600
+ let pos = this.cursor;
601
+ // Skip word chars and PUA placeholders
602
+ while (pos < this._buf.length && this._buf[pos] !== " " && !isPUA(this._buf[pos]!)) pos++;
603
+ // Skip spaces and PUA
604
+ while (pos < this._buf.length && (this._buf[pos] === " " || isPUA(this._buf[pos]!))) pos++;
605
+ if (pos === this.cursor) return false;
606
+ this.cursor = pos;
607
+ return true;
608
+ }
609
+
610
+ private deleteWordBackward(): boolean {
611
+ if (this.cursor === 0) return false;
612
+ const start = this.cursor;
613
+ this.wordBackward();
614
+ // Clean up paste entries
615
+ for (let k = this.cursor; k < start; k++) {
616
+ const ch = this._buf[k]!;
617
+ if (isPUA(ch)) this.pastes.delete(ch.charCodeAt(0) - PUA_BASE);
618
+ }
619
+ this._buf = this._buf.slice(0, this.cursor) + this._buf.slice(start);
620
+ return true;
621
+ }
622
+
623
+ private deleteWordForward(): boolean {
624
+ if (this.cursor >= this._buf.length) return false;
625
+ const start = this.cursor;
626
+ this.wordForward();
627
+ // Clean up paste entries
628
+ for (let k = start; k < this.cursor; k++) {
629
+ const ch = this._buf[k]!;
630
+ if (isPUA(ch)) this.pastes.delete(ch.charCodeAt(0) - PUA_BASE);
631
+ }
632
+ this._buf = this._buf.slice(0, start) + this._buf.slice(this.cursor);
633
+ this.cursor = start;
634
+ return true;
635
+ }
636
+ }