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 +28 -11
- package/dist/acp-client.d.ts +6 -1
- package/dist/acp-client.js +36 -8
- package/dist/core.js +2 -2
- package/dist/event-bus.d.ts +4 -0
- package/dist/extensions/tui-renderer.js +21 -5
- package/dist/index.js +44 -16
- package/dist/input-handler.d.ts +17 -8
- package/dist/input-handler.js +79 -39
- package/dist/shell.js +3 -1
- package/dist/types.d.ts +13 -0
- package/dist/utils/line-editor.js +4 -0
- package/package.json +1 -1
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 $
|
|
17
|
-
⚡ src $ >
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
76
|
-
| `>
|
|
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
|
|
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
|
|
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-
|
|
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.
|
package/dist/acp-client.d.ts
CHANGED
|
@@ -30,7 +30,12 @@ export declare class AcpClient {
|
|
|
30
30
|
/**
|
|
31
31
|
* Send a user query to the agent.
|
|
32
32
|
*/
|
|
33
|
-
|
|
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)" —
|
package/dist/acp-client.js
CHANGED
|
@@ -129,7 +129,29 @@ export class AcpClient {
|
|
|
129
129
|
/**
|
|
130
130
|
* Send a user query to the agent.
|
|
131
131
|
*/
|
|
132
|
-
|
|
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),
|
package/dist/event-bus.d.ts
CHANGED
|
@@ -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
|
|
265
|
-
title
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
//
|
|
167
|
-
//
|
|
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
|
-
|
|
183
|
+
try {
|
|
184
|
+
const shellEnv = await captureShellEnvAsync(shellPath);
|
|
177
185
|
if (Object.keys(shellEnv).length > 0) {
|
|
178
|
-
|
|
179
|
-
config.shellEnv = merged;
|
|
186
|
+
config.shellEnv = mergeShellEnv(config.shellEnv, shellEnv);
|
|
180
187
|
if (process.env.DEBUG) {
|
|
181
|
-
console.error('[agent-sh] Shell environment
|
|
188
|
+
console.error('[agent-sh] Shell environment captured');
|
|
182
189
|
}
|
|
183
190
|
}
|
|
184
|
-
}
|
|
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...');
|
package/dist/input-handler.d.ts
CHANGED
|
@@ -16,7 +16,10 @@ export interface InputContext {
|
|
|
16
16
|
export declare class InputHandler {
|
|
17
17
|
private ctx;
|
|
18
18
|
private lineBuffer;
|
|
19
|
-
private
|
|
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
|
|
43
|
-
private
|
|
46
|
+
/** Write the mode prompt line with cursor at the correct position. */
|
|
47
|
+
private writeModePromptLine;
|
|
44
48
|
handleInput(data: string): void;
|
|
45
|
-
private
|
|
46
|
-
private
|
|
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
|
-
|
|
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
|
|
57
|
-
private
|
|
65
|
+
private handleModeInput;
|
|
66
|
+
private processModeActions;
|
|
58
67
|
}
|
package/dist/input-handler.js
CHANGED
|
@@ -8,7 +8,10 @@ const HISTORY_FILE = path.join(CONFIG_DIR, "history");
|
|
|
8
8
|
export class InputHandler {
|
|
9
9
|
ctx;
|
|
10
10
|
lineBuffer = "";
|
|
11
|
-
|
|
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.
|
|
32
|
-
this.
|
|
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
|
|
56
|
-
|
|
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
|
|
66
|
-
const
|
|
67
|
-
|
|
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.
|
|
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
|
|
143
|
-
if (this.
|
|
144
|
-
this.
|
|
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
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
186
|
-
this.
|
|
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.
|
|
213
|
+
this.writeModePromptLine(false);
|
|
192
214
|
}
|
|
193
|
-
|
|
215
|
+
exitMode() {
|
|
194
216
|
this.dismissAutocomplete();
|
|
195
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
354
|
+
this.processModeActions(flushed);
|
|
317
355
|
}, 50);
|
|
318
356
|
}
|
|
319
|
-
this.
|
|
357
|
+
this.processModeActions(actions);
|
|
320
358
|
}
|
|
321
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
396
|
+
this.pendingReturnMode = currentMode.returnToSelf ? currentMode.id : null;
|
|
397
|
+
currentMode.onSubmit(query, this.bus);
|
|
358
398
|
}
|
|
359
399
|
else {
|
|
360
|
-
this.
|
|
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.
|
|
407
|
+
this.writeModePromptLine();
|
|
368
408
|
}
|
|
369
409
|
else {
|
|
370
|
-
this.
|
|
410
|
+
this.exitMode();
|
|
371
411
|
}
|
|
372
412
|
return;
|
|
373
413
|
case "delete-empty":
|
|
374
414
|
this.dismissAutocomplete();
|
|
375
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
};
|