agent-sh 0.8.0 → 0.10.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.
Files changed (106) hide show
  1. package/README.md +27 -43
  2. package/dist/agent/agent-loop.d.ts +69 -6
  3. package/dist/agent/agent-loop.js +954 -153
  4. package/dist/agent/conversation-state.d.ts +74 -21
  5. package/dist/agent/conversation-state.js +361 -150
  6. package/dist/agent/history-file.d.ts +13 -4
  7. package/dist/agent/history-file.js +110 -36
  8. package/dist/agent/nuclear-form.d.ts +28 -3
  9. package/dist/agent/nuclear-form.js +88 -6
  10. package/dist/agent/skills.d.ts +2 -4
  11. package/dist/agent/skills.js +10 -4
  12. package/dist/agent/subagent.d.ts +23 -0
  13. package/dist/agent/subagent.js +53 -11
  14. package/dist/agent/system-prompt.d.ts +37 -5
  15. package/dist/agent/system-prompt.js +100 -67
  16. package/dist/{token-budget.d.ts → agent/token-budget.d.ts} +5 -4
  17. package/dist/{token-budget.js → agent/token-budget.js} +15 -20
  18. package/dist/agent/tool-protocol.d.ts +105 -0
  19. package/dist/agent/tool-protocol.js +551 -0
  20. package/dist/agent/tools/bash.js +3 -3
  21. package/dist/agent/tools/edit-file.js +9 -6
  22. package/dist/agent/tools/glob.js +4 -2
  23. package/dist/agent/tools/grep.js +27 -3
  24. package/dist/agent/tools/ls.js +5 -6
  25. package/dist/agent/types.d.ts +22 -2
  26. package/dist/context-manager.d.ts +17 -0
  27. package/dist/context-manager.js +37 -4
  28. package/dist/core.d.ts +7 -7
  29. package/dist/core.js +99 -196
  30. package/dist/event-bus.d.ts +85 -2
  31. package/dist/event-bus.js +20 -1
  32. package/dist/executor.d.ts +4 -3
  33. package/dist/executor.js +18 -15
  34. package/dist/extension-loader.d.ts +5 -0
  35. package/dist/extension-loader.js +143 -19
  36. package/dist/extensions/agent-backend.d.ts +14 -0
  37. package/dist/extensions/agent-backend.js +188 -0
  38. package/dist/extensions/command-suggest.d.ts +3 -3
  39. package/dist/extensions/command-suggest.js +4 -3
  40. package/dist/extensions/index.d.ts +19 -0
  41. package/dist/extensions/index.js +24 -0
  42. package/dist/extensions/slash-commands.d.ts +1 -1
  43. package/dist/extensions/slash-commands.js +30 -10
  44. package/dist/extensions/tui-renderer.js +117 -113
  45. package/dist/index.js +39 -26
  46. package/dist/settings.d.ts +40 -3
  47. package/dist/settings.js +57 -10
  48. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +3 -2
  49. package/dist/{input-handler.js → shell/input-handler.js} +111 -85
  50. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  51. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  52. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  53. package/dist/{shell.js → shell/shell.js} +39 -8
  54. package/dist/types.d.ts +61 -10
  55. package/dist/utils/ansi.d.ts +5 -0
  56. package/dist/utils/ansi.js +1 -1
  57. package/dist/utils/compositor.d.ts +67 -0
  58. package/dist/utils/compositor.js +116 -0
  59. package/dist/utils/diff-renderer.d.ts +9 -0
  60. package/dist/utils/diff-renderer.js +312 -146
  61. package/dist/utils/diff.d.ts +21 -2
  62. package/dist/utils/diff.js +165 -89
  63. package/dist/utils/floating-panel.d.ts +2 -0
  64. package/dist/utils/floating-panel.js +30 -14
  65. package/dist/utils/handler-registry.d.ts +31 -10
  66. package/dist/utils/handler-registry.js +58 -16
  67. package/dist/utils/line-editor.d.ts +33 -3
  68. package/dist/utils/line-editor.js +221 -44
  69. package/dist/utils/markdown.d.ts +1 -0
  70. package/dist/utils/markdown.js +1 -1
  71. package/dist/utils/message-utils.d.ts +35 -0
  72. package/dist/utils/message-utils.js +75 -0
  73. package/dist/utils/terminal-buffer.d.ts +5 -1
  74. package/dist/utils/terminal-buffer.js +18 -2
  75. package/dist/utils/tool-display.d.ts +1 -1
  76. package/dist/utils/tool-display.js +4 -4
  77. package/dist/utils/tool-interactive.d.ts +12 -0
  78. package/dist/utils/tool-interactive.js +53 -0
  79. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  80. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  81. package/examples/extensions/ash-acp-bridge/src/index.ts +574 -0
  82. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  83. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  84. package/examples/extensions/ash-mcp-bridge/index.ts +164 -0
  85. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  86. package/examples/extensions/claude-code-bridge/index.ts +198 -51
  87. package/examples/extensions/claude-code-bridge/package.json +1 -0
  88. package/examples/extensions/interactive-prompts.ts +98 -112
  89. package/examples/extensions/overlay-agent.ts +84 -38
  90. package/examples/extensions/peer-mesh.ts +565 -0
  91. package/examples/extensions/pi-bridge/index.ts +2 -2
  92. package/examples/extensions/questionnaire.ts +260 -0
  93. package/examples/extensions/subagents.ts +19 -4
  94. package/examples/extensions/terminal-buffer.ts +32 -53
  95. package/examples/extensions/tmux-pane.ts +307 -0
  96. package/examples/extensions/user-shell.ts +136 -0
  97. package/examples/extensions/web-access.ts +335 -0
  98. package/package.json +44 -2
  99. package/dist/agent/tools/display.d.ts +0 -13
  100. package/dist/agent/tools/display.js +0 -70
  101. package/dist/agent/tools/user-shell.d.ts +0 -13
  102. package/dist/agent/tools/user-shell.js +0 -87
  103. package/dist/extensions/overlay-agent.d.ts +0 -14
  104. package/dist/extensions/overlay-agent.js +0 -147
  105. package/dist/extensions/terminal-buffer.d.ts +0 -14
  106. package/dist/extensions/terminal-buffer.js +0 -125
@@ -50,20 +50,46 @@ export interface Settings {
50
50
  historyMaxBytes?: number;
51
51
  /** Number of prior history entries to load on startup (default: 50). */
52
52
  historyStartupEntries?: number;
53
- /** Max nuclear entries kept in-context before flushing to history file (default: 200). */
54
- nuclearMaxEntries?: number;
53
+ /** Auto-compact threshold as fraction of conversation budget (0-1, default 0.5). */
54
+ autoCompactThreshold?: number;
55
55
  /** Max command output lines shown inline in TUI. */
56
56
  maxCommandOutputLines?: number;
57
57
  /** Max read tool output lines shown inline in TUI (0 = hide). */
58
58
  readOutputMaxLines?: number;
59
- /** Max diff lines shown before "ctrl+o to expand". */
59
+ /** Max diff lines rendered in the TUI (Infinity = no limit). */
60
60
  diffMaxLines?: number;
61
+ /** Tool protocol:
62
+ * "api" — all tools sent with full schema.
63
+ * "deferred" — extensions dispatched through `use_extension(name, args)` meta-tool.
64
+ * "deferred-lookup" — extensions loaded on demand via `load_tool(names[])`; once loaded, callable as first-class tools.
65
+ * "inline" — tools described as text.
66
+ */
67
+ toolMode?: "api" | "deferred" | "deferred-lookup" | "inline";
61
68
  /** Additional directories to scan for skills (supports ~ expansion). */
62
69
  skillPaths?: string[];
70
+ /**
71
+ * Enable the "diagnose" tool — lets the agent evaluate JavaScript
72
+ * expressions against its own runtime state. Powerful for introspection
73
+ * (e.g. this.conversation.turns.length) but grants arbitrary code
74
+ * execution within the agent process. Off by default because the
75
+ * agent already has unrestricted bash access — this is a convenience,
76
+ * not a new capability.
77
+ */
78
+ diagnose?: boolean;
63
79
  /** Show a startup banner when agent-sh launches. */
64
80
  startupBanner?: boolean;
65
81
  /** Show a subtle agent-sh indicator in the shell prompt. */
66
82
  promptIndicator?: boolean;
83
+ /** Names of built-in extensions to disable (e.g. ["command-suggest"]). */
84
+ disabledBuiltins?: string[];
85
+ /**
86
+ * Names of user extensions in ~/.agent-sh/extensions/ to skip when
87
+ * auto-discovering. Match by basename without extension for files
88
+ * (e.g. "peer-mesh" matches peer-mesh.ts), or by directory name for
89
+ * directory-style extensions (e.g. "superash" matches superash/index.ts).
90
+ * Beats having to rename files to .disabled every time.
91
+ */
92
+ disabledExtensions?: string[];
67
93
  }
68
94
  declare const DEFAULTS: Required<Settings>;
69
95
  /** Load settings from disk (cached after first call). */
@@ -81,6 +107,17 @@ export declare function getSettings(): Settings & typeof DEFAULTS;
81
107
  export declare function getExtensionSettings<T extends Record<string, unknown>>(namespace: string, defaults: T): T;
82
108
  /** Reset cached settings (for testing or after external edit). */
83
109
  export declare function reloadSettings(): void;
110
+ /**
111
+ * Deep-merge a patch into ~/.agent-sh/settings.json on disk.
112
+ *
113
+ * Reads the raw file (preserving unknown keys), merges the patch, writes back
114
+ * with 2-space indentation, and clears the cache so subsequent getSettings()
115
+ * calls see the new values.
116
+ *
117
+ * Used by runtime controls (`/model`, `/backend`) that want their selection
118
+ * to persist as the default across restarts.
119
+ */
120
+ export declare function updateSettings(patch: Record<string, unknown>): void;
84
121
  /**
85
122
  * Expand $ENV_VAR references in a string.
86
123
  * Supports $VAR and ${VAR} syntax.
package/dist/settings.js CHANGED
@@ -14,23 +14,27 @@ const DEFAULTS = {
14
14
  historySize: 500,
15
15
  providers: {},
16
16
  defaultProvider: undefined,
17
- defaultBackend: "agent-sh",
17
+ defaultBackend: "ash",
18
+ toolMode: "api",
18
19
  contextWindowSize: 20,
19
- contextBudget: 16384,
20
- shellTruncateThreshold: 10,
21
- shellHeadLines: 5,
22
- shellTailLines: 5,
23
- recallExpandMaxLines: 100,
20
+ contextBudget: 32768,
21
+ shellTruncateThreshold: 20,
22
+ shellHeadLines: 10,
23
+ shellTailLines: 10,
24
+ recallExpandMaxLines: 500,
24
25
  shellContextRatio: 0.35,
25
- historyMaxBytes: 102400,
26
- historyStartupEntries: 50,
27
- nuclearMaxEntries: 200,
26
+ historyMaxBytes: 104857600, // 100MB — history is only accessed via search/expand, never loaded wholesale
27
+ historyStartupEntries: 100,
28
+ autoCompactThreshold: 0.5,
28
29
  maxCommandOutputLines: 3,
29
30
  readOutputMaxLines: 10,
30
- diffMaxLines: 20,
31
+ diffMaxLines: Infinity,
31
32
  skillPaths: [],
33
+ diagnose: false,
32
34
  startupBanner: true,
33
35
  promptIndicator: true,
36
+ disabledBuiltins: [],
37
+ disabledExtensions: [],
34
38
  };
35
39
  let cached = null;
36
40
  /** Load settings from disk (cached after first call). */
@@ -71,6 +75,49 @@ export function getExtensionSettings(namespace, defaults) {
71
75
  export function reloadSettings() {
72
76
  cached = null;
73
77
  }
78
+ /**
79
+ * Deep-merge a patch into ~/.agent-sh/settings.json on disk.
80
+ *
81
+ * Reads the raw file (preserving unknown keys), merges the patch, writes back
82
+ * with 2-space indentation, and clears the cache so subsequent getSettings()
83
+ * calls see the new values.
84
+ *
85
+ * Used by runtime controls (`/model`, `/backend`) that want their selection
86
+ * to persist as the default across restarts.
87
+ */
88
+ export function updateSettings(patch) {
89
+ let existing = {};
90
+ try {
91
+ const raw = fs.readFileSync(SETTINGS_PATH, "utf-8");
92
+ existing = JSON.parse(raw);
93
+ }
94
+ catch {
95
+ // file missing or unreadable — start fresh
96
+ }
97
+ const merged = deepMerge(existing, patch);
98
+ try {
99
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
100
+ fs.writeFileSync(SETTINGS_PATH, JSON.stringify(merged, null, 2) + "\n", "utf-8");
101
+ cached = null;
102
+ }
103
+ catch (err) {
104
+ console.error(`[agent-sh] Warning: failed to update ${SETTINGS_PATH}: ${err.message}`);
105
+ }
106
+ }
107
+ function deepMerge(target, source) {
108
+ const out = { ...target };
109
+ for (const [key, val] of Object.entries(source)) {
110
+ const existing = out[key];
111
+ if (val !== null && typeof val === "object" && !Array.isArray(val) &&
112
+ existing !== null && typeof existing === "object" && !Array.isArray(existing)) {
113
+ out[key] = deepMerge(existing, val);
114
+ }
115
+ else {
116
+ out[key] = val;
117
+ }
118
+ }
119
+ return out;
120
+ }
74
121
  /**
75
122
  * Expand $ENV_VAR references in a string.
76
123
  * Supports $VAR and ${VAR} syntax.
@@ -1,4 +1,4 @@
1
- import type { EventBus } from "./event-bus.js";
1
+ import type { EventBus } from "../event-bus.js";
2
2
  /**
3
3
  * Narrow contract between InputHandler and its host (Shell).
4
4
  * InputHandler never touches the PTY or EventBus directly —
@@ -28,7 +28,8 @@ export declare class InputHandler {
28
28
  private history;
29
29
  private historyIndex;
30
30
  private savedBuffer;
31
- private promptWrappedLines;
31
+ private cursorRowsBelow;
32
+ private cursorTermCol;
32
33
  private escapeTimer;
33
34
  private bus;
34
35
  private onShowAgentInfo;
@@ -1,10 +1,10 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { visibleLen } from "./utils/ansi.js";
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
+ import { visibleLen } from "../utils/ansi.js";
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, "input-history");
8
8
  export class InputHandler {
9
9
  ctx;
10
10
  lineBuffer = "";
@@ -20,7 +20,8 @@ export class InputHandler {
20
20
  history = [];
21
21
  historyIndex = -1; // -1 = not browsing history
22
22
  savedBuffer = ""; // buffer saved when entering history
23
- promptWrappedLines = 0; // extra lines from terminal wrapping
23
+ cursorRowsBelow = 0; // rows from prompt top to cursor row
24
+ cursorTermCol = 1; // 1-indexed terminal column of cursor
24
25
  escapeTimer = null;
25
26
  bus;
26
27
  onShowAgentInfo;
@@ -72,9 +73,10 @@ export class InputHandler {
72
73
  /** Write the mode prompt line with cursor at the correct position. */
73
74
  writeModePromptLine(showBuffer = true) {
74
75
  const termW = process.stdout.columns || 80;
75
- // Move cursor to the start of the prompt area (first line of wrapped content)
76
- if (this.promptWrappedLines > 0) {
77
- process.stdout.write(`\x1b[${this.promptWrappedLines}A`);
76
+ // Move cursor to the start of the prompt area.
77
+ // We know exactly how many rows below the top the cursor currently sits.
78
+ if (this.cursorRowsBelow > 0) {
79
+ process.stdout.write(`\x1b[${this.cursorRowsBelow}A`);
78
80
  }
79
81
  // Clear from here to end of screen — removes current + all wrapped lines below
80
82
  process.stdout.write("\r\x1b[J");
@@ -86,38 +88,39 @@ export class InputHandler {
86
88
  const icon = this.activeMode?.promptIcon ?? "❯";
87
89
  const promptPrefix = infoPrefix + p.warning + p.bold + icon + " " + p.reset;
88
90
  const promptVisLen = visibleLen(infoPrefix) + visibleLen(icon) + 1; // icon + space
89
- if (!showBuffer || !this.editor.buffer.includes("\n")) {
90
- // Single-line: simple rendering
91
- const bufferText = showBuffer ? p.accent + this.editor.buffer + p.reset : "";
92
- process.stdout.write(promptPrefix + bufferText);
93
- const bufferVisLen = showBuffer ? this.editor.buffer.length : 0;
94
- const totalVisLen = promptVisLen + bufferVisLen;
95
- this.promptWrappedLines = totalVisLen > 0 ? Math.floor((totalVisLen - 1) / termW) : 0;
96
- // Position cursor within the buffer
97
- if (showBuffer && this.editor.cursor < this.editor.buffer.length) {
98
- const charsAfterCursor = this.editor.buffer.length - this.editor.cursor;
99
- process.stdout.write(`\x1b[${charsAfterCursor}D`);
100
- }
91
+ const display = showBuffer ? this.editor.displayText : "";
92
+ const dCursor = showBuffer ? this.editor.displayCursor : 0;
93
+ if (!showBuffer) {
94
+ // No buffer — just write the prompt prefix, cursor stays at end
95
+ process.stdout.write(promptPrefix);
96
+ const N = promptVisLen;
97
+ this.cursorRowsBelow = N > 0 ? Math.ceil(N / termW) - 1 : 0;
98
+ this.cursorTermCol = N === 0 ? 1 : (N % termW === 0 ? termW : (N % termW) + 1);
99
+ }
100
+ else if (!display.includes("\n")) {
101
+ // Single-line: write up to cursor, save, write rest, restore.
102
+ // The terminal handles all wrapping — no manual row/col math needed.
103
+ const before = display.slice(0, dCursor);
104
+ const after = display.slice(dCursor);
105
+ process.stdout.write(promptPrefix + p.accent + before + p.reset +
106
+ "\x1b7" + // DECSC — save cursor position
107
+ p.accent + after + p.reset +
108
+ "\x1b8" // DECRC — restore cursor position
109
+ );
110
+ // Clearing on next redraw needs total rows, so measure the full
111
+ // content width — not just up to the cursor.
112
+ const totalVisLen = promptVisLen + visibleLen(display);
113
+ this.cursorRowsBelow = totalVisLen > 0 ? Math.ceil(totalVisLen / termW) - 1 : 0;
114
+ const cursorVisCol = promptVisLen + visibleLen(before);
115
+ this.cursorTermCol = cursorVisCol === 0 ? 1 : (cursorVisCol % termW === 0 ? termW : (cursorVisCol % termW) + 1);
101
116
  }
102
117
  else {
103
- // Multi-line: render each line with continuation indent
104
- const lines = this.editor.buffer.split("\n");
118
+ // Multi-line: render each line with continuation indent.
119
+ // Same save/restore strategy — cursor position is never computed.
120
+ const lines = display.split("\n");
105
121
  const indent = " ".repeat(promptVisLen);
106
- let totalTermLines = 0;
107
- for (let li = 0; li < lines.length; li++) {
108
- const prefix = li === 0 ? promptPrefix : indent;
109
- const prefixVisLen = li === 0 ? promptVisLen : promptVisLen;
110
- const lineText = lines[li];
111
- process.stdout.write(prefix + p.accent + lineText + p.reset);
112
- if (li < lines.length - 1)
113
- process.stdout.write("\n");
114
- // Count terminal lines this logical line occupies
115
- const lineVisLen = prefixVisLen + lineText.length;
116
- totalTermLines += lineVisLen > 0 ? Math.ceil(lineVisLen / termW) : 1;
117
- }
118
- this.promptWrappedLines = totalTermLines - 1;
119
- // Position cursor: find which line and column the cursor is on
120
- let charsRemaining = this.editor.cursor;
122
+ // Locate cursor: which logical line and offset within it.
123
+ let charsRemaining = dCursor;
121
124
  let cursorLine = 0;
122
125
  for (let li = 0; li < lines.length; li++) {
123
126
  if (charsRemaining <= lines[li].length) {
@@ -127,13 +130,35 @@ export class InputHandler {
127
130
  charsRemaining -= lines[li].length + 1; // +1 for \n
128
131
  cursorLine = li + 1;
129
132
  }
130
- // Move from end position to cursor position
131
- const linesFromEnd = lines.length - 1 - cursorLine;
132
- if (linesFromEnd > 0) {
133
- process.stdout.write(`\x1b[${linesFromEnd}A`);
133
+ let output = "";
134
+ let cursorRowFromTop = 0;
135
+ let rowsSoFar = 0;
136
+ for (let li = 0; li < lines.length; li++) {
137
+ const prefix = li === 0 ? promptPrefix : indent;
138
+ const lineText = lines[li];
139
+ const lineVisLen = promptVisLen + visibleLen(lineText);
140
+ const lineTermRows = lineVisLen > 0 ? Math.ceil(lineVisLen / termW) : 1;
141
+ if (li === cursorLine) {
142
+ // Split this line at the cursor.
143
+ const before = lineText.slice(0, charsRemaining);
144
+ const after = lineText.slice(charsRemaining);
145
+ output += prefix + p.accent + before + p.reset;
146
+ output += "\x1b7"; // DECSC — save cursor position
147
+ output += p.accent + after + p.reset;
148
+ const beforeVisCol = promptVisLen + visibleLen(before);
149
+ cursorRowFromTop = rowsSoFar + (beforeVisCol > 0 ? Math.ceil(beforeVisCol / termW) - 1 : 0);
150
+ this.cursorTermCol = beforeVisCol === 0 ? 1 : (beforeVisCol % termW === 0 ? termW : (beforeVisCol % termW) + 1);
151
+ }
152
+ else {
153
+ output += prefix + p.accent + lineText + p.reset;
154
+ }
155
+ if (li < lines.length - 1)
156
+ output += "\n";
157
+ rowsSoFar += lineTermRows;
134
158
  }
135
- const cursorCol = (cursorLine === 0 ? promptVisLen : promptVisLen) + charsRemaining;
136
- process.stdout.write(`\r\x1b[${cursorCol}C`);
159
+ process.stdout.write(output + "\x1b8"); // DECRC restore cursor position
160
+ // Total rows (not cursor row) so next redraw clears the whole area.
161
+ this.cursorRowsBelow = rowsSoFar - 1 > 0 ? rowsSoFar - 1 : 0;
137
162
  }
138
163
  }
139
164
  handleInput(data) {
@@ -249,26 +274,29 @@ export class InputHandler {
249
274
  this.activeMode = mode;
250
275
  this.editor.clear();
251
276
  // Enable kitty keyboard protocol (progressive enhancement flag 1)
252
- // so Shift+Enter sends \x1b[13;2u instead of plain \r
253
- process.stdout.write("\x1b[>1u");
277
+ // so Shift+Enter sends \x1b[13;2u instead of plain \r.
278
+ // Enable bracket paste mode so pasted text doesn't trigger submit.
279
+ process.stdout.write("\x1b[>1u\x1b[?2004h");
254
280
  this.writeModePromptLine(false);
255
281
  }
256
282
  exitMode() {
257
283
  this.dismissAutocomplete();
258
284
  this.activeMode = null;
259
285
  this.editor.clear();
260
- // Disable kitty keyboard protocol
261
- process.stdout.write("\x1b[<u");
286
+ // Disable kitty keyboard protocol and bracket paste mode
287
+ process.stdout.write("\x1b[<u\x1b[?2004l");
262
288
  this.clearPromptArea();
289
+ this.cursorRowsBelow = 0;
290
+ this.cursorTermCol = 1;
263
291
  this.printPrompt();
264
292
  }
265
293
  /** Move to the start of the prompt area and clear everything below. */
266
294
  clearPromptArea() {
267
- if (this.promptWrappedLines > 0) {
268
- process.stdout.write(`\x1b[${this.promptWrappedLines}A`);
295
+ if (this.cursorRowsBelow > 0) {
296
+ process.stdout.write(`\x1b[${this.cursorRowsBelow}A`);
269
297
  }
270
298
  process.stdout.write("\r\x1b[J");
271
- this.promptWrappedLines = 0;
299
+ this.cursorRowsBelow = 0;
272
300
  }
273
301
  printPrompt() {
274
302
  this.ctx.redrawPrompt();
@@ -294,7 +322,7 @@ export class InputHandler {
294
322
  this.updateAutocomplete();
295
323
  }
296
324
  updateAutocomplete() {
297
- const buf = this.editor.buffer;
325
+ const buf = this.editor.text;
298
326
  let command = null;
299
327
  let commandArgs = null;
300
328
  if (buf.startsWith("/")) {
@@ -342,16 +370,10 @@ export class InputHandler {
342
370
  if (this.autocompleteLines > 0) {
343
371
  process.stdout.write(`\x1b[${this.autocompleteLines}A`);
344
372
  }
345
- // Reposition cursor: must match the layout in writeModePromptLine()
346
- const agentInfo = this.onShowAgentInfo();
347
- const indicator = this.activeMode?.indicator ?? "●";
348
- const infoPrefix = agentInfo.info
349
- ? `${agentInfo.info} ${indicator} `
350
- : `${indicator} `;
351
- const icon = this.activeMode?.promptIcon ?? "❯";
352
- const promptVisLen = visibleLen(infoPrefix) + visibleLen(icon) + 1;
353
- const col = promptVisLen + this.editor.cursor;
354
- process.stdout.write(`\r\x1b[${col}C`);
373
+ // Restore cursor column use explicit column set instead of DECRC
374
+ // because writing \n above may have scrolled the terminal, which
375
+ // invalidates the absolute position saved by DECSC.
376
+ process.stdout.write(`\x1b[${this.cursorTermCol}G`);
355
377
  }
356
378
  applyAutocomplete() {
357
379
  if (!this.autocompleteActive || this.autocompleteItems.length === 0)
@@ -359,18 +381,16 @@ export class InputHandler {
359
381
  const selected = this.autocompleteItems[this.autocompleteIndex];
360
382
  if (!selected)
361
383
  return;
362
- const atPos = this.editor.buffer.lastIndexOf("@");
384
+ const atPos = this.editor.text.lastIndexOf("@");
363
385
  const isFileAc = atPos >= 0 &&
364
- (atPos === 0 || this.editor.buffer[atPos - 1] === " ") &&
365
- !this.editor.buffer.slice(atPos + 1).includes(" ");
386
+ (atPos === 0 || this.editor.text[atPos - 1] === " ") &&
387
+ !this.editor.text.slice(atPos + 1).includes(" ");
366
388
  if (isFileAc) {
367
- this.editor.buffer =
368
- this.editor.buffer.slice(0, atPos) + "@" + selected.name;
389
+ this.editor.setText(this.editor.text.slice(0, atPos) + "@" + selected.name);
369
390
  }
370
391
  else {
371
- this.editor.buffer = selected.name;
392
+ this.editor.setText(selected.name);
372
393
  }
373
- this.editor.cursor = this.editor.buffer.length;
374
394
  this.clearAutocompleteLines();
375
395
  this.autocompleteActive = false;
376
396
  this.autocompleteItems = [];
@@ -388,11 +408,12 @@ export class InputHandler {
388
408
  clearAutocompleteLines() {
389
409
  if (this.autocompleteLines <= 0)
390
410
  return;
391
- process.stdout.write("\x1b7"); // save cursor
411
+ // Use CSI B (cursor down, bounded) instead of \n to avoid scroll
392
412
  for (let i = 0; i < this.autocompleteLines; i++) {
393
- process.stdout.write("\n\x1b[2K"); // move down, clear line
413
+ process.stdout.write("\x1b[B\x1b[2K"); // move down, clear line
394
414
  }
395
- process.stdout.write("\x1b8"); // restore cursor
415
+ // Move back up and restore column with relative movement (scroll-safe)
416
+ process.stdout.write(`\x1b[${this.autocompleteLines}A\x1b[${this.cursorTermCol}G`);
396
417
  this.autocompleteLines = 0;
397
418
  }
398
419
  handleModeInput(data) {
@@ -419,8 +440,8 @@ export class InputHandler {
419
440
  switch (act.action) {
420
441
  case "changed": {
421
442
  // If the buffer is exactly a trigger char for a different mode, switch to it
422
- const switchMode = this.modes.get(this.editor.buffer);
423
- if (this.editor.buffer.length === 1 && switchMode && switchMode !== this.activeMode) {
443
+ const switchMode = this.modes.get(this.editor.text);
444
+ if (this.editor.text.length === 1 && switchMode && switchMode !== this.activeMode) {
424
445
  this.dismissAutocomplete();
425
446
  this.clearPromptArea();
426
447
  this.activeMode = switchMode;
@@ -437,10 +458,10 @@ export class InputHandler {
437
458
  if (this.autocompleteActive) {
438
459
  this.applyAutocomplete();
439
460
  }
440
- // Use editor.buffer (not act.buffer) so autocomplete selections
461
+ // Use editor.text (not act.buffer) so autocomplete selections
441
462
  // take effect — act.buffer is a stale snapshot from before
442
- // applyAutocomplete() updated the buffer.
443
- const query = this.editor.buffer.trim();
463
+ // applyAutocomplete() updated the editor.
464
+ const query = this.editor.text.trim();
444
465
  if (query) {
445
466
  // Add to history (avoid consecutive duplicates)
446
467
  if (this.history.length === 0 || this.history[this.history.length - 1] !== query) {
@@ -452,17 +473,24 @@ export class InputHandler {
452
473
  this.savedBuffer = "";
453
474
  this.clearAutocompleteLines();
454
475
  this.clearPromptArea();
455
- process.stdout.write("\x1b[<u"); // disable kitty keyboard protocol
476
+ process.stdout.write("\x1b[<u\x1b[?2004l"); // disable kitty + bracket paste
456
477
  const currentMode = this.activeMode;
457
478
  this.activeMode = null;
458
479
  this.editor.clear();
480
+ this.cursorRowsBelow = 0;
481
+ this.cursorTermCol = 1;
459
482
  this.dismissAutocomplete();
460
483
  if (query && query.startsWith("/")) {
461
484
  const spaceIdx = query.indexOf(" ");
462
485
  const name = spaceIdx === -1 ? query : query.slice(0, spaceIdx);
463
486
  const args = spaceIdx === -1 ? "" : query.slice(spaceIdx + 1).trim();
464
487
  this.bus.emit("command:execute", { name, args });
465
- this.ctx.freshPrompt();
488
+ if (currentMode.returnToSelf) {
489
+ this.enterMode(currentMode);
490
+ }
491
+ else {
492
+ this.ctx.freshPrompt();
493
+ }
466
494
  }
467
495
  else if (query) {
468
496
  this.pendingReturnMode = currentMode.returnToSelf ? currentMode.id : null;
@@ -506,14 +534,13 @@ export class InputHandler {
506
534
  }
507
535
  else if (this.history.length > 0) {
508
536
  if (this.historyIndex === -1) {
509
- this.savedBuffer = this.editor.buffer;
537
+ this.savedBuffer = this.editor.text;
510
538
  this.historyIndex = this.history.length - 1;
511
539
  }
512
540
  else if (this.historyIndex > 0) {
513
541
  this.historyIndex--;
514
542
  }
515
- this.editor.buffer = this.history[this.historyIndex];
516
- this.editor.cursor = this.editor.buffer.length;
543
+ this.editor.setText(this.history[this.historyIndex]);
517
544
  this.clearAutocompleteLines();
518
545
  this.writeModePromptLine();
519
546
  }
@@ -531,13 +558,12 @@ export class InputHandler {
531
558
  else if (this.historyIndex !== -1) {
532
559
  if (this.historyIndex < this.history.length - 1) {
533
560
  this.historyIndex++;
534
- this.editor.buffer = this.history[this.historyIndex];
561
+ this.editor.setText(this.history[this.historyIndex]);
535
562
  }
536
563
  else {
537
564
  this.historyIndex = -1;
538
- this.editor.buffer = this.savedBuffer;
565
+ this.editor.setText(this.savedBuffer);
539
566
  }
540
- this.editor.cursor = this.editor.buffer.length;
541
567
  this.clearAutocompleteLines();
542
568
  this.writeModePromptLine();
543
569
  }
@@ -1,4 +1,4 @@
1
- import type { EventBus } from "./event-bus.js";
1
+ import type { EventBus } from "../event-bus.js";
2
2
  /**
3
3
  * Parses PTY output to detect command boundaries, track cwd,
4
4
  * and emit shell events. Owns the command lifecycle state.
@@ -1,4 +1,4 @@
1
- import { stripAnsi } from "./utils/ansi.js";
1
+ import { stripAnsi } from "../utils/ansi.js";
2
2
  /**
3
3
  * Parses PTY output to detect command boundaries, track cwd,
4
4
  * and emit shell events. Owns the command lifecycle state.
@@ -1,8 +1,13 @@
1
- import type { EventBus } from "./event-bus.js";
1
+ import type { EventBus } from "../event-bus.js";
2
2
  import { type InputContext } from "./input-handler.js";
3
+ export interface ShellHandlers {
4
+ define: (name: string, fn: (...args: any[]) => any) => void;
5
+ call: (name: string, ...args: any[]) => any;
6
+ }
3
7
  export declare class Shell implements InputContext {
4
8
  private ptyProcess;
5
9
  private bus;
10
+ private handlers;
6
11
  private inputHandler;
7
12
  private outputParser;
8
13
  private paused;
@@ -14,6 +19,7 @@ export declare class Shell implements InputContext {
14
19
  private tmpDir?;
15
20
  constructor(opts: {
16
21
  bus: EventBus;
22
+ handlers: ShellHandlers;
17
23
  onShowAgentInfo?: () => {
18
24
  info: string;
19
25
  model?: string;
@@ -43,7 +49,7 @@ export declare class Shell implements InputContext {
43
49
  * Routed through shell:redraw-prompt pipe so extensions (e.g. overlay)
44
50
  * can suppress it by setting `handled: true`.
45
51
  */
46
- freshPrompt(): void;
52
+ freshPrompt(): boolean;
47
53
  onCommandEntered(command: string, cwd: string): void;
48
54
  private setupOutput;
49
55
  private setupInput;