@hasna/terminal 0.1.3 → 0.1.5
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/dist/App.js +219 -102
- package/dist/Browse.js +79 -0
- package/dist/FuzzyPicker.js +47 -0
- package/dist/StatusBar.js +20 -16
- package/dist/ai.js +81 -38
- package/dist/cache.js +41 -0
- package/package.json +1 -1
- package/src/App.tsx +379 -232
- package/src/Browse.tsx +103 -0
- package/src/FuzzyPicker.tsx +69 -0
- package/src/StatusBar.tsx +28 -34
- package/src/ai.ts +98 -48
- package/src/cache.ts +43 -0
package/src/App.tsx
CHANGED
|
@@ -1,28 +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
|
-
|
|
7
|
-
appendHistory,
|
|
8
|
-
loadConfig,
|
|
9
|
-
saveConfig,
|
|
10
|
-
type Permissions,
|
|
11
|
-
} from "./history.js";
|
|
5
|
+
import { loadHistory, appendHistory, loadConfig, saveConfig, type Permissions } from "./history.js";
|
|
6
|
+
import { loadCache } from "./cache.js";
|
|
12
7
|
import Onboarding from "./Onboarding.js";
|
|
13
8
|
import StatusBar from "./StatusBar.js";
|
|
14
9
|
import Spinner from "./Spinner.js";
|
|
10
|
+
import Browse from "./Browse.js";
|
|
11
|
+
import FuzzyPicker from "./FuzzyPicker.js";
|
|
12
|
+
|
|
13
|
+
loadCache();
|
|
15
14
|
|
|
16
15
|
// ── types ─────────────────────────────────────────────────────────────────────
|
|
17
16
|
|
|
18
17
|
type Phase =
|
|
19
18
|
| { type: "input"; value: string; cursor: number; histIdx: number; raw: boolean }
|
|
20
|
-
| { type: "thinking"; nl: string;
|
|
21
|
-
| { type: "confirm"; nl: string; command: string;
|
|
19
|
+
| { type: "thinking"; nl: string; partial: string }
|
|
20
|
+
| { type: "confirm"; nl: string; command: string; danger: boolean }
|
|
22
21
|
| { type: "explain"; nl: string; command: string; explanation: string }
|
|
23
22
|
| { type: "running"; nl: string; command: string }
|
|
24
23
|
| { type: "autofix"; nl: string; command: string; errorOutput: string }
|
|
25
|
-
| { type: "error"; message: string }
|
|
24
|
+
| { type: "error"; message: string }
|
|
25
|
+
| { type: "browse"; cwd: string }
|
|
26
|
+
| { type: "fuzzy" };
|
|
26
27
|
|
|
27
28
|
interface ScrollEntry {
|
|
28
29
|
nl: string;
|
|
@@ -31,6 +32,17 @@ interface ScrollEntry {
|
|
|
31
32
|
truncated: boolean;
|
|
32
33
|
expanded: boolean;
|
|
33
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[];
|
|
34
46
|
}
|
|
35
47
|
|
|
36
48
|
const MAX_LINES = 20;
|
|
@@ -40,83 +52,129 @@ const MAX_LINES = 20;
|
|
|
40
52
|
function insertAt(s: string, pos: number, ch: string) { return s.slice(0, pos) + ch + s.slice(pos); }
|
|
41
53
|
function deleteAt(s: string, pos: number) { return pos <= 0 ? s : s.slice(0, pos - 1) + s.slice(pos); }
|
|
42
54
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
signal: AbortSignal
|
|
48
|
-
): ChildProcess {
|
|
49
|
-
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
|
+
}
|
|
50
59
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
}
|
|
67
|
+
|
|
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
|
+
}
|
|
55
76
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
}
|
|
59
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));
|
|
60
97
|
signal.addEventListener("abort", () => { try { proc.kill("SIGTERM"); } catch {} });
|
|
61
|
-
return proc;
|
|
62
98
|
}
|
|
63
99
|
|
|
64
|
-
// ──
|
|
100
|
+
// ── App ───────────────────────────────────────────────────────────────────────
|
|
65
101
|
|
|
66
102
|
export default function App() {
|
|
67
103
|
const { exit } = useApp();
|
|
68
104
|
const [config, setConfig] = useState(() => loadConfig());
|
|
69
|
-
const [nlHistory] = useState<string[]>(() => loadHistory().map(
|
|
70
|
-
const [
|
|
71
|
-
const [
|
|
72
|
-
const [scroll, setScroll] = useState<ScrollEntry[]>([]);
|
|
73
|
-
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);
|
|
74
108
|
const abortRef = useRef<AbortController | null>(null);
|
|
109
|
+
let nextTabId = useRef(2);
|
|
75
110
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
});
|
|
111
|
+
const tab = tabs[activeTab];
|
|
112
|
+
const allNl = [...nlHistory, ...tab.sessionNl];
|
|
79
113
|
|
|
80
|
-
|
|
114
|
+
// ── tab helpers ─────────────────────────────────────────────────────────────
|
|
81
115
|
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
};
|
|
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 }));
|
|
87
121
|
|
|
88
122
|
const inputPhase = (overrides: Partial<Extract<Phase, { type: "input" }>> = {}) => {
|
|
89
|
-
|
|
90
|
-
|
|
123
|
+
updateTab(t => ({
|
|
124
|
+
...t,
|
|
125
|
+
streamLines: [],
|
|
126
|
+
phase: { type: "input", value: "", cursor: 0, histIdx: -1, raw: false, ...overrides },
|
|
127
|
+
}));
|
|
91
128
|
};
|
|
92
129
|
|
|
93
130
|
const pushScroll = (entry: Omit<ScrollEntry, "expanded">) =>
|
|
94
|
-
|
|
131
|
+
updateTab(t => ({ ...t, scroll: [...t.scroll, { ...entry, expanded: false }] }));
|
|
95
132
|
|
|
96
133
|
const commitStream = (nl: string, cmd: string, lines: string[], error: boolean) => {
|
|
97
134
|
const truncated = lines.length > MAX_LINES;
|
|
98
|
-
|
|
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
|
+
}));
|
|
99
148
|
appendHistory({ nl, cmd, output: lines.join("\n"), ts: Date.now(), error });
|
|
100
|
-
setSessionCmds((c) => [...c.slice(-9), cmd]);
|
|
101
|
-
setStreamLines([]);
|
|
102
149
|
};
|
|
103
150
|
|
|
151
|
+
// ── run command ─────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
104
153
|
const runPhase = async (nl: string, command: string, raw: boolean) => {
|
|
105
154
|
setPhase({ type: "running", nl, command });
|
|
106
|
-
|
|
155
|
+
updateTab(t => ({ ...t, streamLines: [] }));
|
|
107
156
|
const abort = new AbortController();
|
|
108
157
|
abortRef.current = abort;
|
|
109
158
|
const lines: string[] = [];
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
}
|
|
116
175
|
commitStream(nl, command, lines, code !== 0);
|
|
117
176
|
abortRef.current = null;
|
|
118
177
|
if (code !== 0 && !raw) {
|
|
119
|
-
// offer auto-fix
|
|
120
178
|
setPhase({ type: "autofix", nl, command, errorOutput: lines.join("\n") });
|
|
121
179
|
} else {
|
|
122
180
|
inputPhase({ raw });
|
|
@@ -128,172 +186,240 @@ export default function App() {
|
|
|
128
186
|
});
|
|
129
187
|
};
|
|
130
188
|
|
|
131
|
-
|
|
132
|
-
useCallback(
|
|
133
|
-
async (input: string, key: any) => {
|
|
134
|
-
|
|
135
|
-
// ── running: Ctrl+C kills process ─────────────────────────────────
|
|
136
|
-
if (phase.type === "running") {
|
|
137
|
-
if (key.ctrl && input === "c") {
|
|
138
|
-
abortRef.current?.abort();
|
|
139
|
-
inputPhase();
|
|
140
|
-
}
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ── input ─────────────────────────────────────────────────────────
|
|
145
|
-
if (phase.type === "input") {
|
|
146
|
-
if (key.ctrl && input === "c") { exit(); return; }
|
|
147
|
-
if (key.ctrl && input === "l") { setScroll([]); return; }
|
|
148
|
-
if (key.ctrl && input === "r") {
|
|
149
|
-
setPhase({ ...phase, raw: !phase.raw, value: "", cursor: 0 });
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (key.upArrow) {
|
|
154
|
-
const idx = Math.min(phase.histIdx + 1, allNl.length - 1);
|
|
155
|
-
const val = allNl[allNl.length - 1 - idx] ?? "";
|
|
156
|
-
setPhase({ ...phase, value: val, cursor: val.length, histIdx: idx });
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
if (key.downArrow) {
|
|
160
|
-
const idx = Math.max(phase.histIdx - 1, -1);
|
|
161
|
-
const val = idx === -1 ? "" : allNl[allNl.length - 1 - idx] ?? "";
|
|
162
|
-
setPhase({ ...phase, value: val, cursor: val.length, histIdx: idx });
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
if (key.leftArrow) { setPhase({ ...phase, cursor: Math.max(0, phase.cursor - 1) }); return; }
|
|
166
|
-
if (key.rightArrow) { setPhase({ ...phase, cursor: Math.min(phase.value.length, phase.cursor + 1) }); return; }
|
|
167
|
-
|
|
168
|
-
if (key.return) {
|
|
169
|
-
const nl = phase.value.trim();
|
|
170
|
-
if (!nl) return;
|
|
171
|
-
setSessionNl((h) => [...h, nl]);
|
|
172
|
-
|
|
173
|
-
if (phase.raw) {
|
|
174
|
-
await runPhase(nl, nl, true);
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
setPhase({ type: "thinking", nl, raw: false });
|
|
179
|
-
try {
|
|
180
|
-
const command = await translateToCommand(nl, config.permissions, sessionCmds);
|
|
181
|
-
const blocked = checkPermissions(command, config.permissions);
|
|
182
|
-
if (blocked) {
|
|
183
|
-
pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
|
|
184
|
-
inputPhase();
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
const danger = isIrreversible(command);
|
|
188
|
-
// skip confirm unless user opted in OR command is dangerous
|
|
189
|
-
if (!config.confirm && !danger) {
|
|
190
|
-
await runPhase(nl, command, false);
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
setPhase({ type: "confirm", nl, command, raw: false, danger });
|
|
194
|
-
} catch (e: any) {
|
|
195
|
-
setPhase({ type: "error", message: e.message });
|
|
196
|
-
}
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (key.backspace || key.delete) {
|
|
201
|
-
const val = deleteAt(phase.value, phase.cursor);
|
|
202
|
-
setPhase({ ...phase, value: val, cursor: Math.max(0, phase.cursor - 1), histIdx: -1 });
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
if (input && !key.ctrl && !key.meta) {
|
|
206
|
-
const val = insertAt(phase.value, phase.cursor, input);
|
|
207
|
-
setPhase({ ...phase, value: val, cursor: phase.cursor + 1, histIdx: -1 });
|
|
208
|
-
}
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
189
|
+
// ── translate + run ─────────────────────────────────────────────────────────
|
|
211
190
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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; }
|
|
215
194
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
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
|
+
};
|
|
238
214
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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));
|
|
244
253
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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) });
|
|
267
285
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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 });
|
|
273
294
|
return;
|
|
274
295
|
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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 ───────────────────────────────────────────────────────────────
|
|
285
366
|
if (!config.onboarded) {
|
|
286
|
-
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
|
+
}} />;
|
|
287
372
|
}
|
|
288
373
|
|
|
374
|
+
const phase = tab.phase;
|
|
289
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
|
+
}
|
|
290
413
|
|
|
291
|
-
// ── render
|
|
414
|
+
// ── main render ───────────────────────────────────────────────────────────
|
|
292
415
|
return (
|
|
293
416
|
<Box flexDirection="column">
|
|
294
417
|
|
|
418
|
+
{/* tab bar — only shown when >1 tab */}
|
|
419
|
+
{tabs.length > 1 && <TabBar tabs={tabs} active={activeTab} />}
|
|
420
|
+
|
|
295
421
|
{/* scrollback */}
|
|
296
|
-
{scroll.map((entry, i) => (
|
|
422
|
+
{tab.scroll.map((entry, i) => (
|
|
297
423
|
<Box key={i} flexDirection="column" marginBottom={1} paddingLeft={2}>
|
|
298
424
|
<Box gap={2}>
|
|
299
425
|
<Text dimColor>›</Text>
|
|
@@ -307,11 +433,11 @@ export default function App() {
|
|
|
307
433
|
)}
|
|
308
434
|
{entry.lines.length > 0 && (
|
|
309
435
|
<Box flexDirection="column" paddingLeft={4}>
|
|
310
|
-
{entry.lines.map((line, j) => (
|
|
436
|
+
{(entry.expanded ? entry.lines : entry.lines).map((line, j) => (
|
|
311
437
|
<Text key={j} color={entry.error ? "red" : undefined}>{line}</Text>
|
|
312
438
|
))}
|
|
313
439
|
{entry.truncated && !entry.expanded && (
|
|
314
|
-
<Text dimColor
|
|
440
|
+
<Text dimColor> … more lines</Text>
|
|
315
441
|
)}
|
|
316
442
|
</Box>
|
|
317
443
|
)}
|
|
@@ -328,7 +454,7 @@ export default function App() {
|
|
|
328
454
|
<Box gap={2} paddingLeft={2}>
|
|
329
455
|
<Text dimColor>$</Text>
|
|
330
456
|
<Text>{phase.command}</Text>
|
|
331
|
-
{phase.danger && <Text color="red">
|
|
457
|
+
{phase.danger && <Text color="red"> ⚠ irreversible</Text>}
|
|
332
458
|
</Box>
|
|
333
459
|
<Box paddingLeft={4}><Text dimColor>enter n e ?</Text></Box>
|
|
334
460
|
</Box>
|
|
@@ -341,49 +467,45 @@ export default function App() {
|
|
|
341
467
|
<Text dimColor>$</Text>
|
|
342
468
|
<Text>{phase.command}</Text>
|
|
343
469
|
</Box>
|
|
344
|
-
<Box paddingLeft={4}>
|
|
345
|
-
|
|
346
|
-
</Box>
|
|
347
|
-
<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>
|
|
348
472
|
</Box>
|
|
349
473
|
)}
|
|
350
474
|
|
|
351
475
|
{/* autofix */}
|
|
352
476
|
{phase.type === "autofix" && (
|
|
353
|
-
<Box
|
|
354
|
-
<Text dimColor>
|
|
477
|
+
<Box paddingLeft={2}>
|
|
478
|
+
<Text dimColor>failed — retry with fix? [enter / n]</Text>
|
|
355
479
|
</Box>
|
|
356
480
|
)}
|
|
357
481
|
|
|
358
|
-
{/*
|
|
359
|
-
{phase.type === "thinking" &&
|
|
482
|
+
{/* thinking */}
|
|
483
|
+
{phase.type === "thinking" && (
|
|
484
|
+
phase.partial ? (
|
|
485
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
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>
|
|
488
|
+
</Box>
|
|
489
|
+
) : <Spinner label="translating" />
|
|
490
|
+
)}
|
|
360
491
|
|
|
361
|
-
{/* running
|
|
492
|
+
{/* running */}
|
|
362
493
|
{phase.type === "running" && (
|
|
363
494
|
<Box flexDirection="column" paddingLeft={2}>
|
|
364
|
-
<Box gap={2}>
|
|
365
|
-
|
|
366
|
-
<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>)}
|
|
367
498
|
</Box>
|
|
368
|
-
{streamLines.length > 0 && (
|
|
369
|
-
<Box flexDirection="column" paddingLeft={2}>
|
|
370
|
-
{streamLines.slice(-MAX_LINES).map((line, i) => (
|
|
371
|
-
<Text key={i}>{line}</Text>
|
|
372
|
-
))}
|
|
373
|
-
</Box>
|
|
374
|
-
)}
|
|
375
499
|
<Spinner label="ctrl+c to cancel" />
|
|
376
500
|
</Box>
|
|
377
501
|
)}
|
|
378
502
|
|
|
379
503
|
{/* error */}
|
|
380
504
|
{phase.type === "error" && (
|
|
381
|
-
<Box paddingLeft={2}>
|
|
382
|
-
<Text color="red">{phase.message}</Text>
|
|
383
|
-
</Box>
|
|
505
|
+
<Box paddingLeft={2}><Text color="red">{phase.message}</Text></Box>
|
|
384
506
|
)}
|
|
385
507
|
|
|
386
|
-
{/* input */}
|
|
508
|
+
{/* input with ghost text */}
|
|
387
509
|
{phase.type === "input" && (
|
|
388
510
|
<Box gap={2} paddingLeft={2}>
|
|
389
511
|
<Text dimColor>{isRaw ? "$" : "›"}</Text>
|
|
@@ -391,11 +513,36 @@ export default function App() {
|
|
|
391
513
|
<Text>{phase.value.slice(0, phase.cursor)}</Text>
|
|
392
514
|
<Text inverse>{phase.value[phase.cursor] ?? " "}</Text>
|
|
393
515
|
<Text>{phase.value.slice(phase.cursor + 1)}</Text>
|
|
516
|
+
{ghost && phase.cursor === phase.value.length && (
|
|
517
|
+
<Text dimColor>{ghost}</Text>
|
|
518
|
+
)}
|
|
394
519
|
</Box>
|
|
395
520
|
</Box>
|
|
396
521
|
)}
|
|
397
522
|
|
|
398
|
-
<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>
|
|
399
546
|
</Box>
|
|
400
547
|
);
|
|
401
548
|
}
|