agent-sh 0.1.0 → 0.3.0

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.
@@ -0,0 +1,381 @@
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. Buffer and
6
+ * cursor state are public for rendering.
7
+ */
8
+ // ── Kitty protocol keycode → readable name ──────────────────────
9
+ const KITTY_KEY_NAMES = {
10
+ 9: "tab", 13: "enter", 27: "escape", 127: "backspace",
11
+ };
12
+ // ── Line editor ─────────────────────────────────────────────────
13
+ export class LineEditor {
14
+ buffer = "";
15
+ cursor = 0;
16
+ pendingSeq = ""; // buffered incomplete escape sequence
17
+ /** Process raw terminal input, return actions for the consumer. */
18
+ feed(data) {
19
+ // If we had a pending incomplete escape sequence, prepend it
20
+ if (this.pendingSeq) {
21
+ data = this.pendingSeq + data;
22
+ this.pendingSeq = "";
23
+ }
24
+ const actions = [];
25
+ let i = 0;
26
+ while (i < data.length) {
27
+ const ch = data[i];
28
+ // ── Escape sequences ────────────────────────────────
29
+ if (ch === "\x1b") {
30
+ const next = data[i + 1];
31
+ // Incomplete escape — buffer and wait for next feed()
32
+ if (next == null) {
33
+ this.pendingSeq = "\x1b";
34
+ i++;
35
+ continue;
36
+ }
37
+ // CSI sequence: \x1b[...
38
+ if (next === "[") {
39
+ const { consumed, incomplete } = this.handleCSI(data, i, actions);
40
+ if (incomplete) {
41
+ this.pendingSeq = data.slice(i, i + consumed);
42
+ i += consumed;
43
+ }
44
+ else {
45
+ i += consumed;
46
+ }
47
+ continue;
48
+ }
49
+ // SS3 sequence: \x1bO... (application cursor mode — arrow keys, Home, End)
50
+ if (next === "O") {
51
+ const ss3Final = data[i + 2];
52
+ if (ss3Final == null) {
53
+ // Incomplete — buffer for next feed()
54
+ this.pendingSeq = data.slice(i, i + 2);
55
+ i += 2;
56
+ continue;
57
+ }
58
+ i += 3; // consume \x1b O <final>
59
+ switch (ss3Final) {
60
+ case "A":
61
+ actions.push({ action: "arrow-up" });
62
+ break;
63
+ case "B":
64
+ actions.push({ action: "arrow-down" });
65
+ break;
66
+ case "C":
67
+ if (this.cursor < this.buffer.length) {
68
+ this.cursor++;
69
+ actions.push({ action: "changed" });
70
+ }
71
+ break;
72
+ case "D":
73
+ if (this.cursor > 0) {
74
+ this.cursor--;
75
+ actions.push({ action: "changed" });
76
+ }
77
+ break;
78
+ case "H": // Home
79
+ if (this.cursor > 0) {
80
+ this.cursor = 0;
81
+ actions.push({ action: "changed" });
82
+ }
83
+ break;
84
+ case "F": // End
85
+ if (this.cursor < this.buffer.length) {
86
+ this.cursor = this.buffer.length;
87
+ actions.push({ action: "changed" });
88
+ }
89
+ break;
90
+ }
91
+ continue;
92
+ }
93
+ // Alt/Option + key: \x1b followed by char
94
+ i += 2; // consume \x1b + next byte
95
+ if (next === "\x7f") {
96
+ // Option+Backspace: delete word backward
97
+ if (this.deleteWordBackward())
98
+ actions.push({ action: "changed" });
99
+ }
100
+ else if (next === "b") {
101
+ // Alt+B: word backward
102
+ if (this.wordBackward())
103
+ actions.push({ action: "changed" });
104
+ }
105
+ else if (next === "f") {
106
+ // Alt+F: word forward
107
+ if (this.wordForward())
108
+ actions.push({ action: "changed" });
109
+ }
110
+ else if (next === "d") {
111
+ // Alt+D: delete word forward
112
+ if (this.deleteWordForward())
113
+ actions.push({ action: "changed" });
114
+ }
115
+ // Other Alt+key — ignore
116
+ continue;
117
+ }
118
+ // ── Control characters ──────────────────────────────
119
+ if (ch.charCodeAt(0) < 0x20 || ch === "\x7f") {
120
+ const action = this.handleControl(ch);
121
+ if (action)
122
+ actions.push(action);
123
+ i++;
124
+ continue;
125
+ }
126
+ // ── Printable character ─────────────────────────────
127
+ this.buffer = this.buffer.slice(0, this.cursor) + ch + this.buffer.slice(this.cursor);
128
+ this.cursor++;
129
+ actions.push({ action: "changed" });
130
+ i++;
131
+ }
132
+ return actions;
133
+ }
134
+ /** Check if there's a pending incomplete escape sequence. */
135
+ hasPendingEscape() {
136
+ return this.pendingSeq.length > 0;
137
+ }
138
+ /** Flush a pending sequence — treat bare \x1b as cancel, discard incomplete CSI. */
139
+ flushPendingEscape() {
140
+ if (!this.pendingSeq)
141
+ return [];
142
+ const wasBarEscape = this.pendingSeq === "\x1b";
143
+ this.pendingSeq = "";
144
+ return wasBarEscape ? [{ action: "cancel" }] : [];
145
+ }
146
+ clear() {
147
+ this.buffer = "";
148
+ this.cursor = 0;
149
+ this.pendingSeq = "";
150
+ }
151
+ // ── Key bindings ────────────────────────────────────────────
152
+ //
153
+ // Single source of truth for all keybindings. Both legacy control
154
+ // characters and kitty protocol sequences resolve to a key name
155
+ // and look it up here. To add a binding, add one entry.
156
+ bindings = {
157
+ "enter": () => ({ action: "submit", buffer: this.buffer }),
158
+ "ctrl+c": () => ({ action: "cancel" }),
159
+ "tab": () => ({ action: "tab" }),
160
+ "backspace": () => this.deleteBackward(),
161
+ "ctrl+d": () => this.buffer.length === 0 ? { action: "delete-empty" } : this.deleteForward(),
162
+ "ctrl+a": () => this.moveTo(0),
163
+ "ctrl+e": () => this.moveTo(this.buffer.length),
164
+ "ctrl+b": () => this.moveTo(this.cursor - 1),
165
+ "ctrl+f": () => this.moveTo(this.cursor + 1),
166
+ "ctrl+u": () => this.deleteRange(0, this.cursor),
167
+ "ctrl+k": () => this.deleteRange(this.cursor, this.buffer.length),
168
+ "ctrl+w": () => this.deleteWordBackward() ? { action: "changed" } : null,
169
+ "shift+enter": () => this.insertAt("\n"),
170
+ "shift+tab": () => ({ action: "shift+tab" }),
171
+ };
172
+ /** Resolve a key name from the bindings table and execute it. */
173
+ dispatch(key) {
174
+ return this.bindings[key]?.() ?? null;
175
+ }
176
+ // ── Legacy control character mapping ───────────────────────
177
+ /** Map a legacy control character to a key name. */
178
+ static CTRL_MAP = {
179
+ "\r": "enter", "\x03": "ctrl+c", "\t": "tab",
180
+ "\x7f": "backspace", "\b": "backspace",
181
+ "\x01": "ctrl+a", "\x02": "ctrl+b", "\x04": "ctrl+d",
182
+ "\x05": "ctrl+e", "\x06": "ctrl+f", "\x0b": "ctrl+k",
183
+ "\x15": "ctrl+u", "\x17": "ctrl+w",
184
+ };
185
+ handleControl(ch) {
186
+ const key = LineEditor.CTRL_MAP[ch];
187
+ return key ? this.dispatch(key) : null;
188
+ }
189
+ // ── Kitty keyboard protocol ────────────────────────────────
190
+ /** Handle a kitty protocol CSI u sequence. Params format: "keycode;modifier". */
191
+ handleKittyKey(params) {
192
+ const [kc, mod] = params.split(";").map(Number);
193
+ const keycode = kc;
194
+ const mods = (mod ?? 1) - 1; // kitty modifier bits
195
+ // Build key name from modifier + keycode
196
+ const modNames = [];
197
+ if (mods & 4)
198
+ modNames.push("ctrl");
199
+ if (mods & 1)
200
+ modNames.push("shift");
201
+ if (mods & 2)
202
+ modNames.push("alt");
203
+ const keyName = KITTY_KEY_NAMES[keycode] ?? String.fromCharCode(keycode);
204
+ const fullName = [...modNames, keyName].join("+");
205
+ // Try exact binding first, then fall back to ctrl char mapping
206
+ return this.dispatch(fullName)
207
+ ?? ((mods & 4) && keycode >= 97 && keycode <= 122
208
+ ? this.dispatch(`ctrl+${String.fromCharCode(keycode)}`)
209
+ : null)
210
+ ?? (mods === 0 ? this.handleControl(String.fromCharCode(keycode)) : null);
211
+ }
212
+ // ── Editing primitives ─────────────────────────────────────
213
+ insertAt(ch) {
214
+ this.buffer = this.buffer.slice(0, this.cursor) + ch + this.buffer.slice(this.cursor);
215
+ this.cursor++;
216
+ return { action: "changed" };
217
+ }
218
+ moveTo(pos) {
219
+ const clamped = Math.max(0, Math.min(pos, this.buffer.length));
220
+ if (clamped === this.cursor)
221
+ return null;
222
+ this.cursor = clamped;
223
+ return { action: "changed" };
224
+ }
225
+ deleteBackward() {
226
+ if (this.buffer.length === 0)
227
+ return { action: "delete-empty" };
228
+ if (this.cursor <= 0)
229
+ return null;
230
+ this.buffer = this.buffer.slice(0, this.cursor - 1) + this.buffer.slice(this.cursor);
231
+ this.cursor--;
232
+ return { action: "changed" };
233
+ }
234
+ deleteForward() {
235
+ if (this.cursor >= this.buffer.length)
236
+ return null;
237
+ this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1);
238
+ return { action: "changed" };
239
+ }
240
+ deleteRange(start, end) {
241
+ if (start >= end)
242
+ return null;
243
+ this.buffer = this.buffer.slice(0, start) + this.buffer.slice(end);
244
+ this.cursor = start;
245
+ return { action: "changed" };
246
+ }
247
+ // ── CSI sequence handling ───────────────────────────────────
248
+ /**
249
+ * Parse and handle a CSI sequence (\x1b[...) starting at `start`.
250
+ * Returns the number of bytes consumed and whether the sequence was incomplete.
251
+ */
252
+ handleCSI(data, start, actions) {
253
+ // Skip \x1b[
254
+ let j = start + 2;
255
+ // Accumulate parameter bytes (0x20-0x3F: digits, semicolons, etc.)
256
+ let params = "";
257
+ while (j < data.length && data.charCodeAt(j) >= 0x20 && data.charCodeAt(j) < 0x40) {
258
+ params += data[j];
259
+ j++;
260
+ }
261
+ // If we ran out of data before the final byte, sequence is incomplete
262
+ if (j >= data.length) {
263
+ return { consumed: j - start, incomplete: true };
264
+ }
265
+ const final = data[j];
266
+ const consumed = j - start + 1;
267
+ // Dispatch on final byte
268
+ switch (final) {
269
+ case "A": // Up arrow
270
+ actions.push({ action: "arrow-up" });
271
+ break;
272
+ case "B": // Down arrow
273
+ actions.push({ action: "arrow-down" });
274
+ break;
275
+ case "C": // Right (or modified right: 1;3C, 1;5C = word right)
276
+ if (params.includes(";")) {
277
+ if (this.wordForward())
278
+ actions.push({ action: "changed" });
279
+ }
280
+ else {
281
+ if (this.cursor < this.buffer.length) {
282
+ this.cursor++;
283
+ actions.push({ action: "changed" });
284
+ }
285
+ }
286
+ break;
287
+ case "D": // Left (or modified left: 1;3D, 1;5D = word left)
288
+ if (params.includes(";")) {
289
+ if (this.wordBackward())
290
+ actions.push({ action: "changed" });
291
+ }
292
+ else {
293
+ if (this.cursor > 0) {
294
+ this.cursor--;
295
+ actions.push({ action: "changed" });
296
+ }
297
+ }
298
+ break;
299
+ case "H": // Home
300
+ if (this.cursor > 0) {
301
+ this.cursor = 0;
302
+ actions.push({ action: "changed" });
303
+ }
304
+ break;
305
+ case "F": // End
306
+ if (this.cursor < this.buffer.length) {
307
+ this.cursor = this.buffer.length;
308
+ actions.push({ action: "changed" });
309
+ }
310
+ break;
311
+ case "Z": // Shift+Tab (legacy CSI sequence)
312
+ actions.push({ action: "shift+tab" });
313
+ break;
314
+ case "u": { // Kitty keyboard protocol: \x1b[<keycode>;<modifier>u
315
+ const action = this.handleKittyKey(params);
316
+ if (action)
317
+ actions.push(action);
318
+ break;
319
+ }
320
+ case "~": // Extended keys: Delete (3~), etc.
321
+ if (params === "3") {
322
+ // Delete key: delete char under cursor
323
+ if (this.cursor < this.buffer.length) {
324
+ this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(this.cursor + 1);
325
+ actions.push({ action: "changed" });
326
+ }
327
+ }
328
+ break;
329
+ // All other CSI sequences — silently ignored
330
+ }
331
+ return { consumed };
332
+ }
333
+ // ── Word movement / deletion helpers ────────────────────────
334
+ wordBackward() {
335
+ if (this.cursor === 0)
336
+ return false;
337
+ let pos = this.cursor;
338
+ // Skip spaces
339
+ while (pos > 0 && this.buffer[pos - 1] === " ")
340
+ pos--;
341
+ // Skip word chars
342
+ while (pos > 0 && this.buffer[pos - 1] !== " ")
343
+ pos--;
344
+ if (pos === this.cursor)
345
+ return false;
346
+ this.cursor = pos;
347
+ return true;
348
+ }
349
+ wordForward() {
350
+ if (this.cursor >= this.buffer.length)
351
+ return false;
352
+ let pos = this.cursor;
353
+ // Skip word chars
354
+ while (pos < this.buffer.length && this.buffer[pos] !== " ")
355
+ pos++;
356
+ // Skip spaces
357
+ while (pos < this.buffer.length && this.buffer[pos] === " ")
358
+ pos++;
359
+ if (pos === this.cursor)
360
+ return false;
361
+ this.cursor = pos;
362
+ return true;
363
+ }
364
+ deleteWordBackward() {
365
+ if (this.cursor === 0)
366
+ return false;
367
+ const start = this.cursor;
368
+ this.wordBackward();
369
+ this.buffer = this.buffer.slice(0, this.cursor) + this.buffer.slice(start);
370
+ return true;
371
+ }
372
+ deleteWordForward() {
373
+ if (this.cursor >= this.buffer.length)
374
+ return false;
375
+ const start = this.cursor;
376
+ this.wordForward();
377
+ this.buffer = this.buffer.slice(0, start) + this.buffer.slice(this.cursor);
378
+ this.cursor = start;
379
+ return true;
380
+ }
381
+ }
@@ -108,13 +108,13 @@ export class MarkdownRenderer {
108
108
  }
109
109
  }
110
110
  printTopBorder() {
111
- const w = Math.min(this.contentWidth, 40);
112
- process.stdout.write(`${p.dim}${p.accent}${"─".repeat(w)}${p.reset}\n`);
111
+ const termW = process.stdout.columns || 80;
112
+ process.stdout.write(`${p.dim}${p.accent}${"─".repeat(termW)}${p.reset}\n`);
113
113
  this.firstLine = true;
114
114
  }
115
115
  printBottomBorder() {
116
- const w = Math.min(this.contentWidth, 40);
117
- process.stdout.write(`${p.dim}${p.accent}${"─".repeat(w)}${p.reset}\n`);
116
+ const termW = process.stdout.columns || 80;
117
+ process.stdout.write(`${p.dim}${p.accent}${"─".repeat(termW)}${p.reset}\n`);
118
118
  }
119
119
  processBuffer() {
120
120
  const lines = this.buffer.split("\n");
@@ -4,6 +4,15 @@ export interface ToolCallRender {
4
4
  title: string;
5
5
  /** Optional command string for bash-like tools. */
6
6
  command?: string;
7
+ /** Tool kind from ACP (read, edit, execute, search, etc.). */
8
+ kind?: string;
9
+ /** File locations affected by the tool call. */
10
+ locations?: {
11
+ path: string;
12
+ line?: number | null;
13
+ }[];
14
+ /** Raw input parameters sent to the tool. */
15
+ rawInput?: unknown;
7
16
  }
8
17
  export interface ToolResultRender {
9
18
  exitCode: number | null;
@@ -29,5 +38,7 @@ export declare function createSpinner(): SpinnerState;
29
38
  */
30
39
  export declare function startSpinner(label: string, opts?: {
31
40
  color?: string;
41
+ hint?: string;
42
+ startTime?: number;
32
43
  }): SpinnerState;
33
44
  export declare function stopSpinner(state: SpinnerState): void;
@@ -37,24 +37,104 @@ export function selectToolDisplayMode(width) {
37
37
  return "compact";
38
38
  return "summary";
39
39
  }
40
+ // ── Kind icons ──────────────────────────────────────────────────
41
+ const KIND_ICONS = {
42
+ read: "◆",
43
+ edit: "✎",
44
+ delete: "✕",
45
+ move: "↗",
46
+ search: "⌕",
47
+ execute: "▶",
48
+ think: "◇",
49
+ fetch: "↓",
50
+ switch_mode: "⇄",
51
+ };
52
+ function kindIcon(kind) {
53
+ return kind ? (KIND_ICONS[kind] ?? "▶") : "▶";
54
+ }
40
55
  // ── Tool call rendering ──────────────────────────────────────────
41
56
  export function renderToolCall(tool, width) {
42
57
  const mode = selectToolDisplayMode(width);
58
+ const icon = kindIcon(tool.kind);
43
59
  if (mode === "summary") {
44
- const text = truncateVisible(`▶ ${tool.title}`, width);
60
+ const text = truncateVisible(`${icon} ${tool.title}`, width);
45
61
  return [`${p.warning}${text}${p.reset}`];
46
62
  }
47
63
  const lines = [];
48
- lines.push(`${p.warning}${p.bold}▶ ${tool.title}${p.reset}`);
49
- if (tool.command && mode === "full") {
50
- const maxCmdW = Math.max(1, width - 4);
51
- const cmd = tool.command.length > maxCmdW
52
- ? tool.command.slice(0, maxCmdW - 1) + "…"
53
- : tool.command;
54
- lines.push(` ${p.dim}$ ${cmd}${p.reset}`);
64
+ // Build a compact detail string to append after the title
65
+ let detail = "";
66
+ if (mode === "full") {
67
+ if (tool.command) {
68
+ detail = `$ ${tool.command}`;
69
+ }
70
+ else if (tool.locations && tool.locations.length > 0) {
71
+ const loc = tool.locations[0];
72
+ const lineInfo = loc.line ? `:${loc.line}` : "";
73
+ detail = `${loc.path}${lineInfo}`;
74
+ }
75
+ else if (tool.rawInput) {
76
+ const raw = tool.rawInput;
77
+ if (raw && typeof raw === "object") {
78
+ if (typeof raw.command === "string") {
79
+ detail = `$ ${raw.command}`;
80
+ }
81
+ else if (typeof raw.operation === "string") {
82
+ detail = raw.operation;
83
+ if (raw.ids && Array.isArray(raw.ids)) {
84
+ detail += ` #${raw.ids.join(",")}`;
85
+ }
86
+ if (typeof raw.query === "string") {
87
+ detail += ` "${raw.query}"`;
88
+ }
89
+ }
90
+ else {
91
+ detail = formatRawInput(tool.rawInput, width - tool.title.length - 6);
92
+ }
93
+ }
94
+ }
95
+ }
96
+ // Render as single line: ► title: detail
97
+ const maxDetailW = Math.max(1, width - tool.title.length - 6);
98
+ if (detail) {
99
+ if (detail.length > maxDetailW)
100
+ detail = detail.slice(0, maxDetailW - 1) + "…";
101
+ lines.push(`${p.warning}${p.bold}${icon} ${tool.title}${p.reset}${p.dim}: ${detail}${p.reset}`);
102
+ }
103
+ else {
104
+ lines.push(`${p.warning}${p.bold}${icon} ${tool.title}${p.reset}`);
105
+ }
106
+ // Show additional file locations on separate lines (if more than one)
107
+ if (mode === "full" && tool.locations && tool.locations.length > 1) {
108
+ for (const loc of tool.locations.slice(1)) {
109
+ const lineInfo = loc.line ? `:${loc.line}` : "";
110
+ lines.push(` ${p.dim}${loc.path}${lineInfo}${p.reset}`);
111
+ }
55
112
  }
56
113
  return lines;
57
114
  }
115
+ /**
116
+ * Format raw input parameters into a compact single-line summary.
117
+ */
118
+ function formatRawInput(raw, maxWidth) {
119
+ if (raw == null)
120
+ return "";
121
+ if (typeof raw === "string") {
122
+ return raw.length > maxWidth ? raw.slice(0, maxWidth - 1) + "…" : raw;
123
+ }
124
+ if (typeof raw !== "object")
125
+ return String(raw);
126
+ // Show key=value pairs for objects
127
+ const obj = raw;
128
+ const parts = [];
129
+ for (const [key, val] of Object.entries(obj)) {
130
+ if (val == null)
131
+ continue;
132
+ const valStr = typeof val === "string" ? val : JSON.stringify(val);
133
+ parts.push(`${key}=${valStr}`);
134
+ }
135
+ const joined = parts.join(" ");
136
+ return joined.length > maxWidth ? joined.slice(0, maxWidth - 1) + "…" : joined;
137
+ }
58
138
  // ── Tool result rendering ────────────────────────────────────────
59
139
  export function renderToolResult(result, width) {
60
140
  const mode = selectToolDisplayMode(width);
@@ -113,12 +193,15 @@ export function createSpinner() {
113
193
  */
114
194
  export function startSpinner(label, opts) {
115
195
  const state = createSpinner();
196
+ if (opts?.startTime)
197
+ state.startTime = opts.startTime;
116
198
  const color = opts?.color ?? p.accent;
199
+ const hint = opts?.hint ? ` ${p.dim}${opts.hint}${p.reset}` : "";
117
200
  state.interval = setInterval(() => {
118
201
  const frame = SPINNER_FRAMES[state.frame % SPINNER_FRAMES.length];
119
202
  const elapsed = formatElapsed(Date.now() - state.startTime);
120
203
  const timer = elapsed ? ` ${p.dim}${elapsed}${p.reset}` : "";
121
- process.stdout.write(`\r ${color}${frame} ${label}...${p.reset}${timer}\x1b[K`);
204
+ process.stdout.write(`\r ${color}${frame} ${label}...${p.reset}${timer}${hint}\x1b[K`);
122
205
  state.frame++;
123
206
  }, 80);
124
207
  return state;