@hasna/terminal 0.1.4 → 0.2.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/.claude/scheduled_tasks.lock +1 -1
- package/README.md +186 -0
- package/dist/App.js +217 -105
- package/dist/Browse.js +79 -0
- package/dist/FuzzyPicker.js +47 -0
- package/dist/StatusBar.js +20 -16
- package/dist/ai.js +45 -50
- package/dist/cli.js +138 -6
- package/dist/compression.js +107 -0
- package/dist/compression.test.js +42 -0
- package/dist/diff-cache.js +87 -0
- package/dist/diff-cache.test.js +27 -0
- package/dist/economy.js +79 -0
- package/dist/economy.test.js +13 -0
- package/dist/mcp/install.js +98 -0
- package/dist/mcp/server.js +333 -0
- package/dist/output-router.js +41 -0
- package/dist/parsers/base.js +2 -0
- package/dist/parsers/build.js +64 -0
- package/dist/parsers/errors.js +101 -0
- package/dist/parsers/files.js +78 -0
- package/dist/parsers/git.js +86 -0
- package/dist/parsers/index.js +48 -0
- package/dist/parsers/parsers.test.js +136 -0
- package/dist/parsers/tests.js +89 -0
- package/dist/providers/anthropic.js +39 -0
- package/dist/providers/base.js +4 -0
- package/dist/providers/cerebras.js +95 -0
- package/dist/providers/index.js +49 -0
- package/dist/providers/providers.test.js +14 -0
- package/dist/recipes/model.js +20 -0
- package/dist/recipes/recipes.test.js +36 -0
- package/dist/recipes/storage.js +118 -0
- package/dist/search/content-search.js +61 -0
- package/dist/search/file-search.js +61 -0
- package/dist/search/filters.js +34 -0
- package/dist/search/index.js +4 -0
- package/dist/search/search.test.js +22 -0
- package/dist/snapshots.js +51 -0
- package/dist/supervisor.js +112 -0
- package/dist/tree.js +94 -0
- package/package.json +7 -4
- package/src/App.tsx +371 -245
- package/src/Browse.tsx +103 -0
- package/src/FuzzyPicker.tsx +69 -0
- package/src/StatusBar.tsx +28 -34
- package/src/ai.ts +63 -51
- package/src/cli.tsx +132 -6
- package/src/compression.test.ts +50 -0
- package/src/compression.ts +140 -0
- package/src/diff-cache.test.ts +30 -0
- package/src/diff-cache.ts +125 -0
- package/src/economy.test.ts +16 -0
- package/src/economy.ts +99 -0
- package/src/mcp/install.ts +94 -0
- package/src/mcp/server.ts +476 -0
- package/src/output-router.ts +56 -0
- package/src/parsers/base.ts +72 -0
- package/src/parsers/build.ts +73 -0
- package/src/parsers/errors.ts +107 -0
- package/src/parsers/files.ts +91 -0
- package/src/parsers/git.ts +86 -0
- package/src/parsers/index.ts +66 -0
- package/src/parsers/parsers.test.ts +153 -0
- package/src/parsers/tests.ts +98 -0
- package/src/providers/anthropic.ts +44 -0
- package/src/providers/base.ts +34 -0
- package/src/providers/cerebras.ts +108 -0
- package/src/providers/index.ts +60 -0
- package/src/providers/providers.test.ts +16 -0
- package/src/recipes/model.ts +55 -0
- package/src/recipes/recipes.test.ts +44 -0
- package/src/recipes/storage.ts +142 -0
- package/src/search/content-search.ts +97 -0
- package/src/search/file-search.ts +86 -0
- package/src/search/filters.ts +36 -0
- package/src/search/index.ts +7 -0
- package/src/search/search.test.ts +25 -0
- package/src/snapshots.ts +67 -0
- package/src/supervisor.ts +129 -0
- package/src/tree.ts +101 -0
- package/tsconfig.json +2 -1
package/src/App.tsx
CHANGED
|
@@ -1,32 +1,29 @@
|
|
|
1
1
|
import React, { useState, useCallback, useRef } from "react";
|
|
2
2
|
import { Box, Text, useInput, useApp } from "ink";
|
|
3
|
-
import { spawn
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
4
|
import { translateToCommand, explainCommand, fixCommand, checkPermissions, isIrreversible } from "./ai.js";
|
|
5
|
-
import {
|
|
6
|
-
loadHistory,
|
|
7
|
-
appendHistory,
|
|
8
|
-
loadConfig,
|
|
9
|
-
saveConfig,
|
|
10
|
-
type Permissions,
|
|
11
|
-
} from "./history.js";
|
|
5
|
+
import { loadHistory, appendHistory, loadConfig, saveConfig, type Permissions } from "./history.js";
|
|
12
6
|
import { loadCache } from "./cache.js";
|
|
13
7
|
import Onboarding from "./Onboarding.js";
|
|
14
8
|
import StatusBar from "./StatusBar.js";
|
|
15
9
|
import Spinner from "./Spinner.js";
|
|
10
|
+
import Browse from "./Browse.js";
|
|
11
|
+
import FuzzyPicker from "./FuzzyPicker.js";
|
|
16
12
|
|
|
17
|
-
// warm cache on startup
|
|
18
13
|
loadCache();
|
|
19
14
|
|
|
20
15
|
// ── types ─────────────────────────────────────────────────────────────────────
|
|
21
16
|
|
|
22
17
|
type Phase =
|
|
23
18
|
| { type: "input"; value: string; cursor: number; histIdx: number; raw: boolean }
|
|
24
|
-
| { type: "thinking"; nl: string;
|
|
25
|
-
| { type: "confirm"; nl: string; command: string;
|
|
19
|
+
| { type: "thinking"; nl: string; partial: string }
|
|
20
|
+
| { type: "confirm"; nl: string; command: string; danger: boolean }
|
|
26
21
|
| { type: "explain"; nl: string; command: string; explanation: string }
|
|
27
22
|
| { type: "running"; nl: string; command: string }
|
|
28
23
|
| { type: "autofix"; nl: string; command: string; errorOutput: string }
|
|
29
|
-
| { type: "error"; message: string }
|
|
24
|
+
| { type: "error"; message: string }
|
|
25
|
+
| { type: "browse"; cwd: string }
|
|
26
|
+
| { type: "fuzzy" };
|
|
30
27
|
|
|
31
28
|
interface ScrollEntry {
|
|
32
29
|
nl: string;
|
|
@@ -35,6 +32,17 @@ interface ScrollEntry {
|
|
|
35
32
|
truncated: boolean;
|
|
36
33
|
expanded: boolean;
|
|
37
34
|
error?: boolean;
|
|
35
|
+
filePaths?: string[]; // detected file paths for navigable output
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface TabState {
|
|
39
|
+
id: number;
|
|
40
|
+
cwd: string;
|
|
41
|
+
scroll: ScrollEntry[];
|
|
42
|
+
sessionCmds: string[];
|
|
43
|
+
sessionNl: string[];
|
|
44
|
+
phase: Phase;
|
|
45
|
+
streamLines: string[];
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
const MAX_LINES = 20;
|
|
@@ -44,83 +52,129 @@ const MAX_LINES = 20;
|
|
|
44
52
|
function insertAt(s: string, pos: number, ch: string) { return s.slice(0, pos) + ch + s.slice(pos); }
|
|
45
53
|
function deleteAt(s: string, pos: number) { return pos <= 0 ? s : s.slice(0, pos - 1) + s.slice(pos); }
|
|
46
54
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
signal: AbortSignal
|
|
52
|
-
): ChildProcess {
|
|
53
|
-
const proc = spawn("/bin/zsh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"] });
|
|
55
|
+
/** Detect if output lines look like file paths */
|
|
56
|
+
function extractFilePaths(lines: string[]): string[] {
|
|
57
|
+
return lines.filter(l => /^\.?\//.test(l.trim()) || /\.(ts|tsx|js|json|md|py|sh|go|rs|txt|yaml|yml|env)$/.test(l.trim()));
|
|
58
|
+
}
|
|
54
59
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
/** Ghost text: find the best NL match that starts with the current input */
|
|
61
|
+
function ghostText(input: string, history: string[]): string {
|
|
62
|
+
if (!input.trim()) return "";
|
|
63
|
+
const lower = input.toLowerCase();
|
|
64
|
+
const match = [...history].reverse().find(h => h.toLowerCase().startsWith(lower) && h.length > input.length);
|
|
65
|
+
return match ? match.slice(input.length) : "";
|
|
66
|
+
}
|
|
59
67
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
/** Detect cd and change process cwd */
|
|
69
|
+
function maybeCd(command: string): string | null {
|
|
70
|
+
const m = command.match(/^\s*cd\s+(.+)\s*$/);
|
|
71
|
+
if (!m) return null;
|
|
72
|
+
let target = m[1].trim().replace(/^['"]|['"]$/g, "");
|
|
73
|
+
if (target.startsWith("~")) target = target.replace("~", process.env.HOME ?? "");
|
|
74
|
+
return target;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function newTab(id: number, cwd: string): TabState {
|
|
78
|
+
return {
|
|
79
|
+
id, cwd,
|
|
80
|
+
scroll: [], sessionCmds: [], sessionNl: [],
|
|
81
|
+
phase: { type: "input", value: "", cursor: 0, histIdx: -1, raw: false },
|
|
82
|
+
streamLines: [],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
63
85
|
|
|
86
|
+
function runCommand(
|
|
87
|
+
command: string, cwd: string,
|
|
88
|
+
onLine: (l: string) => void,
|
|
89
|
+
onDone: (code: number) => void,
|
|
90
|
+
signal: AbortSignal
|
|
91
|
+
) {
|
|
92
|
+
const proc = spawn("/bin/zsh", ["-c", command], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
93
|
+
const handle = (d: Buffer) => d.toString().split("\n").forEach(l => { if (l) onLine(l); });
|
|
94
|
+
proc.stdout?.on("data", handle);
|
|
95
|
+
proc.stderr?.on("data", handle);
|
|
96
|
+
proc.on("close", code => onDone(code ?? 0));
|
|
64
97
|
signal.addEventListener("abort", () => { try { proc.kill("SIGTERM"); } catch {} });
|
|
65
|
-
return proc;
|
|
66
98
|
}
|
|
67
99
|
|
|
68
|
-
// ──
|
|
100
|
+
// ── App ───────────────────────────────────────────────────────────────────────
|
|
69
101
|
|
|
70
102
|
export default function App() {
|
|
71
103
|
const { exit } = useApp();
|
|
72
104
|
const [config, setConfig] = useState(() => loadConfig());
|
|
73
|
-
const [nlHistory] = useState<string[]>(() => loadHistory().map(
|
|
74
|
-
const [
|
|
75
|
-
const [
|
|
76
|
-
const [scroll, setScroll] = useState<ScrollEntry[]>([]);
|
|
77
|
-
const [streamLines, setStreamLines] = useState<string[]>([]);
|
|
105
|
+
const [nlHistory] = useState<string[]>(() => loadHistory().map(h => h.nl).filter(Boolean));
|
|
106
|
+
const [tabs, setTabs] = useState<TabState[]>([newTab(1, process.cwd())]);
|
|
107
|
+
const [activeTab, setActiveTab] = useState(0);
|
|
78
108
|
const abortRef = useRef<AbortController | null>(null);
|
|
109
|
+
let nextTabId = useRef(2);
|
|
79
110
|
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
});
|
|
111
|
+
const tab = tabs[activeTab];
|
|
112
|
+
const allNl = [...nlHistory, ...tab.sessionNl];
|
|
83
113
|
|
|
84
|
-
|
|
114
|
+
// ── tab helpers ─────────────────────────────────────────────────────────────
|
|
85
115
|
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
};
|
|
116
|
+
const updateTab = (updater: (t: TabState) => TabState) =>
|
|
117
|
+
setTabs(ts => ts.map((t, i) => i === activeTab ? updater(t) : t));
|
|
118
|
+
|
|
119
|
+
const setPhase = (phase: Phase) => updateTab(t => ({ ...t, phase }));
|
|
120
|
+
const setStreamLines = (lines: string[]) => updateTab(t => ({ ...t, streamLines: lines }));
|
|
91
121
|
|
|
92
122
|
const inputPhase = (overrides: Partial<Extract<Phase, { type: "input" }>> = {}) => {
|
|
93
|
-
|
|
94
|
-
|
|
123
|
+
updateTab(t => ({
|
|
124
|
+
...t,
|
|
125
|
+
streamLines: [],
|
|
126
|
+
phase: { type: "input", value: "", cursor: 0, histIdx: -1, raw: false, ...overrides },
|
|
127
|
+
}));
|
|
95
128
|
};
|
|
96
129
|
|
|
97
130
|
const pushScroll = (entry: Omit<ScrollEntry, "expanded">) =>
|
|
98
|
-
|
|
131
|
+
updateTab(t => ({ ...t, scroll: [...t.scroll, { ...entry, expanded: false }] }));
|
|
99
132
|
|
|
100
133
|
const commitStream = (nl: string, cmd: string, lines: string[], error: boolean) => {
|
|
101
134
|
const truncated = lines.length > MAX_LINES;
|
|
102
|
-
|
|
135
|
+
const filePaths = !error ? extractFilePaths(lines) : [];
|
|
136
|
+
updateTab(t => ({
|
|
137
|
+
...t,
|
|
138
|
+
streamLines: [],
|
|
139
|
+
sessionCmds: [...t.sessionCmds.slice(-9), cmd],
|
|
140
|
+
scroll: [...t.scroll, {
|
|
141
|
+
nl, cmd,
|
|
142
|
+
lines: truncated ? lines.slice(0, MAX_LINES) : lines,
|
|
143
|
+
truncated, expanded: false,
|
|
144
|
+
error: error || undefined,
|
|
145
|
+
filePaths: filePaths.length ? filePaths : undefined,
|
|
146
|
+
}],
|
|
147
|
+
}));
|
|
103
148
|
appendHistory({ nl, cmd, output: lines.join("\n"), ts: Date.now(), error });
|
|
104
|
-
setSessionCmds((c) => [...c.slice(-9), cmd]);
|
|
105
|
-
setStreamLines([]);
|
|
106
149
|
};
|
|
107
150
|
|
|
151
|
+
// ── run command ─────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
108
153
|
const runPhase = async (nl: string, command: string, raw: boolean) => {
|
|
109
154
|
setPhase({ type: "running", nl, command });
|
|
110
|
-
|
|
155
|
+
updateTab(t => ({ ...t, streamLines: [] }));
|
|
111
156
|
const abort = new AbortController();
|
|
112
157
|
abortRef.current = abort;
|
|
113
158
|
const lines: string[] = [];
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
159
|
+
const cwd = tabs[activeTab].cwd;
|
|
160
|
+
|
|
161
|
+
await new Promise<void>(resolve => {
|
|
162
|
+
runCommand(command, cwd,
|
|
163
|
+
line => { lines.push(line); setStreamLines([...lines]); },
|
|
164
|
+
code => {
|
|
165
|
+
// handle cd — update tab cwd
|
|
166
|
+
const cdTarget = maybeCd(command);
|
|
167
|
+
if (cdTarget) {
|
|
168
|
+
try {
|
|
169
|
+
const { resolve: resolvePath } = require("path");
|
|
170
|
+
const newCwd = require("path").resolve(cwd, cdTarget);
|
|
171
|
+
process.chdir(newCwd);
|
|
172
|
+
updateTab(t => ({ ...t, cwd: newCwd }));
|
|
173
|
+
} catch {}
|
|
174
|
+
}
|
|
120
175
|
commitStream(nl, command, lines, code !== 0);
|
|
121
176
|
abortRef.current = null;
|
|
122
177
|
if (code !== 0 && !raw) {
|
|
123
|
-
// offer auto-fix
|
|
124
178
|
setPhase({ type: "autofix", nl, command, errorOutput: lines.join("\n") });
|
|
125
179
|
} else {
|
|
126
180
|
inputPhase({ raw });
|
|
@@ -132,174 +186,240 @@ export default function App() {
|
|
|
132
186
|
});
|
|
133
187
|
};
|
|
134
188
|
|
|
135
|
-
|
|
136
|
-
useCallback(
|
|
137
|
-
async (input: string, key: any) => {
|
|
138
|
-
|
|
139
|
-
// ── running: Ctrl+C kills process ─────────────────────────────────
|
|
140
|
-
if (phase.type === "running") {
|
|
141
|
-
if (key.ctrl && input === "c") {
|
|
142
|
-
abortRef.current?.abort();
|
|
143
|
-
inputPhase();
|
|
144
|
-
}
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// ── input ─────────────────────────────────────────────────────────
|
|
149
|
-
if (phase.type === "input") {
|
|
150
|
-
if (key.ctrl && input === "c") { exit(); return; }
|
|
151
|
-
if (key.ctrl && input === "l") { setScroll([]); return; }
|
|
152
|
-
if (key.ctrl && input === "r") {
|
|
153
|
-
setPhase({ ...phase, raw: !phase.raw, value: "", cursor: 0 });
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (key.upArrow) {
|
|
158
|
-
const idx = Math.min(phase.histIdx + 1, allNl.length - 1);
|
|
159
|
-
const val = allNl[allNl.length - 1 - idx] ?? "";
|
|
160
|
-
setPhase({ ...phase, value: val, cursor: val.length, histIdx: idx });
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
if (key.downArrow) {
|
|
164
|
-
const idx = Math.max(phase.histIdx - 1, -1);
|
|
165
|
-
const val = idx === -1 ? "" : allNl[allNl.length - 1 - idx] ?? "";
|
|
166
|
-
setPhase({ ...phase, value: val, cursor: val.length, histIdx: idx });
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
if (key.leftArrow) { setPhase({ ...phase, cursor: Math.max(0, phase.cursor - 1) }); return; }
|
|
170
|
-
if (key.rightArrow) { setPhase({ ...phase, cursor: Math.min(phase.value.length, phase.cursor + 1) }); return; }
|
|
171
|
-
|
|
172
|
-
if (key.return) {
|
|
173
|
-
const nl = phase.value.trim();
|
|
174
|
-
if (!nl) return;
|
|
175
|
-
setSessionNl((h) => [...h, nl]);
|
|
176
|
-
|
|
177
|
-
if (phase.raw) {
|
|
178
|
-
await runPhase(nl, nl, true);
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
189
|
+
// ── translate + run ─────────────────────────────────────────────────────────
|
|
181
190
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
setPhase({ type: "thinking", nl, raw: false, partial });
|
|
186
|
-
});
|
|
187
|
-
const blocked = checkPermissions(command, config.permissions);
|
|
188
|
-
if (blocked) {
|
|
189
|
-
pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
|
|
190
|
-
inputPhase();
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
const danger = isIrreversible(command);
|
|
194
|
-
// skip confirm unless user opted in OR command is dangerous
|
|
195
|
-
if (!config.confirm && !danger) {
|
|
196
|
-
await runPhase(nl, command, false);
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
setPhase({ type: "confirm", nl, command, raw: false, danger });
|
|
200
|
-
} catch (e: any) {
|
|
201
|
-
setPhase({ type: "error", message: e.message });
|
|
202
|
-
}
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
191
|
+
const translateAndRun = async (nl: string, raw: boolean) => {
|
|
192
|
+
updateTab(t => ({ ...t, sessionNl: [...t.sessionNl, nl] }));
|
|
193
|
+
if (raw) { await runPhase(nl, nl, true); return; }
|
|
205
194
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
try {
|
|
226
|
-
const explanation = await explainCommand(command);
|
|
227
|
-
setPhase({ type: "explain", nl, command, explanation });
|
|
228
|
-
} catch {
|
|
229
|
-
setPhase({ type: "confirm", nl, command, raw: false, danger: phase.danger });
|
|
230
|
-
}
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
if (input === "y" || input === "Y" || key.return) {
|
|
234
|
-
await runPhase(phase.nl, phase.command, false);
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
if (input === "n" || input === "N" || key.escape) { inputPhase(); return; }
|
|
238
|
-
if (input === "e" || input === "E") {
|
|
239
|
-
setPhase({ type: "input", value: phase.command, cursor: phase.command.length, histIdx: -1, raw: false });
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
195
|
+
const sessionCmds = tabs[activeTab].sessionCmds;
|
|
196
|
+
setPhase({ type: "thinking", nl, partial: "" });
|
|
197
|
+
try {
|
|
198
|
+
const command = await translateToCommand(nl, config.permissions, sessionCmds, partial =>
|
|
199
|
+
setPhase({ type: "thinking", nl, partial })
|
|
200
|
+
);
|
|
201
|
+
const blocked = checkPermissions(command, config.permissions);
|
|
202
|
+
if (blocked) {
|
|
203
|
+
pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
|
|
204
|
+
inputPhase();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const danger = isIrreversible(command);
|
|
208
|
+
if (!config.confirm && !danger) { await runPhase(nl, command, false); return; }
|
|
209
|
+
setPhase({ type: "confirm", nl, command, danger });
|
|
210
|
+
} catch (e: any) {
|
|
211
|
+
setPhase({ type: "error", message: e.message });
|
|
212
|
+
}
|
|
213
|
+
};
|
|
244
214
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
215
|
+
// ── input handler ───────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
useInput(useCallback(async (input: string, key: any) => {
|
|
218
|
+
const phase = tabs[activeTab].phase;
|
|
219
|
+
|
|
220
|
+
// ── global: ctrl+c always exits ─────────────────────────────────────────
|
|
221
|
+
if (key.ctrl && input === "c" && phase.type !== "running") { exit(); return; }
|
|
222
|
+
|
|
223
|
+
// ── running: ctrl+c cancels ──────────────────────────────────────────────
|
|
224
|
+
if (phase.type === "running") {
|
|
225
|
+
if (key.ctrl && input === "c") { abortRef.current?.abort(); inputPhase(); }
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── browse ───────────────────────────────────────────────────────────────
|
|
230
|
+
if (phase.type === "browse") return; // handled by Browse component
|
|
231
|
+
|
|
232
|
+
// ── fuzzy ────────────────────────────────────────────────────────────────
|
|
233
|
+
if (phase.type === "fuzzy") return; // handled by FuzzyPicker component
|
|
234
|
+
|
|
235
|
+
// ── input ────────────────────────────────────────────────────────────────
|
|
236
|
+
if (phase.type === "input") {
|
|
237
|
+
// global shortcuts
|
|
238
|
+
if (key.ctrl && input === "l") { updateTab(t => ({ ...t, scroll: [] })); return; }
|
|
239
|
+
if (key.ctrl && input === "b") { setPhase({ type: "browse", cwd: tab.cwd }); return; }
|
|
240
|
+
if (key.ctrl && input === "r") { setPhase({ type: "fuzzy" }); return; }
|
|
241
|
+
|
|
242
|
+
// tab management
|
|
243
|
+
if (key.ctrl && input === "t") {
|
|
244
|
+
const id = nextTabId.current++;
|
|
245
|
+
setTabs(ts => [...ts, newTab(id, tab.cwd)]);
|
|
246
|
+
setActiveTab(tabs.length); // new tab index
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (key.ctrl && input === "w") {
|
|
250
|
+
if (tabs.length > 1) {
|
|
251
|
+
setTabs(ts => ts.filter((_, i) => i !== activeTab));
|
|
252
|
+
setActiveTab(i => Math.min(i, tabs.length - 2));
|
|
250
253
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (key.tab) {
|
|
257
|
+
setActiveTab(i => (i + 1) % tabs.length);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// history nav
|
|
262
|
+
if (key.upArrow) {
|
|
263
|
+
const idx = Math.min(phase.histIdx + 1, allNl.length - 1);
|
|
264
|
+
const val = allNl[allNl.length - 1 - idx] ?? "";
|
|
265
|
+
setPhase({ ...phase, value: val, cursor: val.length, histIdx: idx });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (key.downArrow) {
|
|
269
|
+
const idx = Math.max(phase.histIdx - 1, -1);
|
|
270
|
+
const val = idx === -1 ? "" : allNl[allNl.length - 1 - idx] ?? "";
|
|
271
|
+
setPhase({ ...phase, value: val, cursor: val.length, histIdx: idx });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// cursor movement
|
|
276
|
+
if (key.leftArrow) { setPhase({ ...phase, cursor: Math.max(0, phase.cursor - 1) }); return; }
|
|
277
|
+
if (key.rightArrow) {
|
|
278
|
+
// right arrow at end → accept ghost text
|
|
279
|
+
const ghost = ghostText(phase.value, allNl);
|
|
280
|
+
if (phase.cursor === phase.value.length && ghost) {
|
|
281
|
+
const full = phase.value + ghost;
|
|
282
|
+
setPhase({ ...phase, value: full, cursor: full.length });
|
|
283
|
+
} else {
|
|
284
|
+
setPhase({ ...phase, cursor: Math.min(phase.value.length, phase.cursor + 1) });
|
|
273
285
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (key.tab) {
|
|
289
|
+
// tab → accept ghost text
|
|
290
|
+
const ghost = ghostText(phase.value, allNl);
|
|
291
|
+
if (ghost) {
|
|
292
|
+
const full = phase.value + ghost;
|
|
293
|
+
setPhase({ ...phase, value: full, cursor: full.length });
|
|
279
294
|
return;
|
|
280
295
|
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (key.return) {
|
|
299
|
+
const nl = phase.value.trim();
|
|
300
|
+
if (!nl) return;
|
|
301
|
+
await translateAndRun(nl, phase.raw);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (key.backspace || key.delete) {
|
|
306
|
+
const val = deleteAt(phase.value, phase.cursor);
|
|
307
|
+
setPhase({ ...phase, value: val, cursor: Math.max(0, phase.cursor - 1), histIdx: -1 });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (input && !key.ctrl && !key.meta) {
|
|
311
|
+
const val = insertAt(phase.value, phase.cursor, input);
|
|
312
|
+
setPhase({ ...phase, value: val, cursor: phase.cursor + 1, histIdx: -1 });
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── confirm ──────────────────────────────────────────────────────────────
|
|
318
|
+
if (phase.type === "confirm") {
|
|
319
|
+
if (input === "?") {
|
|
320
|
+
const { nl, command } = phase;
|
|
321
|
+
setPhase({ type: "thinking", nl, partial: "" });
|
|
322
|
+
try {
|
|
323
|
+
const explanation = await explainCommand(command);
|
|
324
|
+
setPhase({ type: "explain", nl, command, explanation });
|
|
325
|
+
} catch { setPhase({ type: "confirm", nl, command, danger: phase.danger }); }
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (input === "y" || input === "Y" || key.return) { await runPhase(phase.nl, phase.command, false); return; }
|
|
329
|
+
if (input === "n" || input === "N" || key.escape) { inputPhase(); return; }
|
|
330
|
+
if (input === "e" || input === "E") {
|
|
331
|
+
setPhase({ type: "input", value: phase.command, cursor: phase.command.length, histIdx: -1, raw: false });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── explain ──────────────────────────────────────────────────────────────
|
|
338
|
+
if (phase.type === "explain") {
|
|
339
|
+
setPhase({ type: "confirm", nl: phase.nl, command: phase.command, danger: isIrreversible(phase.command) });
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── autofix ──────────────────────────────────────────────────────────────
|
|
344
|
+
if (phase.type === "autofix") {
|
|
345
|
+
if (input === "y" || input === "Y" || key.return) {
|
|
346
|
+
const { nl, command, errorOutput } = phase;
|
|
347
|
+
setPhase({ type: "thinking", nl, partial: "" });
|
|
348
|
+
try {
|
|
349
|
+
const fixed = await fixCommand(nl, command, errorOutput, config.permissions, tab.sessionCmds);
|
|
350
|
+
const danger = isIrreversible(fixed);
|
|
351
|
+
if (!config.confirm && !danger) { await runPhase(nl, fixed, false); return; }
|
|
352
|
+
setPhase({ type: "confirm", nl, command: fixed, danger });
|
|
353
|
+
} catch (e: any) { setPhase({ type: "error", message: e.message }); }
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
inputPhase();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── error ────────────────────────────────────────────────────────────────
|
|
361
|
+
if (phase.type === "error") { inputPhase(); return; }
|
|
362
|
+
|
|
363
|
+
}, [tabs, activeTab, allNl, config, exit]));
|
|
364
|
+
|
|
365
|
+
// ── onboarding ───────────────────────────────────────────────────────────────
|
|
291
366
|
if (!config.onboarded) {
|
|
292
|
-
return <Onboarding onDone={
|
|
367
|
+
return <Onboarding onDone={(perms: Permissions) => {
|
|
368
|
+
const next = { onboarded: true, confirm: false, permissions: perms };
|
|
369
|
+
setConfig(next);
|
|
370
|
+
saveConfig(next);
|
|
371
|
+
}} />;
|
|
293
372
|
}
|
|
294
373
|
|
|
374
|
+
const phase = tab.phase;
|
|
295
375
|
const isRaw = phase.type === "input" && phase.raw;
|
|
376
|
+
const ghost = phase.type === "input" ? ghostText(phase.value, allNl) : "";
|
|
377
|
+
|
|
378
|
+
// ── browse overlay ────────────────────────────────────────────────────────
|
|
379
|
+
if (phase.type === "browse") {
|
|
380
|
+
return (
|
|
381
|
+
<Box flexDirection="column">
|
|
382
|
+
{tabs.length > 1 && <TabBar tabs={tabs} active={activeTab} />}
|
|
383
|
+
<Browse
|
|
384
|
+
cwd={phase.cwd}
|
|
385
|
+
onCd={path => setPhase({ type: "browse", cwd: path })}
|
|
386
|
+
onSelect={path => {
|
|
387
|
+
// fill input with the path
|
|
388
|
+
setPhase({ type: "input", value: path, cursor: path.length, histIdx: -1, raw: false });
|
|
389
|
+
}}
|
|
390
|
+
onExit={() => inputPhase()}
|
|
391
|
+
/>
|
|
392
|
+
<StatusBar permissions={config.permissions} />
|
|
393
|
+
</Box>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── fuzzy overlay ──────────────────────────────────────────────────────────
|
|
398
|
+
if (phase.type === "fuzzy") {
|
|
399
|
+
return (
|
|
400
|
+
<Box flexDirection="column">
|
|
401
|
+
{tabs.length > 1 && <TabBar tabs={tabs} active={activeTab} />}
|
|
402
|
+
<FuzzyPicker
|
|
403
|
+
history={allNl}
|
|
404
|
+
onSelect={nl => {
|
|
405
|
+
setPhase({ type: "input", value: nl, cursor: nl.length, histIdx: -1, raw: false });
|
|
406
|
+
}}
|
|
407
|
+
onExit={() => inputPhase()}
|
|
408
|
+
/>
|
|
409
|
+
<StatusBar permissions={config.permissions} />
|
|
410
|
+
</Box>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
296
413
|
|
|
297
|
-
// ── render
|
|
414
|
+
// ── main render ───────────────────────────────────────────────────────────
|
|
298
415
|
return (
|
|
299
416
|
<Box flexDirection="column">
|
|
300
417
|
|
|
418
|
+
{/* tab bar — only shown when >1 tab */}
|
|
419
|
+
{tabs.length > 1 && <TabBar tabs={tabs} active={activeTab} />}
|
|
420
|
+
|
|
301
421
|
{/* scrollback */}
|
|
302
|
-
{scroll.map((entry, i) => (
|
|
422
|
+
{tab.scroll.map((entry, i) => (
|
|
303
423
|
<Box key={i} flexDirection="column" marginBottom={1} paddingLeft={2}>
|
|
304
424
|
<Box gap={2}>
|
|
305
425
|
<Text dimColor>›</Text>
|
|
@@ -313,11 +433,11 @@ export default function App() {
|
|
|
313
433
|
)}
|
|
314
434
|
{entry.lines.length > 0 && (
|
|
315
435
|
<Box flexDirection="column" paddingLeft={4}>
|
|
316
|
-
{entry.lines.map((line, j) => (
|
|
436
|
+
{(entry.expanded ? entry.lines : entry.lines).map((line, j) => (
|
|
317
437
|
<Text key={j} color={entry.error ? "red" : undefined}>{line}</Text>
|
|
318
438
|
))}
|
|
319
439
|
{entry.truncated && !entry.expanded && (
|
|
320
|
-
<Text dimColor
|
|
440
|
+
<Text dimColor> … more lines</Text>
|
|
321
441
|
)}
|
|
322
442
|
</Box>
|
|
323
443
|
)}
|
|
@@ -334,7 +454,7 @@ export default function App() {
|
|
|
334
454
|
<Box gap={2} paddingLeft={2}>
|
|
335
455
|
<Text dimColor>$</Text>
|
|
336
456
|
<Text>{phase.command}</Text>
|
|
337
|
-
{phase.danger && <Text color="red">
|
|
457
|
+
{phase.danger && <Text color="red"> ⚠ irreversible</Text>}
|
|
338
458
|
</Box>
|
|
339
459
|
<Box paddingLeft={4}><Text dimColor>enter n e ?</Text></Box>
|
|
340
460
|
</Box>
|
|
@@ -347,64 +467,45 @@ export default function App() {
|
|
|
347
467
|
<Text dimColor>$</Text>
|
|
348
468
|
<Text>{phase.command}</Text>
|
|
349
469
|
</Box>
|
|
350
|
-
<Box paddingLeft={4}>
|
|
351
|
-
|
|
352
|
-
</Box>
|
|
353
|
-
<Box paddingLeft={4}><Text dimColor>any key to continue</Text></Box>
|
|
470
|
+
<Box paddingLeft={4}><Text dimColor>{phase.explanation}</Text></Box>
|
|
471
|
+
<Box paddingLeft={4}><Text dimColor>any key →</Text></Box>
|
|
354
472
|
</Box>
|
|
355
473
|
)}
|
|
356
474
|
|
|
357
475
|
{/* autofix */}
|
|
358
476
|
{phase.type === "autofix" && (
|
|
359
|
-
<Box
|
|
360
|
-
<Text dimColor>
|
|
477
|
+
<Box paddingLeft={2}>
|
|
478
|
+
<Text dimColor>failed — retry with fix? [enter / n]</Text>
|
|
361
479
|
</Box>
|
|
362
480
|
)}
|
|
363
481
|
|
|
364
|
-
{/* thinking
|
|
482
|
+
{/* thinking */}
|
|
365
483
|
{phase.type === "thinking" && (
|
|
366
484
|
phase.partial ? (
|
|
367
485
|
<Box flexDirection="column" paddingLeft={2}>
|
|
368
|
-
<Box gap={2}>
|
|
369
|
-
|
|
370
|
-
<Text dimColor>{phase.nl}</Text>
|
|
371
|
-
</Box>
|
|
372
|
-
<Box gap={2} paddingLeft={2}>
|
|
373
|
-
<Text dimColor>$</Text>
|
|
374
|
-
<Text dimColor>{phase.partial}</Text>
|
|
375
|
-
</Box>
|
|
486
|
+
<Box gap={2}><Text dimColor>›</Text><Text dimColor>{phase.nl}</Text></Box>
|
|
487
|
+
<Box gap={2} paddingLeft={2}><Text dimColor>$</Text><Text dimColor>{phase.partial}</Text></Box>
|
|
376
488
|
</Box>
|
|
377
|
-
) :
|
|
378
|
-
<Spinner label="translating" />
|
|
379
|
-
)
|
|
489
|
+
) : <Spinner label="translating" />
|
|
380
490
|
)}
|
|
381
491
|
|
|
382
|
-
{/* running
|
|
492
|
+
{/* running */}
|
|
383
493
|
{phase.type === "running" && (
|
|
384
494
|
<Box flexDirection="column" paddingLeft={2}>
|
|
385
|
-
<Box gap={2}>
|
|
386
|
-
|
|
387
|
-
<Text
|
|
495
|
+
<Box gap={2}><Text dimColor>$</Text><Text dimColor>{phase.command}</Text></Box>
|
|
496
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
497
|
+
{tab.streamLines.slice(-MAX_LINES).map((l, i) => <Text key={i}>{l}</Text>)}
|
|
388
498
|
</Box>
|
|
389
|
-
{streamLines.length > 0 && (
|
|
390
|
-
<Box flexDirection="column" paddingLeft={2}>
|
|
391
|
-
{streamLines.slice(-MAX_LINES).map((line, i) => (
|
|
392
|
-
<Text key={i}>{line}</Text>
|
|
393
|
-
))}
|
|
394
|
-
</Box>
|
|
395
|
-
)}
|
|
396
499
|
<Spinner label="ctrl+c to cancel" />
|
|
397
500
|
</Box>
|
|
398
501
|
)}
|
|
399
502
|
|
|
400
503
|
{/* error */}
|
|
401
504
|
{phase.type === "error" && (
|
|
402
|
-
<Box paddingLeft={2}>
|
|
403
|
-
<Text color="red">{phase.message}</Text>
|
|
404
|
-
</Box>
|
|
505
|
+
<Box paddingLeft={2}><Text color="red">{phase.message}</Text></Box>
|
|
405
506
|
)}
|
|
406
507
|
|
|
407
|
-
{/* input */}
|
|
508
|
+
{/* input with ghost text */}
|
|
408
509
|
{phase.type === "input" && (
|
|
409
510
|
<Box gap={2} paddingLeft={2}>
|
|
410
511
|
<Text dimColor>{isRaw ? "$" : "›"}</Text>
|
|
@@ -412,11 +513,36 @@ export default function App() {
|
|
|
412
513
|
<Text>{phase.value.slice(0, phase.cursor)}</Text>
|
|
413
514
|
<Text inverse>{phase.value[phase.cursor] ?? " "}</Text>
|
|
414
515
|
<Text>{phase.value.slice(phase.cursor + 1)}</Text>
|
|
516
|
+
{ghost && phase.cursor === phase.value.length && (
|
|
517
|
+
<Text dimColor>{ghost}</Text>
|
|
518
|
+
)}
|
|
415
519
|
</Box>
|
|
416
520
|
</Box>
|
|
417
521
|
)}
|
|
418
522
|
|
|
419
|
-
<StatusBar permissions={config.permissions} />
|
|
523
|
+
<StatusBar permissions={config.permissions} cwd={tab.cwd} />
|
|
524
|
+
</Box>
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── TabBar ────────────────────────────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
function TabBar({ tabs, active }: { tabs: TabState[]; active: number }) {
|
|
531
|
+
return (
|
|
532
|
+
<Box gap={1} paddingLeft={2} marginBottom={1}>
|
|
533
|
+
{tabs.map((t, i) => {
|
|
534
|
+
const label = ` ${i + 1} `;
|
|
535
|
+
const cwd = t.cwd.split("/").pop() || t.cwd;
|
|
536
|
+
return (
|
|
537
|
+
<Box key={t.id}>
|
|
538
|
+
{i === active
|
|
539
|
+
? <Text inverse>{label}{cwd}</Text>
|
|
540
|
+
: <Text dimColor>{label}{cwd}</Text>
|
|
541
|
+
}
|
|
542
|
+
</Box>
|
|
543
|
+
);
|
|
544
|
+
})}
|
|
545
|
+
<Text dimColor> ctrl+t new tab switch ctrl+w close</Text>
|
|
420
546
|
</Box>
|
|
421
547
|
);
|
|
422
548
|
}
|