agent-sh 0.2.0 → 0.3.1

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 (42) hide show
  1. package/README.md +21 -0
  2. package/dist/acp-client.d.ts +24 -0
  3. package/dist/acp-client.js +155 -33
  4. package/dist/context-manager.d.ts +5 -3
  5. package/dist/context-manager.js +62 -31
  6. package/dist/core.js +10 -0
  7. package/dist/event-bus.d.ts +26 -0
  8. package/dist/event-bus.js +10 -0
  9. package/dist/extension-loader.js +3 -14
  10. package/dist/extensions/shell-exec.js +27 -22
  11. package/dist/extensions/tui-renderer.d.ts +1 -1
  12. package/dist/extensions/tui-renderer.js +369 -126
  13. package/dist/index.js +184 -37
  14. package/dist/input-handler.d.ts +10 -0
  15. package/dist/input-handler.js +169 -10
  16. package/dist/mcp-server.js +37 -8
  17. package/dist/settings.d.ts +44 -0
  18. package/dist/settings.js +61 -0
  19. package/dist/shell.d.ts +1 -0
  20. package/dist/shell.js +44 -4
  21. package/dist/types.d.ts +17 -0
  22. package/dist/utils/ansi.d.ts +4 -1
  23. package/dist/utils/ansi.js +60 -2
  24. package/dist/utils/box-frame.js +2 -1
  25. package/dist/utils/diff-renderer.js +1 -1
  26. package/dist/utils/frame-renderer.d.ts +26 -0
  27. package/dist/utils/frame-renderer.js +76 -0
  28. package/dist/utils/handler-registry.d.ts +41 -0
  29. package/dist/utils/handler-registry.js +52 -0
  30. package/dist/utils/line-editor.d.ts +21 -1
  31. package/dist/utils/line-editor.js +193 -99
  32. package/dist/utils/markdown.d.ts +15 -6
  33. package/dist/utils/markdown.js +106 -67
  34. package/dist/utils/output-writer.d.ts +22 -0
  35. package/dist/utils/output-writer.js +29 -0
  36. package/dist/utils/stream-transform.d.ts +70 -0
  37. package/dist/utils/stream-transform.js +229 -0
  38. package/dist/utils/tool-display.d.ts +11 -8
  39. package/dist/utils/tool-display.js +69 -46
  40. package/examples/extensions/latex-images.ts +142 -0
  41. package/examples/pi-agent-sh.ts +166 -0
  42. package/package.json +10 -2
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { execFileSync } from "node:child_process";
2
+ import { spawn } from "node:child_process";
3
3
  import { Shell } from "./shell.js";
4
4
  import { createCore } from "./core.js";
5
5
  import { palette as p } from "./utils/palette.js";
@@ -10,35 +10,63 @@ import shellRecall from "./extensions/shell-recall.js";
10
10
  import shellExec from "./extensions/shell-exec.js";
11
11
  import { loadExtensions } from "./extension-loader.js";
12
12
  /**
13
- * Capture the user's full shell environment by running a quick interactive
14
- * subshell. This picks up env vars exported in .zshrc/.bashrc that the
15
- * Node.js process (which was spawned before the PTY sources rc files)
16
- * doesn't have.
13
+ * Capture the user's full shell environment asynchronously.
14
+ * This picks up env vars exported in .zshrc/.bashrc that the
15
+ * Node.js process doesn't have.
16
+ *
17
+ * Uses -l (login shell) instead of -i to avoid TTY blocking issues.
17
18
  */
18
- function captureShellEnv(shell) {
19
- try {
20
- const output = execFileSync(shell, ["-i", "-c", "env -0"], {
21
- encoding: "utf-8",
22
- timeout: 5000,
23
- stdio: ["pipe", "pipe", "pipe"], // suppress interactive noise on stderr
24
- });
25
- const env = {};
26
- for (const entry of output.split("\0")) {
27
- const eq = entry.indexOf("=");
28
- if (eq > 0)
29
- env[entry.slice(0, eq)] = entry.slice(eq + 1);
19
+ async function captureShellEnvAsync(shell) {
20
+ return new Promise((resolve) => {
21
+ try {
22
+ const child = spawn(shell, ["-l", "-c", "env -0"], {
23
+ stdio: ["ignore", "pipe", "ignore"],
24
+ timeout: 5000,
25
+ });
26
+ let output = "";
27
+ child.stdout?.on("data", (data) => {
28
+ output += data.toString("utf-8");
29
+ });
30
+ child.on("close", (code) => {
31
+ if (code !== 0 || !output) {
32
+ resolve({}); // Return empty to trigger fallback
33
+ return;
34
+ }
35
+ const env = {};
36
+ for (const entry of output.split("\0")) {
37
+ const eq = entry.indexOf("=");
38
+ if (eq > 0)
39
+ env[entry.slice(0, eq)] = entry.slice(eq + 1);
40
+ }
41
+ resolve(env);
42
+ });
43
+ child.on("error", () => {
44
+ resolve({}); // Return empty to trigger fallback
45
+ });
46
+ // Safety timeout
47
+ setTimeout(() => {
48
+ child.kill("SIGTERM");
49
+ resolve({});
50
+ }, 5000);
30
51
  }
31
- return env;
32
- }
33
- catch {
34
- // Fallback: use Node's own environment
35
- const env = {};
36
- for (const [k, v] of Object.entries(process.env)) {
37
- if (v !== undefined)
38
- env[k] = v;
52
+ catch {
53
+ resolve({});
54
+ }
55
+ });
56
+ }
57
+ /**
58
+ * Merge captured shell env into base env, only adding keys that don't exist.
59
+ * This preserves any runtime modifications while adding missing shell vars.
60
+ */
61
+ function mergeShellEnv(baseEnv, shellEnv) {
62
+ const merged = { ...baseEnv };
63
+ for (const [key, value] of Object.entries(shellEnv)) {
64
+ // Only add if key doesn't exist or is empty in base env
65
+ if (!(key in merged) || !merged[key]) {
66
+ merged[key] = value;
39
67
  }
40
- return env;
41
68
  }
69
+ return merged;
42
70
  }
43
71
  function parseArgs(argv) {
44
72
  // Priority: CLI args > Environment variables > Config file > Defaults
@@ -111,7 +139,7 @@ Inside the shell:
111
139
  }
112
140
  return { agentCommand, agentArgs, shell, model, extensions };
113
141
  }
114
- function formatAgentInfo(agentInfo, model) {
142
+ function formatAgentInfo(agentInfo, model, thoughtLevel) {
115
143
  const name = agentInfo.name.replace(/-acp$/, "").replace(/-/g, " ");
116
144
  let infoStr = `${p.dim}${name}${p.reset}`;
117
145
  if (model) {
@@ -121,17 +149,51 @@ function formatAgentInfo(agentInfo, model) {
121
149
  .replace(/^google\//i, "");
122
150
  infoStr += ` ${p.dim}(${cleanModel})${p.reset}`;
123
151
  }
152
+ if (thoughtLevel) {
153
+ // Clean up verbose mode names like "Thinking: medium" → "medium"
154
+ const label = thoughtLevel.replace(/^Thinking:\s*/i, "");
155
+ infoStr += ` ${p.dim}[${label}]${p.reset}`;
156
+ }
124
157
  return `${infoStr} ${p.success}●${p.reset}`;
125
158
  }
126
159
  async function main() {
160
+ // Set up signal handlers before any terminal operations.
161
+ // Ignore SIGTTOU to prevent suspension when modifying terminal settings.
162
+ process.on("SIGTTOU", () => { });
163
+ // Also ignore SIGTTIN which can occur when reading from terminal while backgrounded.
164
+ process.on("SIGTTIN", () => { });
127
165
  const config = parseArgs(process.argv.slice(2));
128
- // Capture the user's shell environment (picks up vars from .zshrc/.bashrc
129
- // that the Node process doesn't have)
130
- config.shellEnv = captureShellEnv(config.shell || process.env.SHELL || "/bin/bash");
166
+ // Start with current process environment (fast, non-blocking)
167
+ // We'll enrich it with shell env asynchronously in the background
168
+ const baseEnv = {};
169
+ for (const [k, v] of Object.entries(process.env)) {
170
+ if (v !== undefined)
171
+ baseEnv[k] = v;
172
+ }
173
+ config.shellEnv = baseEnv;
174
+ // Asynchronously capture full shell environment without blocking startup
175
+ const shellPath = config.shell || process.env.SHELL || "/bin/bash";
176
+ captureShellEnvAsync(shellPath).then((shellEnv) => {
177
+ if (Object.keys(shellEnv).length > 0) {
178
+ const merged = mergeShellEnv(config.shellEnv, shellEnv);
179
+ config.shellEnv = merged;
180
+ if (process.env.DEBUG) {
181
+ console.error('[agent-sh] Shell environment enriched asynchronously');
182
+ }
183
+ }
184
+ }).catch(() => {
185
+ // Ignore errors, we already have process.env as fallback
186
+ });
187
+ if (process.env.DEBUG) {
188
+ console.error('[agent-sh] Using current process environment (async enrichment pending)');
189
+ }
131
190
  // ── Core (frontend-agnostic) ──────────────────────────────────
132
191
  const core = createCore(config);
133
192
  const { bus, client } = core;
134
193
  // ── Interactive frontend ──────────────────────────────────────
194
+ if (process.env.DEBUG) {
195
+ console.error('[agent-sh] Setting up interactive frontend...');
196
+ }
135
197
  process.stdout.write(`\x1b]0;agent-sh\x07`);
136
198
  const cols = process.stdout.columns || 80;
137
199
  const rows = process.stdout.rows || 24;
@@ -143,6 +205,12 @@ async function main() {
143
205
  }
144
206
  process.exit(0);
145
207
  };
208
+ if (process.env.DEBUG) {
209
+ console.error('[agent-sh] Creating Shell...');
210
+ }
211
+ // Small delay on macOS to ensure we're fully in the foreground process group
212
+ // before spawning the PTY. This prevents SIGTTOU suspension.
213
+ await new Promise(resolve => setTimeout(resolve, 100));
146
214
  const shell = new Shell({
147
215
  bus,
148
216
  cols,
@@ -154,32 +222,86 @@ async function main() {
154
222
  const agentInfo = client.getAgentInfo();
155
223
  const model = client.getModel();
156
224
  if (agentInfo) {
157
- return { info: formatAgentInfo(agentInfo, model) };
225
+ const mode = client.getCurrentMode();
226
+ return { info: formatAgentInfo(agentInfo, model, mode?.name ?? null) };
158
227
  }
159
228
  }
160
229
  return { info: "" };
161
230
  },
162
231
  });
232
+ if (process.env.DEBUG) {
233
+ console.error('[agent-sh] Shell created');
234
+ }
163
235
  // ── Extensions ────────────────────────────────────────────────
236
+ if (process.env.DEBUG) {
237
+ console.error('[agent-sh] Setting up extensions...');
238
+ }
164
239
  const extCtx = core.extensionContext({ quit: cleanup });
165
240
  tuiRenderer(extCtx);
166
241
  slashCommands(extCtx);
167
242
  fileAutocomplete(extCtx);
168
243
  shellRecall(extCtx);
169
- // Shell-exec: start the Unix socket bridge so the MCP server can
170
- // route user_shell tool calls to the PTY via the EventBus.
244
+ // Shell-exec: start the Unix domain socket bridge so agent extensions
245
+ // and MCP servers can route tool calls to the PTY via the EventBus.
171
246
  const tmpDir = shell.getTmpDir();
172
247
  if (tmpDir) {
248
+ if (process.env.DEBUG) {
249
+ console.error('[agent-sh] Starting shell-exec socket server...');
250
+ }
173
251
  shellExec(extCtx, { socketPath: `${tmpDir}/shell.sock` });
174
252
  }
175
- await loadExtensions(extCtx, config.extensions);
253
+ // Load extensions with timeout to prevent blocking startup
254
+ if (process.env.DEBUG) {
255
+ console.error('[agent-sh] Loading extensions...');
256
+ }
257
+ const loadExtensionsTimeoutMs = 10000; // 10 seconds
258
+ await Promise.race([
259
+ loadExtensions(extCtx, config.extensions),
260
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Extension loading timeout after ${loadExtensionsTimeoutMs}ms`)), loadExtensionsTimeoutMs)),
261
+ ]).catch((err) => {
262
+ console.error(`Warning: ${err.message}`);
263
+ });
264
+ if (process.env.DEBUG) {
265
+ console.error('[agent-sh] Extensions loaded');
266
+ }
176
267
  // ── Agent connection (async — don't block shell startup) ──────
177
- core.start().catch((err) => {
268
+ const agentStartTimeoutMs = 35000; // 35 seconds (slightly longer than internal timeouts)
269
+ Promise.race([
270
+ core.start(),
271
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Agent connection timeout`)), agentStartTimeoutMs)),
272
+ ]).catch((err) => {
178
273
  console.error(`Failed to connect to ${config.agentCommand}:`, err);
179
274
  });
180
275
  // ── Terminal lifecycle ────────────────────────────────────────
181
276
  process.on("SIGTERM", cleanup);
182
277
  process.on("SIGHUP", cleanup);
278
+ // Handle terminal stop/resume signals properly
279
+ process.on("SIGTSTP", () => {
280
+ // Handle Ctrl+Z - suspend the entire process group
281
+ // Restore terminal state before suspending
282
+ if (process.stdin.isTTY) {
283
+ try {
284
+ process.stdin.setRawMode(false);
285
+ }
286
+ catch {
287
+ // Ignore
288
+ }
289
+ }
290
+ // Re-send SIGSTOP to actually suspend
291
+ process.kill(process.pid, "SIGSTOP");
292
+ });
293
+ process.on("SIGCONT", () => {
294
+ // Re-acquire terminal when brought back to foreground
295
+ if (process.stdin.isTTY) {
296
+ try {
297
+ // Ensure we reacquire controlling terminal
298
+ process.stdin.setRawMode(true);
299
+ }
300
+ catch {
301
+ // May fail if stdin is not a TTY
302
+ }
303
+ }
304
+ });
183
305
  process.stdout.on("resize", () => {
184
306
  shell.resize(process.stdout.columns || 80, process.stdout.rows || 24);
185
307
  });
@@ -190,10 +312,35 @@ async function main() {
190
312
  }
191
313
  process.exit(e.exitCode);
192
314
  });
193
- if (process.stdin.isTTY) {
194
- process.stdin.setRawMode(true);
315
+ // Set up stdin - resume after all event listeners are in place
316
+ if (process.env.DEBUG) {
317
+ console.error('[agent-sh] Resuming stdin...');
195
318
  }
196
319
  process.stdin.resume();
320
+ // Set raw mode after resume to avoid SIGTTOU issues
321
+ if (process.stdin.isTTY) {
322
+ if (process.env.DEBUG) {
323
+ console.error('[agent-sh] Setting raw mode...');
324
+ }
325
+ // Use setImmediate to ensure we're in the next tick
326
+ setImmediate(() => {
327
+ try {
328
+ process.stdin.setRawMode(true);
329
+ if (process.env.DEBUG) {
330
+ console.error('[agent-sh] Raw mode enabled');
331
+ }
332
+ }
333
+ catch (err) {
334
+ if (process.env.DEBUG) {
335
+ console.error(`[agent-sh] Failed to set raw mode: ${err}`);
336
+ }
337
+ // May fail if process is in background; SIGTTOU handler prevents suspension
338
+ }
339
+ });
340
+ }
341
+ if (process.env.DEBUG) {
342
+ console.error('[agent-sh] Startup complete');
343
+ }
197
344
  }
198
345
  main().catch((err) => {
199
346
  console.error("Fatal:", err);
@@ -22,6 +22,11 @@ export declare class InputHandler {
22
22
  private autocompleteIndex;
23
23
  private autocompleteItems;
24
24
  private autocompleteLines;
25
+ private history;
26
+ private historyIndex;
27
+ private savedBuffer;
28
+ private promptWrappedLines;
29
+ private escapeTimer;
25
30
  private bus;
26
31
  private onShowAgentInfo;
27
32
  constructor(opts: {
@@ -32,11 +37,15 @@ export declare class InputHandler {
32
37
  model?: string;
33
38
  };
34
39
  });
40
+ private loadHistory;
41
+ private saveHistory;
35
42
  /** Write the agent prompt line with cursor at the correct position. */
36
43
  private writeAgentPromptLine;
37
44
  handleInput(data: string): void;
38
45
  private enterAgentInputMode;
39
46
  private exitAgentInputMode;
47
+ /** Move to the start of the prompt area and clear everything below. */
48
+ private clearPromptArea;
40
49
  printPrompt(): void;
41
50
  private renderAgentInput;
42
51
  private updateAutocomplete;
@@ -45,4 +54,5 @@ export declare class InputHandler {
45
54
  private dismissAutocomplete;
46
55
  private clearAutocompleteLines;
47
56
  private handleAgentInput;
57
+ private processAgentActions;
48
58
  }
@@ -1,6 +1,10 @@
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";
3
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");
4
8
  export class InputHandler {
5
9
  ctx;
6
10
  lineBuffer = "";
@@ -10,24 +14,105 @@ export class InputHandler {
10
14
  autocompleteIndex = 0;
11
15
  autocompleteItems = [];
12
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;
13
22
  bus;
14
23
  onShowAgentInfo;
15
24
  constructor(opts) {
16
25
  this.ctx = opts.ctx;
17
26
  this.bus = opts.bus;
18
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
+ }
43
+ }
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
+ }
19
54
  }
20
55
  /** Write the agent prompt line with cursor at the correct position. */
21
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");
22
64
  const agentInfo = this.onShowAgentInfo();
23
65
  const infoPrefix = agentInfo.info ? `${agentInfo.info} ` : "";
24
66
  const promptPrefix = infoPrefix + p.warning + p.bold + "❯ " + p.reset;
25
- const bufferText = showBuffer ? p.accent + this.editor.buffer + p.reset : "";
26
- process.stdout.write("\r\x1b[2K" + promptPrefix + bufferText);
27
- // Position cursor within the buffer (not always at end)
28
- if (showBuffer && this.editor.cursor < this.editor.buffer.length) {
29
- const charsAfterCursor = this.editor.buffer.length - this.editor.cursor;
30
- process.stdout.write(`\x1b[${charsAfterCursor}D`);
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`);
31
116
  }
32
117
  }
33
118
  handleInput(data) {
@@ -41,10 +126,15 @@ export class InputHandler {
41
126
  }
42
127
  return;
43
128
  }
44
- // 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
45
130
  if (data.length === 1 && data.charCodeAt(0) < 32 && !this.agentInputMode) {
46
131
  const code = data.charCodeAt(0);
47
- // 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
48
138
  if (code !== 0x0d && code !== 0x03 && code !== 0x04 && code !== 0x09) {
49
139
  this.bus.emit("input:keypress", { key: data });
50
140
  }
@@ -95,15 +185,28 @@ export class InputHandler {
95
185
  enterAgentInputMode() {
96
186
  this.agentInputMode = true;
97
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");
98
191
  this.writeAgentPromptLine(false);
99
192
  }
100
193
  exitAgentInputMode() {
101
194
  this.dismissAutocomplete();
102
195
  this.agentInputMode = false;
103
196
  this.editor.clear();
104
- process.stdout.write("\r\x1b[2K");
197
+ // Disable kitty keyboard protocol
198
+ process.stdout.write("\x1b[<u");
199
+ this.clearPromptArea();
105
200
  this.printPrompt();
106
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
+ }
107
210
  printPrompt() {
108
211
  this.ctx.redrawPrompt();
109
212
  }
@@ -197,10 +300,29 @@ export class InputHandler {
197
300
  this.autocompleteLines = 0;
198
301
  }
199
302
  handleAgentInput(data) {
303
+ // Clear any pending escape timer — new data arrived
304
+ if (this.escapeTimer) {
305
+ clearTimeout(this.escapeTimer);
306
+ this.escapeTimer = null;
307
+ }
200
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) {
201
322
  for (const act of actions) {
202
323
  switch (act.action) {
203
324
  case "changed":
325
+ this.historyIndex = -1;
204
326
  this.autocompleteIndex = 0;
205
327
  this.renderAgentInput();
206
328
  break;
@@ -209,8 +331,18 @@ export class InputHandler {
209
331
  this.applyAutocomplete();
210
332
  }
211
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 = "";
212
343
  this.clearAutocompleteLines();
213
- process.stdout.write("\r\x1b[2K");
344
+ this.clearPromptArea();
345
+ process.stdout.write("\x1b[<u"); // disable kitty keyboard protocol
214
346
  this.agentInputMode = false;
215
347
  this.editor.clear();
216
348
  this.dismissAutocomplete();
@@ -247,6 +379,9 @@ export class InputHandler {
247
379
  this.applyAutocomplete();
248
380
  }
249
381
  break;
382
+ case "shift+tab":
383
+ this.bus.emit("config:cycle", {});
384
+ break;
250
385
  case "arrow-up":
251
386
  if (this.autocompleteActive) {
252
387
  this.autocompleteIndex =
@@ -257,6 +392,18 @@ export class InputHandler {
257
392
  this.writeAgentPromptLine();
258
393
  this.renderAutocomplete();
259
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
+ }
260
407
  break;
261
408
  case "arrow-down":
262
409
  if (this.autocompleteActive) {
@@ -268,6 +415,18 @@ export class InputHandler {
268
415
  this.writeAgentPromptLine();
269
416
  this.renderAutocomplete();
270
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
+ }
271
430
  break;
272
431
  }
273
432
  }