agent-sh 0.3.1 → 0.4.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.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  Not a shell that lives in an agent — an agent that lives in a shell.
7
7
 
8
- agent-sh is a real terminal first. Every keystroke goes to a real PTY. `cd`, pipes, vim, job control — they all just work. But type `>` at the start of a line, and you're talking to an AI agent that has full context of what you've been doing: your working directory, recent commands, their output.
8
+ agent-sh is a real terminal first. Every keystroke goes to a real PTY. `cd`, pipes, vim, job control — they all just work. But type `?` or `>` at the start of a line, and you're talking to an AI agent that has full context of what you've been doing: your working directory, recent commands, their output.
9
9
 
10
10
  The agent connects via the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/), so you can plug in **any** ACP-compatible agent: [pi](https://github.com/svkozak/pi-acp), claude-code, codex, gemini-cli, goose, etc.
11
11
 
@@ -13,15 +13,17 @@ The agent connects via the [Agent Client Protocol (ACP)](https://agentclientprot
13
13
  ⚡ src $ ls -la # real shell command
14
14
  ⚡ src $ cd ../tests && npm test # real cd, env, aliases — all just work
15
15
  ⚡ src $ vim file.ts # opens vim in the same PTY
16
- ⚡ src $ > refactor the auth middleware # sent to agent via ACP
17
- ⚡ src $ > explain the last error # agent sees your recent commands + output
16
+ ⚡ src $ ? explain the last error # query mode agent investigates using its own tools
17
+ ⚡ src $ > deploy to staging # execute mode → agent runs it in your live shell
18
18
  ```
19
19
 
20
20
  ## Why shell-first?
21
21
 
22
- Most AI coding tools are agent-first: the LLM drives the experience and the shell is bolted on. That means no real PTY, no job control, no interactive commands, and fragile `cd` tracking that reimplements what bash gives you for free.
22
+ I live mostly in a terminal. I don't just want an agent that has access to my shell I want a shell that has access to my agent.
23
23
 
24
- agent-sh starts from the opposite end. The shell is the primary interface it's your terminal, not the agent's. The agent is a tool you reach for when you need it, not the other way around.
24
+ Most AI coding tools get this backwards: the LLM drives the experience and the shell is bolted on. That means no real PTY, no job control, no interactive commands, and fragile `cd` tracking that reimplements what bash gives you for free.
25
+
26
+ agent-sh starts from the opposite end. The shell is the primary interface — it's your terminal, not the agent's. The agent is a tool you reach for when you need it, not the other way around. Two modes give you fine-grained control: `?` for questions and tasks (agent uses its own tools), `>` for commands that run directly in your live shell.
25
27
 
26
28
  ### Why ACP?
27
29
 
@@ -40,6 +42,8 @@ The [Agent Client Protocol](https://agentclientprotocol.com/) decouples the shel
40
42
  - **Real-time Streaming** — Agent responses stream live with syntax highlighting
41
43
  - **Zero Latency** — Direct PTY access, full terminal compatibility
42
44
  - **Context Aware** — Agent sees your cwd, recent commands, and their output
45
+ - **Dual Input Modes** — `?` for questions/tasks (agent tools), `>` for live shell execution
46
+ - **Extensible Modes** — Extensions can register custom input modes with their own triggers
43
47
  - **Multiple Agents** — Easy switching between pi-acp, claude, and other ACP agents
44
48
  - **Inline Diff Preview** — File writes show syntax-highlighted diffs inline (Ctrl+O to expand)
45
49
  - **Thinking Display** — Toggle agent thinking/reasoning text with Ctrl+T
@@ -67,22 +71,34 @@ See the [Usage Guide](docs/usage.md) for all options, model configuration, and e
67
71
 
68
72
  ## Input Modes
69
73
 
74
+ agent-sh has two agent input modes, each triggered by a single character at the start of an empty line:
75
+
76
+ | Trigger | Mode | Behavior |
77
+ |---|---|---|
78
+ | `?` | **Query** | Agent uses its own tools (bash, file read/write, search) to investigate and answer. Stays in query mode after each response. |
79
+ | `>` | **Execute** | Agent runs a command in your live shell via `user_shell`. Your aliases, env vars, and cwd apply. Returns to shell after execution. |
80
+
81
+ Regular shell input works as before — commands go straight to the PTY:
82
+
70
83
  | Input | Behavior |
71
84
  |---|---|
72
85
  | `ls -la` | Runs in real shell (PTY), output displayed normally |
73
86
  | `cd src && make` | Real shell — cd, env, aliases all just work |
74
87
  | `vim file.ts` | Opens vim in the same PTY, no hacks needed |
75
- | `> refactor this fn` | Sends to agent via ACP, streams response inline |
76
- | `> /help` | Shows available slash commands |
88
+ | `? refactor this fn` | Query mode agent investigates and responds |
89
+ | `> restart the server` | Execute mode agent runs it in your live shell |
90
+ | `? /help` | Shows available slash commands (works in either mode) |
77
91
  | `Ctrl-C` | Standard signal to shell, or cancels active agent response |
78
92
  | `Ctrl-O` | Expand/collapse truncated diff preview |
79
93
  | `Ctrl-T` | Toggle thinking/reasoning text display |
80
94
  | `Shift-Tab` | Cycle thinking level (off → minimal → low → medium → high → xhigh) |
81
- | `Escape` | Exit agent input mode (when typing after `>`) |
95
+ | `Escape` | Exit agent input mode |
96
+
97
+ Modes are extensible — extensions can register new modes via the `input-mode:register` event (see [Extensions](docs/extensions.md#custom-input-modes)).
82
98
 
83
99
  ### Agent Input Keybindings
84
100
 
85
- When typing after `>`, full readline-style keybindings are available:
101
+ When typing in either agent mode (`?` or `>`), full readline-style keybindings are available:
86
102
 
87
103
  | Key | Action |
88
104
  |---|---|
@@ -103,10 +119,11 @@ When typing after `>`, full readline-style keybindings are available:
103
119
 
104
120
  ### Thinking Level
105
121
 
106
- The agent prompt shows the current thinking level next to the model name:
122
+ The agent prompt shows the current thinking level next to the model name, with a mode-specific indicator:
107
123
 
108
124
  ```
109
- pi (claude-3.5-sonnet) [medium]
125
+ pi (claude-sonnet-4-6) [medium] # query mode
126
+ pi (claude-sonnet-4-6) [medium] ● ⟩ # execute mode
110
127
  ```
111
128
 
112
129
  Press **Shift-Tab** in agent input mode to cycle through levels. The levels are advertised by the agent via ACP session modes — different agents may offer different options. The spinner label reflects the mode: "Thinking" when thinking is enabled, "Working" when it's off.
@@ -30,7 +30,12 @@ export declare class AcpClient {
30
30
  /**
31
31
  * Send a user query to the agent.
32
32
  */
33
- sendPrompt(query: string): Promise<void>;
33
+ private firstPromptSent;
34
+ private static readonly SESSION_ORIENTATION;
35
+ sendPrompt(query: string, opts?: {
36
+ modeInstruction?: string;
37
+ modeLabel?: string;
38
+ }): Promise<void>;
34
39
  /**
35
40
  * Silently cancel the prompt after a shell tool completes.
36
41
  * Unlike user-initiated cancel(), this doesn't show "(cancelled)" —
@@ -129,7 +129,29 @@ export class AcpClient {
129
129
  /**
130
130
  * Send a user query to the agent.
131
131
  */
132
- async sendPrompt(query) {
132
+ firstPromptSent = false;
133
+ static SESSION_ORIENTATION = [
134
+ "You are running inside agent-sh, a terminal wrapper that gives the user two interaction modes:",
135
+ "",
136
+ "QUERY mode (triggered by '?'): The user is asking questions or requesting tasks.",
137
+ "Use your internal tools (bash, file operations, etc.) to accomplish tasks.",
138
+ "Do NOT use user_shell in this mode.",
139
+ "",
140
+ "EXECUTE mode (triggered by '>'): The user wants a command run in their live shell session.",
141
+ "You may use shell_recall to understand previous context and your own tools to investigate,",
142
+ "but the final action must be sending the command via user_shell,",
143
+ "which executes in the user's actual shell (with their aliases, env vars, and cwd).",
144
+ "Do not explain or ask for confirmation — just run it.",
145
+ "",
146
+ "Each prompt includes a per-query mode instruction — follow it.",
147
+ "",
148
+ "Available tools:",
149
+ "- user_shell: Runs commands in the user's live shell session (their PTY). Use in EXECUTE mode.",
150
+ "- shell_recall: Retrieves recent shell command history and output from the user's session.",
151
+ " Use this to understand what the user has been doing before answering questions.",
152
+ "- Your standard tools (bash, file read/write, etc.): Use in AGENT mode.",
153
+ ].join("\n");
154
+ async sendPrompt(query, opts) {
133
155
  if (!this.connection || !this.sessionId) {
134
156
  this.bus.emit("agent:error", { message: "Not connected to agent" });
135
157
  return;
@@ -141,19 +163,24 @@ export class AcpClient {
141
163
  this.autoCancelled = false;
142
164
  let cancelled = false;
143
165
  // Emit agent query event (TUI renders echo+spinner, ContextManager records it)
144
- this.bus.emit("agent:query", { query });
166
+ this.bus.emit("agent:query", { query, modeLabel: opts?.modeLabel });
145
167
  // Build structured context from ContextManager
146
168
  const contextBlock = this.contextManager.getContext();
147
169
  try {
148
170
  this.log("sending prompt...");
171
+ const promptContent = [];
172
+ // Send session orientation on first prompt
173
+ if (!this.firstPromptSent) {
174
+ promptContent.push({ type: "text", text: AcpClient.SESSION_ORIENTATION });
175
+ this.firstPromptSent = true;
176
+ }
177
+ if (opts?.modeInstruction) {
178
+ promptContent.push({ type: "text", text: opts.modeInstruction });
179
+ }
180
+ promptContent.push({ type: "text", text: contextBlock + "\n" + query });
149
181
  const response = await this.connection.prompt({
150
182
  sessionId: this.sessionId,
151
- prompt: [
152
- {
153
- type: "text",
154
- text: contextBlock + "\n" + query,
155
- },
156
- ],
183
+ prompt: promptContent,
157
184
  });
158
185
  this.log(`prompt resolved: stopReason=${response.stopReason}`);
159
186
  if (response.stopReason === "cancelled") {
@@ -240,6 +267,7 @@ export class AcpClient {
240
267
  this.sessionId = sessionResponse.sessionId;
241
268
  this.lastResponseText = "";
242
269
  this.currentResponseText = "";
270
+ this.firstPromptSent = false;
243
271
  this.updateModes(sessionResponse);
244
272
  }
245
273
  /**
package/dist/core.js CHANGED
@@ -34,7 +34,7 @@ export function createCore(config) {
34
34
  let connected = false;
35
35
  // Route frontend events to the agent — any frontend (Shell, WebSocket,
36
36
  // REST handler, test harness) can emit these without knowing about AcpClient.
37
- bus.on("agent:submit", ({ query }) => {
37
+ bus.on("agent:submit", ({ query, modeInstruction, modeLabel }) => {
38
38
  (async () => {
39
39
  // Wait briefly for agent connection if start() is still in progress
40
40
  if (!connected) {
@@ -46,7 +46,7 @@ export function createCore(config) {
46
46
  bus.emit("ui:error", { message: "Agent not connected. Please wait a moment and try again." });
47
47
  return;
48
48
  }
49
- await client.sendPrompt(query);
49
+ await client.sendPrompt(query, { modeInstruction, modeLabel });
50
50
  })().catch((err) => {
51
51
  bus.emit("agent:error", {
52
52
  message: err instanceof Error ? err.message : String(err),
@@ -22,10 +22,14 @@ export interface ShellEvents {
22
22
  "shell:agent-exec-done": Record<string, never>;
23
23
  "agent:submit": {
24
24
  query: string;
25
+ modeInstruction?: string;
26
+ modeLabel?: string;
25
27
  };
26
28
  "agent:cancel-request": Record<string, never>;
29
+ "input-mode:register": import("./types.js").InputModeConfig;
27
30
  "agent:query": {
28
31
  query: string;
32
+ modeLabel?: string;
29
33
  };
30
34
  "agent:thinking-chunk": {
31
35
  text: string;
@@ -75,7 +75,7 @@ export default function activate(ctx) {
75
75
  // ── Event subscriptions ─────────────────────────────────────
76
76
  bus.on("agent:query", (e) => {
77
77
  s.spinnerStartTime = 0;
78
- showUserQuery(e.query);
78
+ showUserQuery(e.query, e.modeLabel);
79
79
  startAgentResponse();
80
80
  startThinkingSpinner();
81
81
  });
@@ -237,7 +237,7 @@ export default function activate(ctx) {
237
237
  s.renderer = null;
238
238
  }
239
239
  }
240
- function showUserQuery(query) {
240
+ function showUserQuery(query, modeLabel) {
241
241
  const boxW = Math.min(84, writer.columns);
242
242
  const contentW = boxW - 4;
243
243
  const lines = [];
@@ -258,11 +258,17 @@ export default function activate(ctx) {
258
258
  lines.push(`${p.accent}${remaining}${p.reset}`);
259
259
  }
260
260
  }
261
+ // Mode-specific border color and title
262
+ const isExecute = modeLabel === "Execute";
263
+ const borderColor = isExecute ? p.success : p.accent;
264
+ const title = modeLabel
265
+ ? `${borderColor}${p.bold} ${modeLabel} ${p.reset}`
266
+ : `${p.accent}${p.bold}❯${p.reset}`;
261
267
  const framed = renderBoxFrame(lines, {
262
268
  width: boxW,
263
269
  style: "rounded",
264
- borderColor: p.accent,
265
- title: `${p.accent}${p.bold}❯${p.reset}`,
270
+ borderColor,
271
+ title,
266
272
  });
267
273
  writer.write("\n");
268
274
  for (const line of framed) {
@@ -572,7 +578,17 @@ export default function activate(ctx) {
572
578
  s.showThinkingText = !s.showThinkingText;
573
579
  if (s.spinner) {
574
580
  stopCurrentSpinner();
575
- startThinkingSpinner();
581
+ if (s.showThinkingText) {
582
+ // Expanding: replace spinner with thinking text header
583
+ if (!s.renderer)
584
+ startAgentResponse();
585
+ s.renderer.writeLine(`${p.dim}Thinking (ctrl+t to collapse)${p.reset}`);
586
+ drain();
587
+ }
588
+ else {
589
+ // Collapsing: restart spinner with updated hint
590
+ startThinkingSpinner();
591
+ }
576
592
  return;
577
593
  }
578
594
  if (!s.isThinking)
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
+ import * as path from "node:path";
3
4
  import { Shell } from "./shell.js";
4
5
  import { createCore } from "./core.js";
5
6
  import { palette as p } from "./utils/palette.js";
@@ -10,16 +11,23 @@ import shellRecall from "./extensions/shell-recall.js";
10
11
  import shellExec from "./extensions/shell-exec.js";
11
12
  import { loadExtensions } from "./extension-loader.js";
12
13
  /**
13
- * Capture the user's full shell environment asynchronously.
14
+ * Capture the user's full shell environment.
14
15
  * This picks up env vars exported in .zshrc/.bashrc that the
15
- * Node.js process doesn't have.
16
+ * Node.js process doesn't have (e.g. when launched from an IDE).
16
17
  *
17
- * Uses -l (login shell) instead of -i to avoid TTY blocking issues.
18
+ * Uses -l (login shell) to get .zprofile/.bash_profile vars, then
19
+ * explicitly sources the interactive rc file (.zshrc/.bashrc) which
20
+ * -l alone doesn't load (that requires -i, which blocks on TTY).
18
21
  */
19
22
  async function captureShellEnvAsync(shell) {
20
23
  return new Promise((resolve) => {
21
24
  try {
22
- const child = spawn(shell, ["-l", "-c", "env -0"], {
25
+ const shellName = path.basename(shell);
26
+ const isZsh = shellName.includes("zsh");
27
+ const sourceRc = isZsh
28
+ ? 'source ~/.zshrc 2>/dev/null;'
29
+ : '[ -f ~/.bashrc ] && source ~/.bashrc 2>/dev/null;';
30
+ const child = spawn(shell, ["-l", "-c", `${sourceRc} env -0`], {
23
31
  stdio: ["ignore", "pipe", "ignore"],
24
32
  timeout: 5000,
25
33
  });
@@ -154,7 +162,7 @@ function formatAgentInfo(agentInfo, model, thoughtLevel) {
154
162
  const label = thoughtLevel.replace(/^Thinking:\s*/i, "");
155
163
  infoStr += ` ${p.dim}[${label}]${p.reset}`;
156
164
  }
157
- return `${infoStr} ${p.success}●${p.reset}`;
165
+ return infoStr;
158
166
  }
159
167
  async function main() {
160
168
  // Set up signal handlers before any terminal operations.
@@ -163,29 +171,26 @@ async function main() {
163
171
  // Also ignore SIGTTIN which can occur when reading from terminal while backgrounded.
164
172
  process.on("SIGTTIN", () => { });
165
173
  const config = parseArgs(process.argv.slice(2));
166
- // Start with current process environment (fast, non-blocking)
167
- // We'll enrich it with shell env asynchronously in the background
174
+ // Capture user's full shell environment (from .zshrc/.bashrc etc.)
175
+ // This must complete before spawning the agent so it sees all env vars.
168
176
  const baseEnv = {};
169
177
  for (const [k, v] of Object.entries(process.env)) {
170
178
  if (v !== undefined)
171
179
  baseEnv[k] = v;
172
180
  }
173
181
  config.shellEnv = baseEnv;
174
- // Asynchronously capture full shell environment without blocking startup
175
182
  const shellPath = config.shell || process.env.SHELL || "/bin/bash";
176
- captureShellEnvAsync(shellPath).then((shellEnv) => {
183
+ try {
184
+ const shellEnv = await captureShellEnvAsync(shellPath);
177
185
  if (Object.keys(shellEnv).length > 0) {
178
- const merged = mergeShellEnv(config.shellEnv, shellEnv);
179
- config.shellEnv = merged;
186
+ config.shellEnv = mergeShellEnv(config.shellEnv, shellEnv);
180
187
  if (process.env.DEBUG) {
181
- console.error('[agent-sh] Shell environment enriched asynchronously');
188
+ console.error('[agent-sh] Shell environment captured');
182
189
  }
183
190
  }
184
- }).catch(() => {
191
+ }
192
+ catch {
185
193
  // 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
194
  }
190
195
  // ── Core (frontend-agnostic) ──────────────────────────────────
191
196
  const core = createCore(config);
@@ -232,6 +237,29 @@ async function main() {
232
237
  if (process.env.DEBUG) {
233
238
  console.error('[agent-sh] Shell created');
234
239
  }
240
+ // ── Input modes ──────────────────────────────────────────────
241
+ bus.emit("input-mode:register", {
242
+ id: "query",
243
+ trigger: "?",
244
+ label: "query",
245
+ promptIcon: "❯",
246
+ indicator: "❓",
247
+ onSubmit(query, b) {
248
+ b.emit("agent:submit", { query, modeLabel: "Query", modeInstruction: "[mode: query]" });
249
+ },
250
+ returnToSelf: true,
251
+ });
252
+ bus.emit("input-mode:register", {
253
+ id: "execute",
254
+ trigger: ">",
255
+ label: "execute",
256
+ promptIcon: "⟩",
257
+ indicator: "●",
258
+ onSubmit(query, b) {
259
+ b.emit("agent:submit", { query, modeLabel: "Execute", modeInstruction: "[mode: execute]" });
260
+ },
261
+ returnToSelf: false,
262
+ });
235
263
  // ── Extensions ────────────────────────────────────────────────
236
264
  if (process.env.DEBUG) {
237
265
  console.error('[agent-sh] Setting up extensions...');
@@ -16,7 +16,10 @@ export interface InputContext {
16
16
  export declare class InputHandler {
17
17
  private ctx;
18
18
  private lineBuffer;
19
- private agentInputMode;
19
+ private activeMode;
20
+ private pendingReturnMode;
21
+ private modes;
22
+ private modesById;
20
23
  private editor;
21
24
  private autocompleteActive;
22
25
  private autocompleteIndex;
@@ -37,22 +40,28 @@ export declare class InputHandler {
37
40
  model?: string;
38
41
  };
39
42
  });
43
+ private registerMode;
40
44
  private loadHistory;
41
45
  private saveHistory;
42
- /** Write the agent prompt line with cursor at the correct position. */
43
- private writeAgentPromptLine;
46
+ /** Write the mode prompt line with cursor at the correct position. */
47
+ private writeModePromptLine;
44
48
  handleInput(data: string): void;
45
- private enterAgentInputMode;
46
- private exitAgentInputMode;
49
+ private enterMode;
50
+ private exitMode;
47
51
  /** Move to the start of the prompt area and clear everything below. */
48
52
  private clearPromptArea;
49
53
  printPrompt(): void;
50
- private renderAgentInput;
54
+ /**
55
+ * Called when agent processing completes. Returns true if the input
56
+ * handler re-entered a mode (so caller should skip shell prompt).
57
+ */
58
+ handleProcessingDone(): boolean;
59
+ private renderModeInput;
51
60
  private updateAutocomplete;
52
61
  private renderAutocomplete;
53
62
  private applyAutocomplete;
54
63
  private dismissAutocomplete;
55
64
  private clearAutocompleteLines;
56
- private handleAgentInput;
57
- private processAgentActions;
65
+ private handleModeInput;
66
+ private processModeActions;
58
67
  }
@@ -8,7 +8,10 @@ const HISTORY_FILE = path.join(CONFIG_DIR, "history");
8
8
  export class InputHandler {
9
9
  ctx;
10
10
  lineBuffer = "";
11
- agentInputMode = false;
11
+ activeMode = null;
12
+ pendingReturnMode = null; // mode id to return to after processing
13
+ modes = new Map(); // keyed by trigger char
14
+ modesById = new Map(); // keyed by id
12
15
  editor = new LineEditor();
13
16
  autocompleteActive = false;
14
17
  autocompleteIndex = 0;
@@ -28,9 +31,23 @@ export class InputHandler {
28
31
  this.loadHistory();
29
32
  // Re-render prompt when config changes (e.g. thinking level cycled)
30
33
  this.bus.on("config:changed", () => {
31
- if (this.agentInputMode)
32
- this.writeAgentPromptLine();
34
+ if (this.activeMode)
35
+ this.writeModePromptLine();
33
36
  });
37
+ // Listen for mode registrations from extensions
38
+ this.bus.on("input-mode:register", (config) => {
39
+ this.registerMode(config);
40
+ });
41
+ }
42
+ registerMode(config) {
43
+ if (this.modes.has(config.trigger)) {
44
+ this.bus.emit("ui:error", {
45
+ message: `Input mode "${config.id}" cannot register trigger "${config.trigger}" — already taken by "${this.modes.get(config.trigger).id}"`,
46
+ });
47
+ return;
48
+ }
49
+ this.modes.set(config.trigger, config);
50
+ this.modesById.set(config.id, config);
34
51
  }
35
52
  loadHistory() {
36
53
  try {
@@ -52,8 +69,8 @@ export class InputHandler {
52
69
  // Non-critical — ignore write failures
53
70
  }
54
71
  }
55
- /** Write the agent prompt line with cursor at the correct position. */
56
- writeAgentPromptLine(showBuffer = true) {
72
+ /** Write the mode prompt line with cursor at the correct position. */
73
+ writeModePromptLine(showBuffer = true) {
57
74
  const termW = process.stdout.columns || 80;
58
75
  // Move cursor to the start of the prompt area (first line of wrapped content)
59
76
  if (this.promptWrappedLines > 0) {
@@ -62,9 +79,13 @@ export class InputHandler {
62
79
  // Clear from here to end of screen — removes current + all wrapped lines below
63
80
  process.stdout.write("\r\x1b[J");
64
81
  const agentInfo = this.onShowAgentInfo();
65
- const infoPrefix = agentInfo.info ? `${agentInfo.info} ` : "";
66
- const promptPrefix = infoPrefix + p.warning + p.bold + "❯ " + p.reset;
67
- const promptVisLen = visibleLen(infoPrefix) + 2; // "❯ "
82
+ const indicator = this.activeMode?.indicator ?? "";
83
+ const infoPrefix = agentInfo.info
84
+ ? `${agentInfo.info} ${p.success}${indicator}${p.reset} `
85
+ : `${p.success}${indicator}${p.reset} `;
86
+ const icon = this.activeMode?.promptIcon ?? "❯";
87
+ const promptPrefix = infoPrefix + p.warning + p.bold + icon + " " + p.reset;
88
+ const promptVisLen = visibleLen(infoPrefix) + visibleLen(icon) + 1; // icon + space
68
89
  if (!showBuffer || !this.editor.buffer.includes("\n")) {
69
90
  // Single-line: simple rendering
70
91
  const bufferText = showBuffer ? p.accent + this.editor.buffer + p.reset : "";
@@ -127,7 +148,7 @@ export class InputHandler {
127
148
  return;
128
149
  }
129
150
  // Intercept control chars for TUI (Ctrl+T, Ctrl+O) — don't pass to PTY
130
- if (data.length === 1 && data.charCodeAt(0) < 32 && !this.agentInputMode) {
151
+ if (data.length === 1 && data.charCodeAt(0) < 32 && !this.activeMode) {
131
152
  const code = data.charCodeAt(0);
132
153
  // Keys consumed by TUI extensions
133
154
  if (code === 0x14 || code === 0x0f) { // Ctrl+T, Ctrl+O
@@ -139,9 +160,9 @@ export class InputHandler {
139
160
  this.bus.emit("input:keypress", { key: data });
140
161
  }
141
162
  }
142
- // If in agent input mode (typing a query after ">")
143
- if (this.agentInputMode) {
144
- this.handleAgentInput(data);
163
+ // If in an input mode (typing a query)
164
+ if (this.activeMode) {
165
+ this.handleModeInput(data);
145
166
  return;
146
167
  }
147
168
  for (let i = 0; i < data.length; i++) {
@@ -171,10 +192,11 @@ export class InputHandler {
171
192
  this.ctx.writeToPty(ch);
172
193
  }
173
194
  else {
174
- // Check if ">" at start of empty line → enter agent input mode
195
+ // Check if trigger char at start of empty line → enter that mode
175
196
  // But not if a foreground process (ssh, vim, etc.) is running
176
- if (this.lineBuffer === "" && ch === ">" && !this.ctx.isForegroundBusy()) {
177
- this.enterAgentInputMode();
197
+ const mode = this.modes.get(ch);
198
+ if (this.lineBuffer === "" && mode && !this.ctx.isForegroundBusy()) {
199
+ this.enterMode(mode);
178
200
  return; // don't process remaining chars
179
201
  }
180
202
  this.lineBuffer += ch;
@@ -182,17 +204,17 @@ export class InputHandler {
182
204
  }
183
205
  }
184
206
  }
185
- enterAgentInputMode() {
186
- this.agentInputMode = true;
207
+ enterMode(mode) {
208
+ this.activeMode = mode;
187
209
  this.editor.clear();
188
210
  // Enable kitty keyboard protocol (progressive enhancement flag 1)
189
211
  // so Shift+Enter sends \x1b[13;2u instead of plain \r
190
212
  process.stdout.write("\x1b[>1u");
191
- this.writeAgentPromptLine(false);
213
+ this.writeModePromptLine(false);
192
214
  }
193
- exitAgentInputMode() {
215
+ exitMode() {
194
216
  this.dismissAutocomplete();
195
- this.agentInputMode = false;
217
+ this.activeMode = null;
196
218
  this.editor.clear();
197
219
  // Disable kitty keyboard protocol
198
220
  process.stdout.write("\x1b[<u");
@@ -210,9 +232,24 @@ export class InputHandler {
210
232
  printPrompt() {
211
233
  this.ctx.redrawPrompt();
212
234
  }
213
- renderAgentInput() {
235
+ /**
236
+ * Called when agent processing completes. Returns true if the input
237
+ * handler re-entered a mode (so caller should skip shell prompt).
238
+ */
239
+ handleProcessingDone() {
240
+ if (this.pendingReturnMode) {
241
+ const mode = this.modesById.get(this.pendingReturnMode);
242
+ this.pendingReturnMode = null;
243
+ if (mode) {
244
+ this.enterMode(mode);
245
+ return true;
246
+ }
247
+ }
248
+ return false;
249
+ }
250
+ renderModeInput() {
214
251
  this.clearAutocompleteLines();
215
- this.writeAgentPromptLine();
252
+ this.writeModePromptLine();
216
253
  this.updateAutocomplete();
217
254
  }
218
255
  updateAutocomplete() {
@@ -254,7 +291,8 @@ export class InputHandler {
254
291
  }
255
292
  const agentInfo = this.onShowAgentInfo();
256
293
  const infoLength = visibleLen(agentInfo.info);
257
- const col = infoLength + 2 + this.editor.cursor;
294
+ const icon = this.activeMode?.promptIcon ?? "❯";
295
+ const col = infoLength + visibleLen(icon) + 1 + this.editor.cursor;
258
296
  process.stdout.write(`\r\x1b[${col}C`);
259
297
  }
260
298
  applyAutocomplete() {
@@ -279,7 +317,7 @@ export class InputHandler {
279
317
  this.autocompleteActive = false;
280
318
  this.autocompleteItems = [];
281
319
  this.autocompleteIndex = 0;
282
- this.writeAgentPromptLine();
320
+ this.writeModePromptLine();
283
321
  if (isFileAc)
284
322
  this.updateAutocomplete();
285
323
  }
@@ -299,7 +337,7 @@ export class InputHandler {
299
337
  process.stdout.write("\x1b8"); // restore cursor
300
338
  this.autocompleteLines = 0;
301
339
  }
302
- handleAgentInput(data) {
340
+ handleModeInput(data) {
303
341
  // Clear any pending escape timer — new data arrived
304
342
  if (this.escapeTimer) {
305
343
  clearTimeout(this.escapeTimer);
@@ -313,18 +351,18 @@ export class InputHandler {
313
351
  this.escapeTimer = null;
314
352
  const flushed = this.editor.flushPendingEscape();
315
353
  if (flushed.length > 0)
316
- this.processAgentActions(flushed);
354
+ this.processModeActions(flushed);
317
355
  }, 50);
318
356
  }
319
- this.processAgentActions(actions);
357
+ this.processModeActions(actions);
320
358
  }
321
- processAgentActions(actions) {
359
+ processModeActions(actions) {
322
360
  for (const act of actions) {
323
361
  switch (act.action) {
324
362
  case "changed":
325
363
  this.historyIndex = -1;
326
364
  this.autocompleteIndex = 0;
327
- this.renderAgentInput();
365
+ this.renderModeInput();
328
366
  break;
329
367
  case "submit": {
330
368
  if (this.autocompleteActive) {
@@ -343,7 +381,8 @@ export class InputHandler {
343
381
  this.clearAutocompleteLines();
344
382
  this.clearPromptArea();
345
383
  process.stdout.write("\x1b[<u"); // disable kitty keyboard protocol
346
- this.agentInputMode = false;
384
+ const currentMode = this.activeMode;
385
+ this.activeMode = null;
347
386
  this.editor.clear();
348
387
  this.dismissAutocomplete();
349
388
  if (query && query.startsWith("/")) {
@@ -354,25 +393,26 @@ export class InputHandler {
354
393
  this.ctx.redrawPrompt();
355
394
  }
356
395
  else if (query) {
357
- this.bus.emit("agent:submit", { query });
396
+ this.pendingReturnMode = currentMode.returnToSelf ? currentMode.id : null;
397
+ currentMode.onSubmit(query, this.bus);
358
398
  }
359
399
  else {
360
- this.exitAgentInputMode();
400
+ this.exitMode();
361
401
  }
362
402
  return;
363
403
  }
364
404
  case "cancel":
365
405
  if (this.autocompleteActive) {
366
406
  this.dismissAutocomplete();
367
- this.writeAgentPromptLine();
407
+ this.writeModePromptLine();
368
408
  }
369
409
  else {
370
- this.exitAgentInputMode();
410
+ this.exitMode();
371
411
  }
372
412
  return;
373
413
  case "delete-empty":
374
414
  this.dismissAutocomplete();
375
- this.exitAgentInputMode();
415
+ this.exitMode();
376
416
  return;
377
417
  case "tab":
378
418
  if (this.autocompleteActive) {
@@ -389,7 +429,7 @@ export class InputHandler {
389
429
  ? this.autocompleteItems.length - 1
390
430
  : this.autocompleteIndex - 1;
391
431
  this.clearAutocompleteLines();
392
- this.writeAgentPromptLine();
432
+ this.writeModePromptLine();
393
433
  this.renderAutocomplete();
394
434
  }
395
435
  else if (this.history.length > 0) {
@@ -402,7 +442,7 @@ export class InputHandler {
402
442
  }
403
443
  this.editor.buffer = this.history[this.historyIndex];
404
444
  this.editor.cursor = this.editor.buffer.length;
405
- this.renderAgentInput();
445
+ this.renderModeInput();
406
446
  }
407
447
  break;
408
448
  case "arrow-down":
@@ -412,7 +452,7 @@ export class InputHandler {
412
452
  ? 0
413
453
  : this.autocompleteIndex + 1;
414
454
  this.clearAutocompleteLines();
415
- this.writeAgentPromptLine();
455
+ this.writeModePromptLine();
416
456
  this.renderAutocomplete();
417
457
  }
418
458
  else if (this.historyIndex !== -1) {
@@ -425,7 +465,7 @@ export class InputHandler {
425
465
  this.editor.buffer = this.savedBuffer;
426
466
  }
427
467
  this.editor.cursor = this.editor.buffer.length;
428
- this.renderAgentInput();
468
+ this.renderModeInput();
429
469
  }
430
470
  break;
431
471
  }
package/dist/shell.js CHANGED
@@ -239,7 +239,9 @@ export class Shell {
239
239
  this.paused = false;
240
240
  this.agentActive = false;
241
241
  this.echoSkip = true;
242
- this.freshPrompt();
242
+ if (!this.inputHandler.handleProcessingDone()) {
243
+ this.freshPrompt();
244
+ }
243
245
  });
244
246
  // Permission prompts need stdout unpaused so the interactive UI renders,
245
247
  // then re-paused after the decision.
package/dist/types.d.ts CHANGED
@@ -40,6 +40,19 @@ export interface ExtensionContext {
40
40
  /** Call a named handler. */
41
41
  call: (name: string, ...args: any[]) => any;
42
42
  }
43
+ /**
44
+ * Configuration for a registered input mode.
45
+ * Extensions emit "input-mode:register" with this shape to add new modes.
46
+ */
47
+ export interface InputModeConfig {
48
+ id: string;
49
+ trigger: string;
50
+ label: string;
51
+ promptIcon: string;
52
+ indicator: string;
53
+ onSubmit(query: string, bus: EventBus): void;
54
+ returnToSelf: boolean;
55
+ }
43
56
  export interface TerminalSession {
44
57
  id: string;
45
58
  command: string;
@@ -166,6 +166,10 @@ export class LineEditor {
166
166
  "ctrl+u": () => this.deleteRange(0, this.cursor),
167
167
  "ctrl+k": () => this.deleteRange(this.cursor, this.buffer.length),
168
168
  "ctrl+w": () => this.deleteWordBackward() ? { action: "changed" } : null,
169
+ "alt+f": () => this.wordForward() ? { action: "changed" } : null,
170
+ "alt+b": () => this.wordBackward() ? { action: "changed" } : null,
171
+ "alt+d": () => this.deleteWordForward() ? { action: "changed" } : null,
172
+ "alt+backspace": () => this.deleteWordBackward() ? { action: "changed" } : null,
169
173
  "shift+enter": () => this.insertAt("\n"),
170
174
  "shift+tab": () => ({ action: "shift+tab" }),
171
175
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "A shell-first terminal where any ACP-compatible AI agent is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",