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.
@@ -1,29 +1,119 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import { visibleLen } from "./utils/ansi.js";
2
4
  import { palette as p } from "./utils/palette.js";
5
+ import { LineEditor } from "./utils/line-editor.js";
6
+ import { CONFIG_DIR, getSettings } from "./settings.js";
7
+ const HISTORY_FILE = path.join(CONFIG_DIR, "history");
3
8
  export class InputHandler {
4
9
  ctx;
5
10
  lineBuffer = "";
6
11
  agentInputMode = false;
7
- agentInputBuffer = "";
12
+ editor = new LineEditor();
8
13
  autocompleteActive = false;
9
14
  autocompleteIndex = 0;
10
15
  autocompleteItems = [];
11
16
  autocompleteLines = 0;
17
+ history = [];
18
+ historyIndex = -1; // -1 = not browsing history
19
+ savedBuffer = ""; // buffer saved when entering history
20
+ promptWrappedLines = 0; // extra lines from terminal wrapping
21
+ escapeTimer = null;
12
22
  bus;
13
23
  onShowAgentInfo;
14
24
  constructor(opts) {
15
25
  this.ctx = opts.ctx;
16
26
  this.bus = opts.bus;
17
27
  this.onShowAgentInfo = opts.onShowAgentInfo;
28
+ this.loadHistory();
29
+ // Re-render prompt when config changes (e.g. thinking level cycled)
30
+ this.bus.on("config:changed", () => {
31
+ if (this.agentInputMode)
32
+ this.writeAgentPromptLine();
33
+ });
34
+ }
35
+ loadHistory() {
36
+ try {
37
+ const data = fs.readFileSync(HISTORY_FILE, "utf-8");
38
+ this.history = data.split("\n").filter(Boolean);
39
+ }
40
+ catch {
41
+ // No history file yet
42
+ }
18
43
  }
19
- /** Write the agent prompt line (clear + info prefix + ❯ + buffer text). */
44
+ saveHistory() {
45
+ try {
46
+ const { historySize } = getSettings();
47
+ fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
48
+ const lines = this.history.slice(-historySize);
49
+ fs.writeFileSync(HISTORY_FILE, lines.join("\n") + "\n");
50
+ }
51
+ catch {
52
+ // Non-critical — ignore write failures
53
+ }
54
+ }
55
+ /** Write the agent prompt line with cursor at the correct position. */
20
56
  writeAgentPromptLine(showBuffer = true) {
57
+ const termW = process.stdout.columns || 80;
58
+ // Move cursor to the start of the prompt area (first line of wrapped content)
59
+ if (this.promptWrappedLines > 0) {
60
+ process.stdout.write(`\x1b[${this.promptWrappedLines}A`);
61
+ }
62
+ // Clear from here to end of screen — removes current + all wrapped lines below
63
+ process.stdout.write("\r\x1b[J");
21
64
  const agentInfo = this.onShowAgentInfo();
22
65
  const infoPrefix = agentInfo.info ? `${agentInfo.info} ` : "";
23
- process.stdout.write("\r\x1b[2K" +
24
- infoPrefix +
25
- p.warning + p.bold + "" + p.reset +
26
- (showBuffer ? p.accent + this.agentInputBuffer + p.reset : ""));
66
+ const promptPrefix = infoPrefix + p.warning + p.bold + "" + p.reset;
67
+ const promptVisLen = visibleLen(infoPrefix) + 2; // "❯ "
68
+ if (!showBuffer || !this.editor.buffer.includes("\n")) {
69
+ // Single-line: simple rendering
70
+ const bufferText = showBuffer ? p.accent + this.editor.buffer + p.reset : "";
71
+ process.stdout.write(promptPrefix + bufferText);
72
+ const bufferVisLen = showBuffer ? this.editor.buffer.length : 0;
73
+ const totalVisLen = promptVisLen + bufferVisLen;
74
+ this.promptWrappedLines = totalVisLen > 0 ? Math.floor((totalVisLen - 1) / termW) : 0;
75
+ // Position cursor within the buffer
76
+ if (showBuffer && this.editor.cursor < this.editor.buffer.length) {
77
+ const charsAfterCursor = this.editor.buffer.length - this.editor.cursor;
78
+ process.stdout.write(`\x1b[${charsAfterCursor}D`);
79
+ }
80
+ }
81
+ else {
82
+ // Multi-line: render each line with continuation indent
83
+ const lines = this.editor.buffer.split("\n");
84
+ const indent = " ".repeat(promptVisLen);
85
+ let totalTermLines = 0;
86
+ for (let li = 0; li < lines.length; li++) {
87
+ const prefix = li === 0 ? promptPrefix : indent;
88
+ const prefixVisLen = li === 0 ? promptVisLen : promptVisLen;
89
+ const lineText = lines[li];
90
+ process.stdout.write(prefix + p.accent + lineText + p.reset);
91
+ if (li < lines.length - 1)
92
+ process.stdout.write("\n");
93
+ // Count terminal lines this logical line occupies
94
+ const lineVisLen = prefixVisLen + lineText.length;
95
+ totalTermLines += lineVisLen > 0 ? Math.ceil(lineVisLen / termW) : 1;
96
+ }
97
+ this.promptWrappedLines = totalTermLines - 1;
98
+ // Position cursor: find which line and column the cursor is on
99
+ let charsRemaining = this.editor.cursor;
100
+ let cursorLine = 0;
101
+ for (let li = 0; li < lines.length; li++) {
102
+ if (charsRemaining <= lines[li].length) {
103
+ cursorLine = li;
104
+ break;
105
+ }
106
+ charsRemaining -= lines[li].length + 1; // +1 for \n
107
+ cursorLine = li + 1;
108
+ }
109
+ // Move from end position to cursor position
110
+ const linesFromEnd = lines.length - 1 - cursorLine;
111
+ if (linesFromEnd > 0) {
112
+ process.stdout.write(`\x1b[${linesFromEnd}A`);
113
+ }
114
+ const cursorCol = (cursorLine === 0 ? promptVisLen : promptVisLen) + charsRemaining;
115
+ process.stdout.write(`\r\x1b[${cursorCol}C`);
116
+ }
27
117
  }
28
118
  handleInput(data) {
29
119
  // If agent is running (processing a query), only Ctrl-C and control keys
@@ -36,10 +126,15 @@ export class InputHandler {
36
126
  }
37
127
  return;
38
128
  }
39
- // Forward control chars that normal shell mode doesn't handle
129
+ // Intercept control chars for TUI (Ctrl+T, Ctrl+O) — don't pass to PTY
40
130
  if (data.length === 1 && data.charCodeAt(0) < 32 && !this.agentInputMode) {
41
131
  const code = data.charCodeAt(0);
42
- // Don't intercept keys that shell mode handles: CR, Ctrl-C, Ctrl-D, Tab
132
+ // Keys consumed by TUI extensions
133
+ if (code === 0x14 || code === 0x0f) { // Ctrl+T, Ctrl+O
134
+ this.bus.emit("input:keypress", { key: data });
135
+ return;
136
+ }
137
+ // Forward other control chars that shell mode doesn't handle
43
138
  if (code !== 0x0d && code !== 0x03 && code !== 0x04 && code !== 0x09) {
44
139
  this.bus.emit("input:keypress", { key: data });
45
140
  }
@@ -89,16 +184,29 @@ export class InputHandler {
89
184
  }
90
185
  enterAgentInputMode() {
91
186
  this.agentInputMode = true;
92
- this.agentInputBuffer = "";
187
+ this.editor.clear();
188
+ // Enable kitty keyboard protocol (progressive enhancement flag 1)
189
+ // so Shift+Enter sends \x1b[13;2u instead of plain \r
190
+ process.stdout.write("\x1b[>1u");
93
191
  this.writeAgentPromptLine(false);
94
192
  }
95
193
  exitAgentInputMode() {
96
194
  this.dismissAutocomplete();
97
195
  this.agentInputMode = false;
98
- this.agentInputBuffer = "";
99
- process.stdout.write("\r\x1b[2K");
196
+ this.editor.clear();
197
+ // Disable kitty keyboard protocol
198
+ process.stdout.write("\x1b[<u");
199
+ this.clearPromptArea();
100
200
  this.printPrompt();
101
201
  }
202
+ /** Move to the start of the prompt area and clear everything below. */
203
+ clearPromptArea() {
204
+ if (this.promptWrappedLines > 0) {
205
+ process.stdout.write(`\x1b[${this.promptWrappedLines}A`);
206
+ }
207
+ process.stdout.write("\r\x1b[J");
208
+ this.promptWrappedLines = 0;
209
+ }
102
210
  printPrompt() {
103
211
  this.ctx.redrawPrompt();
104
212
  }
@@ -109,7 +217,7 @@ export class InputHandler {
109
217
  }
110
218
  updateAutocomplete() {
111
219
  const { items } = this.bus.emitPipe("autocomplete:request", {
112
- buffer: this.agentInputBuffer,
220
+ buffer: this.editor.buffer,
113
221
  items: [],
114
222
  });
115
223
  if (items.length > 0) {
@@ -146,36 +254,27 @@ export class InputHandler {
146
254
  }
147
255
  const agentInfo = this.onShowAgentInfo();
148
256
  const infoLength = visibleLen(agentInfo.info);
149
- const col = infoLength + 2 + this.agentInputBuffer.length;
257
+ const col = infoLength + 2 + this.editor.cursor;
150
258
  process.stdout.write(`\r\x1b[${col}C`);
151
259
  }
152
- clearAutocompleteLines() {
153
- if (this.autocompleteLines <= 0)
154
- return;
155
- process.stdout.write("\x1b7"); // save cursor
156
- for (let i = 0; i < this.autocompleteLines; i++) {
157
- process.stdout.write("\n\x1b[2K"); // move down, clear line
158
- }
159
- process.stdout.write("\x1b8"); // restore cursor
160
- this.autocompleteLines = 0;
161
- }
162
260
  applyAutocomplete() {
163
261
  if (!this.autocompleteActive || this.autocompleteItems.length === 0)
164
262
  return;
165
263
  const selected = this.autocompleteItems[this.autocompleteIndex];
166
264
  if (!selected)
167
265
  return;
168
- const atPos = this.agentInputBuffer.lastIndexOf("@");
266
+ const atPos = this.editor.buffer.lastIndexOf("@");
169
267
  const isFileAc = atPos >= 0 &&
170
- (atPos === 0 || this.agentInputBuffer[atPos - 1] === " ") &&
171
- !this.agentInputBuffer.slice(atPos + 1).includes(" ");
268
+ (atPos === 0 || this.editor.buffer[atPos - 1] === " ") &&
269
+ !this.editor.buffer.slice(atPos + 1).includes(" ");
172
270
  if (isFileAc) {
173
- this.agentInputBuffer =
174
- this.agentInputBuffer.slice(0, atPos) + "@" + selected.name;
271
+ this.editor.buffer =
272
+ this.editor.buffer.slice(0, atPos) + "@" + selected.name;
175
273
  }
176
274
  else {
177
- this.agentInputBuffer = selected.name;
275
+ this.editor.buffer = selected.name;
178
276
  }
277
+ this.editor.cursor = this.editor.buffer.length;
179
278
  this.clearAutocompleteLines();
180
279
  this.autocompleteActive = false;
181
280
  this.autocompleteItems = [];
@@ -190,112 +289,145 @@ export class InputHandler {
190
289
  this.autocompleteItems = [];
191
290
  this.autocompleteIndex = 0;
192
291
  }
292
+ clearAutocompleteLines() {
293
+ if (this.autocompleteLines <= 0)
294
+ return;
295
+ process.stdout.write("\x1b7"); // save cursor
296
+ for (let i = 0; i < this.autocompleteLines; i++) {
297
+ process.stdout.write("\n\x1b[2K"); // move down, clear line
298
+ }
299
+ process.stdout.write("\x1b8"); // restore cursor
300
+ this.autocompleteLines = 0;
301
+ }
193
302
  handleAgentInput(data) {
194
- for (let i = 0; i < data.length; i++) {
195
- const ch = data[i];
196
- // Detect arrow key sequences: \x1b[A (up), \x1b[B (down)
197
- if (ch === "\x1b" && data[i + 1] === "[") {
198
- const arrow = data[i + 2];
199
- if (arrow === "A" && this.autocompleteActive) {
200
- // Arrow up
201
- this.autocompleteIndex =
202
- this.autocompleteIndex === 0
203
- ? this.autocompleteItems.length - 1
204
- : this.autocompleteIndex - 1;
205
- this.clearAutocompleteLines();
206
- this.writeAgentPromptLine();
207
- this.renderAutocomplete();
208
- i += 2;
209
- continue;
210
- }
211
- else if (arrow === "B" && this.autocompleteActive) {
212
- this.autocompleteIndex =
213
- this.autocompleteIndex === this.autocompleteItems.length - 1
214
- ? 0
215
- : this.autocompleteIndex + 1;
303
+ // Clear any pending escape timer new data arrived
304
+ if (this.escapeTimer) {
305
+ clearTimeout(this.escapeTimer);
306
+ this.escapeTimer = null;
307
+ }
308
+ const actions = this.editor.feed(data);
309
+ // If the editor is waiting for more escape sequence data, set a short
310
+ // timer — if nothing arrives, treat it as a bare Escape keypress
311
+ if (this.editor.hasPendingEscape()) {
312
+ this.escapeTimer = setTimeout(() => {
313
+ this.escapeTimer = null;
314
+ const flushed = this.editor.flushPendingEscape();
315
+ if (flushed.length > 0)
316
+ this.processAgentActions(flushed);
317
+ }, 50);
318
+ }
319
+ this.processAgentActions(actions);
320
+ }
321
+ processAgentActions(actions) {
322
+ for (const act of actions) {
323
+ switch (act.action) {
324
+ case "changed":
325
+ this.historyIndex = -1;
326
+ this.autocompleteIndex = 0;
327
+ this.renderAgentInput();
328
+ break;
329
+ case "submit": {
330
+ if (this.autocompleteActive) {
331
+ this.applyAutocomplete();
332
+ }
333
+ const query = act.buffer.trim();
334
+ if (query) {
335
+ // Add to history (avoid consecutive duplicates)
336
+ if (this.history.length === 0 || this.history[this.history.length - 1] !== query) {
337
+ this.history.push(query);
338
+ this.saveHistory();
339
+ }
340
+ }
341
+ this.historyIndex = -1;
342
+ this.savedBuffer = "";
216
343
  this.clearAutocompleteLines();
217
- this.writeAgentPromptLine();
218
- this.renderAutocomplete();
219
- i += 2;
220
- continue;
221
- }
222
- else if (!this.autocompleteActive) {
223
- // Escape without arrow: cancel agent input mode
344
+ this.clearPromptArea();
345
+ process.stdout.write("\x1b[<u"); // disable kitty keyboard protocol
346
+ this.agentInputMode = false;
347
+ this.editor.clear();
224
348
  this.dismissAutocomplete();
225
- this.exitAgentInputMode();
349
+ if (query && query.startsWith("/")) {
350
+ const spaceIdx = query.indexOf(" ");
351
+ const name = spaceIdx === -1 ? query : query.slice(0, spaceIdx);
352
+ const args = spaceIdx === -1 ? "" : query.slice(spaceIdx + 1).trim();
353
+ this.bus.emit("command:execute", { name, args });
354
+ this.ctx.redrawPrompt();
355
+ }
356
+ else if (query) {
357
+ this.bus.emit("agent:submit", { query });
358
+ }
359
+ else {
360
+ this.exitAgentInputMode();
361
+ }
226
362
  return;
227
363
  }
228
- // Other escape sequences (e.g. left/right arrow) — ignore for now
229
- i += 2;
230
- continue;
231
- }
232
- if (ch === "\x1b") {
233
- // Bare escape (no bracket follows)
234
- if (this.autocompleteActive) {
235
- this.dismissAutocomplete();
236
- this.writeAgentPromptLine();
237
- }
238
- else {
239
- this.dismissAutocomplete();
240
- this.exitAgentInputMode();
241
- }
242
- return;
243
- }
244
- if (ch === "\t") {
245
- if (this.autocompleteActive) {
246
- this.applyAutocomplete();
247
- }
248
- continue;
249
- }
250
- if (ch === "\r") {
251
- if (this.autocompleteActive) {
252
- this.applyAutocomplete();
253
- }
254
- const query = this.agentInputBuffer.trim();
255
- this.clearAutocompleteLines();
256
- process.stdout.write("\r\x1b[2K");
257
- this.agentInputMode = false;
258
- this.agentInputBuffer = "";
259
- this.dismissAutocomplete();
260
- if (query && query.startsWith("/")) {
261
- const spaceIdx = query.indexOf(" ");
262
- const name = spaceIdx === -1 ? query : query.slice(0, spaceIdx);
263
- const args = spaceIdx === -1 ? "" : query.slice(spaceIdx + 1).trim();
264
- this.bus.emit("command:execute", { name, args });
265
- this.ctx.redrawPrompt();
266
- }
267
- else if (query) {
268
- this.bus.emit("agent:submit", { query });
269
- }
270
- else {
271
- this.exitAgentInputMode();
272
- }
273
- return;
274
- }
275
- else if (ch === "\x03") {
276
- // Ctrl-C: cancel
277
- this.dismissAutocomplete();
278
- this.exitAgentInputMode();
279
- return;
280
- }
281
- else if (ch === "\x7f" || ch === "\b") {
282
- // Backspace
283
- if (this.agentInputBuffer.length > 0) {
284
- this.agentInputBuffer = this.agentInputBuffer.slice(0, -1);
285
- this.autocompleteIndex = 0;
286
- this.renderAgentInput();
287
- }
288
- else {
364
+ case "cancel":
365
+ if (this.autocompleteActive) {
366
+ this.dismissAutocomplete();
367
+ this.writeAgentPromptLine();
368
+ }
369
+ else {
370
+ this.exitAgentInputMode();
371
+ }
372
+ return;
373
+ case "delete-empty":
289
374
  this.dismissAutocomplete();
290
375
  this.exitAgentInputMode();
291
376
  return;
292
- }
293
- }
294
- else if (ch.charCodeAt(0) >= 32) {
295
- // Printable character
296
- this.agentInputBuffer += ch;
297
- this.autocompleteIndex = 0;
298
- this.renderAgentInput();
377
+ case "tab":
378
+ if (this.autocompleteActive) {
379
+ this.applyAutocomplete();
380
+ }
381
+ break;
382
+ case "shift+tab":
383
+ this.bus.emit("config:cycle", {});
384
+ break;
385
+ case "arrow-up":
386
+ if (this.autocompleteActive) {
387
+ this.autocompleteIndex =
388
+ this.autocompleteIndex === 0
389
+ ? this.autocompleteItems.length - 1
390
+ : this.autocompleteIndex - 1;
391
+ this.clearAutocompleteLines();
392
+ this.writeAgentPromptLine();
393
+ this.renderAutocomplete();
394
+ }
395
+ else if (this.history.length > 0) {
396
+ if (this.historyIndex === -1) {
397
+ this.savedBuffer = this.editor.buffer;
398
+ this.historyIndex = this.history.length - 1;
399
+ }
400
+ else if (this.historyIndex > 0) {
401
+ this.historyIndex--;
402
+ }
403
+ this.editor.buffer = this.history[this.historyIndex];
404
+ this.editor.cursor = this.editor.buffer.length;
405
+ this.renderAgentInput();
406
+ }
407
+ break;
408
+ case "arrow-down":
409
+ if (this.autocompleteActive) {
410
+ this.autocompleteIndex =
411
+ this.autocompleteIndex === this.autocompleteItems.length - 1
412
+ ? 0
413
+ : this.autocompleteIndex + 1;
414
+ this.clearAutocompleteLines();
415
+ this.writeAgentPromptLine();
416
+ this.renderAutocomplete();
417
+ }
418
+ else if (this.historyIndex !== -1) {
419
+ if (this.historyIndex < this.history.length - 1) {
420
+ this.historyIndex++;
421
+ this.editor.buffer = this.history[this.historyIndex];
422
+ }
423
+ else {
424
+ this.historyIndex = -1;
425
+ this.editor.buffer = this.savedBuffer;
426
+ }
427
+ this.editor.cursor = this.editor.buffer.length;
428
+ this.renderAgentInput();
429
+ }
430
+ break;
299
431
  }
300
432
  }
301
433
  }
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Minimal MCP server exposing a `user_shell` tool.
4
+ *
5
+ * Spawned by the ACP agent (pi-acp, claude-agent-acp, etc.) as an MCP
6
+ * stdio server. When the LLM calls `user_shell`, this process connects
7
+ * to agent-sh's Unix socket to execute the command in the user's live
8
+ * PTY shell.
9
+ *
10
+ * Protocol: MCP over stdio (newline-delimited JSON-RPC 2.0).
11
+ * No SDK dependency — the protocol surface is tiny.
12
+ */
13
+ export {};