@hasna/terminal 0.1.0 → 0.1.2
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 +163 -38
- package/dist/Onboarding.js +19 -23
- package/dist/Spinner.js +12 -0
- package/dist/StatusBar.js +45 -0
- package/dist/ai.js +73 -23
- package/dist/history.js +4 -4
- package/package.json +4 -4
- package/src/App.tsx +262 -82
- package/src/Onboarding.tsx +47 -59
- package/src/Spinner.tsx +24 -0
- package/src/StatusBar.tsx +67 -0
- package/src/ai.ts +94 -27
- package/src/history.ts +4 -4
package/src/App.tsx
CHANGED
|
@@ -1,43 +1,81 @@
|
|
|
1
|
-
import React, { useState, useCallback } from "react";
|
|
1
|
+
import React, { useState, useCallback, useRef } from "react";
|
|
2
2
|
import { Box, Text, useInput, useApp } from "ink";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { translateToCommand, checkPermissions } from "./ai.js";
|
|
3
|
+
import { spawn, type ChildProcess } from "child_process";
|
|
4
|
+
import { translateToCommand, explainCommand, fixCommand, checkPermissions, isIrreversible } from "./ai.js";
|
|
6
5
|
import {
|
|
7
6
|
loadHistory,
|
|
8
7
|
appendHistory,
|
|
9
8
|
loadConfig,
|
|
10
9
|
saveConfig,
|
|
11
|
-
type HistoryEntry,
|
|
12
10
|
type Permissions,
|
|
13
11
|
} from "./history.js";
|
|
14
12
|
import Onboarding from "./Onboarding.js";
|
|
13
|
+
import StatusBar from "./StatusBar.js";
|
|
14
|
+
import Spinner from "./Spinner.js";
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// ── types ─────────────────────────────────────────────────────────────────────
|
|
17
17
|
|
|
18
18
|
type Phase =
|
|
19
|
-
| { type: "input";
|
|
20
|
-
| { type: "thinking"; nl: string }
|
|
21
|
-
| { type: "confirm"; nl: string; command: string }
|
|
19
|
+
| { type: "input"; value: string; cursor: number; histIdx: number; raw: boolean }
|
|
20
|
+
| { type: "thinking"; nl: string; raw: boolean }
|
|
21
|
+
| { type: "confirm"; nl: string; command: string; raw: boolean; danger: boolean }
|
|
22
|
+
| { type: "explain"; nl: string; command: string; explanation: string }
|
|
22
23
|
| { type: "running"; nl: string; command: string }
|
|
23
|
-
| { type: "
|
|
24
|
+
| { type: "autofix"; nl: string; command: string; errorOutput: string }
|
|
25
|
+
| { type: "error"; message: string };
|
|
24
26
|
|
|
25
27
|
interface ScrollEntry {
|
|
26
28
|
nl: string;
|
|
27
29
|
cmd: string;
|
|
28
|
-
|
|
30
|
+
lines: string[];
|
|
31
|
+
truncated: boolean;
|
|
32
|
+
expanded: boolean;
|
|
29
33
|
error?: boolean;
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
const MAX_LINES = 20;
|
|
37
|
+
|
|
38
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function insertAt(s: string, pos: number, ch: string) { return s.slice(0, pos) + ch + s.slice(pos); }
|
|
41
|
+
function deleteAt(s: string, pos: number) { return pos <= 0 ? s : s.slice(0, pos - 1) + s.slice(pos); }
|
|
42
|
+
|
|
43
|
+
function runCommand(
|
|
44
|
+
command: string,
|
|
45
|
+
onLine: (line: string) => void,
|
|
46
|
+
onDone: (code: number) => void,
|
|
47
|
+
signal: AbortSignal
|
|
48
|
+
): ChildProcess {
|
|
49
|
+
const proc = spawn("/bin/zsh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"] });
|
|
50
|
+
|
|
51
|
+
const handleData = (data: Buffer) => {
|
|
52
|
+
const text = data.toString();
|
|
53
|
+
text.split("\n").forEach((line) => { if (line) onLine(line); });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
proc.stdout?.on("data", handleData);
|
|
57
|
+
proc.stderr?.on("data", handleData);
|
|
58
|
+
proc.on("close", (code) => onDone(code ?? 0));
|
|
59
|
+
|
|
60
|
+
signal.addEventListener("abort", () => { try { proc.kill("SIGTERM"); } catch {} });
|
|
61
|
+
return proc;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── component ─────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
32
66
|
export default function App() {
|
|
33
67
|
const { exit } = useApp();
|
|
34
68
|
const [config, setConfig] = useState(() => loadConfig());
|
|
35
|
-
const [nlHistory] = useState<string[]>(() =>
|
|
36
|
-
|
|
37
|
-
);
|
|
69
|
+
const [nlHistory] = useState<string[]>(() => loadHistory().map((h) => h.nl).filter(Boolean));
|
|
70
|
+
const [sessionCmds, setSessionCmds] = useState<string[]>([]);
|
|
38
71
|
const [sessionNl, setSessionNl] = useState<string[]>([]);
|
|
39
72
|
const [scroll, setScroll] = useState<ScrollEntry[]>([]);
|
|
40
|
-
const [
|
|
73
|
+
const [streamLines, setStreamLines] = useState<string[]>([]);
|
|
74
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
75
|
+
|
|
76
|
+
const [phase, setPhase] = useState<Phase>({
|
|
77
|
+
type: "input", value: "", cursor: 0, histIdx: -1, raw: false,
|
|
78
|
+
});
|
|
41
79
|
|
|
42
80
|
const allNl = [...nlHistory, ...sessionNl];
|
|
43
81
|
|
|
@@ -47,166 +85,308 @@ export default function App() {
|
|
|
47
85
|
saveConfig(next);
|
|
48
86
|
};
|
|
49
87
|
|
|
50
|
-
const
|
|
88
|
+
const inputPhase = (overrides: Partial<Extract<Phase, { type: "input" }>> = {}) => {
|
|
89
|
+
setPhase({ type: "input", value: "", cursor: 0, histIdx: -1, raw: false, ...overrides });
|
|
90
|
+
setStreamLines([]);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const pushScroll = (entry: Omit<ScrollEntry, "expanded">) =>
|
|
94
|
+
setScroll((s) => [...s, { ...entry, expanded: false }]);
|
|
95
|
+
|
|
96
|
+
const commitStream = (nl: string, cmd: string, lines: string[], error: boolean) => {
|
|
97
|
+
const truncated = lines.length > MAX_LINES;
|
|
98
|
+
pushScroll({ nl, cmd, lines: truncated ? lines.slice(0, MAX_LINES) : lines, truncated, error });
|
|
99
|
+
appendHistory({ nl, cmd, output: lines.join("\n"), ts: Date.now(), error });
|
|
100
|
+
setSessionCmds((c) => [...c.slice(-9), cmd]);
|
|
101
|
+
setStreamLines([]);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const runPhase = async (nl: string, command: string, raw: boolean) => {
|
|
105
|
+
setPhase({ type: "running", nl, command });
|
|
106
|
+
setStreamLines([]);
|
|
107
|
+
const abort = new AbortController();
|
|
108
|
+
abortRef.current = abort;
|
|
109
|
+
const lines: string[] = [];
|
|
110
|
+
|
|
111
|
+
await new Promise<void>((resolve) => {
|
|
112
|
+
runCommand(
|
|
113
|
+
command,
|
|
114
|
+
(line) => { lines.push(line); setStreamLines([...lines]); },
|
|
115
|
+
(code) => {
|
|
116
|
+
commitStream(nl, command, lines, code !== 0);
|
|
117
|
+
abortRef.current = null;
|
|
118
|
+
if (code !== 0 && !raw) {
|
|
119
|
+
// offer auto-fix
|
|
120
|
+
setPhase({ type: "autofix", nl, command, errorOutput: lines.join("\n") });
|
|
121
|
+
} else {
|
|
122
|
+
inputPhase({ raw });
|
|
123
|
+
}
|
|
124
|
+
resolve();
|
|
125
|
+
},
|
|
126
|
+
abort.signal
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
};
|
|
51
130
|
|
|
52
131
|
useInput(
|
|
53
132
|
useCallback(
|
|
54
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 ─────────────────────────────────────────────────────────
|
|
55
145
|
if (phase.type === "input") {
|
|
56
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
|
+
}
|
|
57
152
|
|
|
58
153
|
if (key.upArrow) {
|
|
59
|
-
const
|
|
60
|
-
const val = allNl[allNl.length - 1 -
|
|
61
|
-
setPhase({
|
|
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 });
|
|
62
157
|
return;
|
|
63
158
|
}
|
|
64
159
|
if (key.downArrow) {
|
|
65
|
-
const
|
|
66
|
-
const val =
|
|
67
|
-
setPhase({
|
|
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 });
|
|
68
163
|
return;
|
|
69
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
|
+
|
|
70
168
|
if (key.return) {
|
|
71
169
|
const nl = phase.value.trim();
|
|
72
170
|
if (!nl) return;
|
|
73
171
|
setSessionNl((h) => [...h, nl]);
|
|
74
|
-
|
|
172
|
+
|
|
173
|
+
if (phase.raw) {
|
|
174
|
+
await runPhase(nl, nl, true);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
setPhase({ type: "thinking", nl, raw: false });
|
|
75
179
|
try {
|
|
76
|
-
const command = await translateToCommand(nl, config.permissions);
|
|
77
|
-
// Local permission guard on the returned command
|
|
180
|
+
const command = await translateToCommand(nl, config.permissions, sessionCmds);
|
|
78
181
|
const blocked = checkPermissions(command, config.permissions);
|
|
79
182
|
if (blocked) {
|
|
80
|
-
pushScroll({ nl, cmd: command,
|
|
81
|
-
|
|
183
|
+
pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
|
|
184
|
+
inputPhase();
|
|
82
185
|
return;
|
|
83
186
|
}
|
|
84
|
-
|
|
187
|
+
const danger = isIrreversible(command);
|
|
188
|
+
setPhase({ type: "confirm", nl, command, raw: false, danger });
|
|
85
189
|
} catch (e: any) {
|
|
86
190
|
setPhase({ type: "error", message: e.message });
|
|
87
191
|
}
|
|
88
192
|
return;
|
|
89
193
|
}
|
|
194
|
+
|
|
90
195
|
if (key.backspace || key.delete) {
|
|
91
|
-
|
|
196
|
+
const val = deleteAt(phase.value, phase.cursor);
|
|
197
|
+
setPhase({ ...phase, value: val, cursor: Math.max(0, phase.cursor - 1), histIdx: -1 });
|
|
92
198
|
return;
|
|
93
199
|
}
|
|
94
200
|
if (input && !key.ctrl && !key.meta) {
|
|
95
|
-
|
|
201
|
+
const val = insertAt(phase.value, phase.cursor, input);
|
|
202
|
+
setPhase({ ...phase, value: val, cursor: phase.cursor + 1, histIdx: -1 });
|
|
96
203
|
}
|
|
97
204
|
return;
|
|
98
205
|
}
|
|
99
206
|
|
|
207
|
+
// ── confirm ───────────────────────────────────────────────────────
|
|
100
208
|
if (phase.type === "confirm") {
|
|
101
209
|
if (key.ctrl && input === "c") { exit(); return; }
|
|
102
|
-
|
|
210
|
+
|
|
211
|
+
if (input === "?") {
|
|
103
212
|
const { nl, command } = phase;
|
|
104
|
-
setPhase({ type: "
|
|
213
|
+
setPhase({ type: "thinking", nl, raw: false });
|
|
105
214
|
try {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
setPhase({ type: "input", value: "", histIdx: -1 });
|
|
111
|
-
} catch (e: any) {
|
|
112
|
-
const output = ((e.stdout ?? "") + (e.stderr ?? "")).trim() || e.message;
|
|
113
|
-
pushScroll({ nl, cmd: command, output, error: true });
|
|
114
|
-
appendHistory({ nl, cmd: command, output, ts: Date.now(), error: true });
|
|
115
|
-
setPhase({ type: "input", value: "", histIdx: -1 });
|
|
215
|
+
const explanation = await explainCommand(command);
|
|
216
|
+
setPhase({ type: "explain", nl, command, explanation });
|
|
217
|
+
} catch {
|
|
218
|
+
setPhase({ type: "confirm", nl, command, raw: false, danger: phase.danger });
|
|
116
219
|
}
|
|
117
220
|
return;
|
|
118
221
|
}
|
|
119
|
-
if (input === "
|
|
120
|
-
|
|
222
|
+
if (input === "y" || input === "Y" || key.return) {
|
|
223
|
+
await runPhase(phase.nl, phase.command, false);
|
|
121
224
|
return;
|
|
122
225
|
}
|
|
226
|
+
if (input === "n" || input === "N" || key.escape) { inputPhase(); return; }
|
|
123
227
|
if (input === "e" || input === "E") {
|
|
124
|
-
setPhase({ type: "input", value: phase.command, histIdx: -1 });
|
|
228
|
+
setPhase({ type: "input", value: phase.command, cursor: phase.command.length, histIdx: -1, raw: false });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── explain → back to confirm ─────────────────────────────────────
|
|
235
|
+
if (phase.type === "explain") {
|
|
236
|
+
if (key.ctrl && input === "c") { exit(); return; }
|
|
237
|
+
setPhase({ type: "confirm", nl: phase.nl, command: phase.command, raw: false, danger: isIrreversible(phase.command) });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── autofix ───────────────────────────────────────────────────────
|
|
242
|
+
if (phase.type === "autofix") {
|
|
243
|
+
if (key.ctrl && input === "c") { exit(); return; }
|
|
244
|
+
if (input === "y" || input === "Y" || key.return) {
|
|
245
|
+
const { nl, command, errorOutput } = phase;
|
|
246
|
+
setPhase({ type: "thinking", nl, raw: false });
|
|
247
|
+
try {
|
|
248
|
+
const fixed = await fixCommand(nl, command, errorOutput, config.permissions, sessionCmds);
|
|
249
|
+
const danger = isIrreversible(fixed);
|
|
250
|
+
setPhase({ type: "confirm", nl, command: fixed, raw: false, danger });
|
|
251
|
+
} catch (e: any) {
|
|
252
|
+
setPhase({ type: "error", message: e.message });
|
|
253
|
+
}
|
|
125
254
|
return;
|
|
126
255
|
}
|
|
256
|
+
inputPhase();
|
|
127
257
|
return;
|
|
128
258
|
}
|
|
129
259
|
|
|
260
|
+
// ── error ─────────────────────────────────────────────────────────
|
|
130
261
|
if (phase.type === "error") {
|
|
131
262
|
if (key.ctrl && input === "c") { exit(); return; }
|
|
132
|
-
|
|
263
|
+
inputPhase();
|
|
133
264
|
return;
|
|
134
265
|
}
|
|
135
266
|
},
|
|
136
|
-
[phase, allNl, config, exit]
|
|
267
|
+
[phase, allNl, config, sessionCmds, exit]
|
|
137
268
|
)
|
|
138
269
|
);
|
|
139
270
|
|
|
271
|
+
// ── expand toggle ──────────────────────────────────────────────────────────
|
|
272
|
+
const toggleExpand = (i: number) =>
|
|
273
|
+
setScroll((s) => s.map((e, idx) => idx === i ? { ...e, expanded: !e.expanded } : e));
|
|
274
|
+
|
|
275
|
+
// ── onboarding ─────────────────────────────────────────────────────────────
|
|
140
276
|
if (!config.onboarded) {
|
|
141
277
|
return <Onboarding onDone={finishOnboarding} />;
|
|
142
278
|
}
|
|
143
279
|
|
|
280
|
+
const isRaw = phase.type === "input" && phase.raw;
|
|
281
|
+
|
|
282
|
+
// ── render ─────────────────────────────────────────────────────────────────
|
|
144
283
|
return (
|
|
145
284
|
<Box flexDirection="column">
|
|
285
|
+
|
|
286
|
+
{/* scrollback */}
|
|
146
287
|
{scroll.map((entry, i) => (
|
|
147
|
-
<Box key={i} flexDirection="column" marginBottom={1}>
|
|
148
|
-
<Box gap={
|
|
288
|
+
<Box key={i} flexDirection="column" marginBottom={1} paddingLeft={2}>
|
|
289
|
+
<Box gap={2}>
|
|
149
290
|
<Text dimColor>›</Text>
|
|
150
291
|
<Text dimColor>{entry.nl}</Text>
|
|
151
292
|
</Box>
|
|
152
|
-
|
|
153
|
-
<
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
293
|
+
{entry.nl !== entry.cmd && (
|
|
294
|
+
<Box gap={2} paddingLeft={2}>
|
|
295
|
+
<Text dimColor>$</Text>
|
|
296
|
+
<Text dimColor>{entry.cmd}</Text>
|
|
297
|
+
</Box>
|
|
298
|
+
)}
|
|
299
|
+
{entry.lines.length > 0 && (
|
|
300
|
+
<Box flexDirection="column" paddingLeft={4}>
|
|
301
|
+
{entry.lines.map((line, j) => (
|
|
302
|
+
<Text key={j} color={entry.error ? "red" : undefined}>{line}</Text>
|
|
303
|
+
))}
|
|
304
|
+
{entry.truncated && !entry.expanded && (
|
|
305
|
+
<Text dimColor>… (space to expand)</Text>
|
|
306
|
+
)}
|
|
307
|
+
</Box>
|
|
158
308
|
)}
|
|
159
309
|
</Box>
|
|
160
310
|
))}
|
|
161
311
|
|
|
162
|
-
{
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
<
|
|
166
|
-
<Text inverse> </Text>
|
|
167
|
-
</Box>
|
|
168
|
-
)}
|
|
169
|
-
|
|
170
|
-
{phase.type === "thinking" && (
|
|
171
|
-
<Box flexDirection="column">
|
|
172
|
-
<Box gap={1}>
|
|
312
|
+
{/* confirm */}
|
|
313
|
+
{phase.type === "confirm" && (
|
|
314
|
+
<Box flexDirection="column" marginBottom={1} paddingLeft={2}>
|
|
315
|
+
<Box gap={2}>
|
|
173
316
|
<Text dimColor>›</Text>
|
|
174
317
|
<Text dimColor>{phase.nl}</Text>
|
|
175
318
|
</Box>
|
|
176
|
-
<
|
|
319
|
+
<Box gap={2} paddingLeft={2}>
|
|
320
|
+
<Text dimColor>$</Text>
|
|
321
|
+
<Text>{phase.command}</Text>
|
|
322
|
+
{phase.danger && <Text color="red"> ⚠ irreversible</Text>}
|
|
323
|
+
</Box>
|
|
324
|
+
<Box paddingLeft={4}><Text dimColor>enter n e ?</Text></Box>
|
|
177
325
|
</Box>
|
|
178
326
|
)}
|
|
179
327
|
|
|
180
|
-
{
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
<Text dimColor>{phase.nl}</Text>
|
|
185
|
-
</Box>
|
|
186
|
-
<Box gap={1}>
|
|
328
|
+
{/* explain */}
|
|
329
|
+
{phase.type === "explain" && (
|
|
330
|
+
<Box flexDirection="column" marginBottom={1} paddingLeft={2}>
|
|
331
|
+
<Box gap={2} paddingLeft={2}>
|
|
187
332
|
<Text dimColor>$</Text>
|
|
188
333
|
<Text>{phase.command}</Text>
|
|
189
334
|
</Box>
|
|
190
|
-
<
|
|
335
|
+
<Box paddingLeft={4}>
|
|
336
|
+
<Text dimColor>{phase.explanation}</Text>
|
|
337
|
+
</Box>
|
|
338
|
+
<Box paddingLeft={4}><Text dimColor>any key to continue</Text></Box>
|
|
339
|
+
</Box>
|
|
340
|
+
)}
|
|
341
|
+
|
|
342
|
+
{/* autofix */}
|
|
343
|
+
{phase.type === "autofix" && (
|
|
344
|
+
<Box flexDirection="column" marginBottom={1} paddingLeft={2}>
|
|
345
|
+
<Text dimColor> command failed — retry with fix? [enter / n]</Text>
|
|
191
346
|
</Box>
|
|
192
347
|
)}
|
|
193
348
|
|
|
349
|
+
{/* spinners */}
|
|
350
|
+
{phase.type === "thinking" && <Spinner label="translating" />}
|
|
351
|
+
|
|
352
|
+
{/* running — live stream */}
|
|
194
353
|
{phase.type === "running" && (
|
|
195
|
-
<Box flexDirection="column">
|
|
196
|
-
<Box gap={
|
|
354
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
355
|
+
<Box gap={2}>
|
|
197
356
|
<Text dimColor>$</Text>
|
|
198
|
-
<Text>{phase.command}</Text>
|
|
357
|
+
<Text dimColor>{phase.command}</Text>
|
|
199
358
|
</Box>
|
|
200
|
-
|
|
359
|
+
{streamLines.length > 0 && (
|
|
360
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
361
|
+
{streamLines.slice(-MAX_LINES).map((line, i) => (
|
|
362
|
+
<Text key={i}>{line}</Text>
|
|
363
|
+
))}
|
|
364
|
+
</Box>
|
|
365
|
+
)}
|
|
366
|
+
<Spinner label="ctrl+c to cancel" />
|
|
201
367
|
</Box>
|
|
202
368
|
)}
|
|
203
369
|
|
|
370
|
+
{/* error */}
|
|
204
371
|
{phase.type === "error" && (
|
|
205
|
-
<Box
|
|
206
|
-
<Text color="red">
|
|
207
|
-
|
|
372
|
+
<Box paddingLeft={2}>
|
|
373
|
+
<Text color="red">{phase.message}</Text>
|
|
374
|
+
</Box>
|
|
375
|
+
)}
|
|
376
|
+
|
|
377
|
+
{/* input */}
|
|
378
|
+
{phase.type === "input" && (
|
|
379
|
+
<Box gap={2} paddingLeft={2}>
|
|
380
|
+
<Text dimColor>{isRaw ? "$" : "›"}</Text>
|
|
381
|
+
<Box>
|
|
382
|
+
<Text>{phase.value.slice(0, phase.cursor)}</Text>
|
|
383
|
+
<Text inverse>{phase.value[phase.cursor] ?? " "}</Text>
|
|
384
|
+
<Text>{phase.value.slice(phase.cursor + 1)}</Text>
|
|
385
|
+
</Box>
|
|
208
386
|
</Box>
|
|
209
387
|
)}
|
|
388
|
+
|
|
389
|
+
<StatusBar permissions={config.permissions} />
|
|
210
390
|
</Box>
|
|
211
391
|
);
|
|
212
392
|
}
|
package/src/Onboarding.tsx
CHANGED
|
@@ -6,89 +6,77 @@ interface Props {
|
|
|
6
6
|
onDone: (perms: Permissions) => void;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
type Step =
|
|
10
|
-
| { type: "welcome" }
|
|
11
|
-
| { type: "permissions"; cursor: number }
|
|
12
|
-
| { type: "done" };
|
|
9
|
+
type Step = "welcome" | "permissions";
|
|
13
10
|
|
|
14
|
-
const PERM_KEYS: Array<{ key: keyof Permissions; label: string;
|
|
15
|
-
{ key: "destructive",
|
|
16
|
-
{ key: "network",
|
|
17
|
-
{ key: "sudo",
|
|
18
|
-
{ key: "install",
|
|
19
|
-
{ key: "write_outside_cwd", label: "write outside
|
|
11
|
+
const PERM_KEYS: Array<{ key: keyof Permissions; label: string; hint: string }> = [
|
|
12
|
+
{ key: "destructive", label: "destructive", hint: "rm, delete, drop…" },
|
|
13
|
+
{ key: "network", label: "network", hint: "curl, wget, ssh…" },
|
|
14
|
+
{ key: "sudo", label: "sudo", hint: "root-level commands" },
|
|
15
|
+
{ key: "install", label: "install", hint: "brew, npm -g, pip…" },
|
|
16
|
+
{ key: "write_outside_cwd", label: "write outside", hint: "files outside current dir" },
|
|
20
17
|
];
|
|
21
18
|
|
|
22
19
|
export default function Onboarding({ onDone }: Props) {
|
|
23
|
-
const [step, setStep] = useState<Step>(
|
|
20
|
+
const [step, setStep] = useState<Step>("welcome");
|
|
21
|
+
const [cursor, setCursor] = useState(0);
|
|
24
22
|
const [perms, setPerms] = useState<Permissions>({ ...DEFAULT_PERMISSIONS });
|
|
25
23
|
|
|
26
24
|
useInput((input, key) => {
|
|
27
25
|
if (key.ctrl && input === "c") process.exit(0);
|
|
28
26
|
|
|
29
|
-
if (step
|
|
30
|
-
setStep(
|
|
27
|
+
if (step === "welcome") {
|
|
28
|
+
setStep("permissions");
|
|
31
29
|
return;
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
if (step
|
|
35
|
-
|
|
36
|
-
if (key.
|
|
37
|
-
setStep({ type: "permissions", cursor: Math.max(0, cursor - 1) });
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
if (key.downArrow) {
|
|
41
|
-
setStep({ type: "permissions", cursor: Math.min(PERM_KEYS.length - 1, cursor + 1) });
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
32
|
+
if (step === "permissions") {
|
|
33
|
+
if (key.upArrow) { setCursor((c) => Math.max(0, c - 1)); return; }
|
|
34
|
+
if (key.downArrow) { setCursor((c) => Math.min(PERM_KEYS.length - 1, c + 1)); return; }
|
|
44
35
|
if (input === " ") {
|
|
45
36
|
const k = PERM_KEYS[cursor].key;
|
|
46
37
|
setPerms((p) => ({ ...p, [k]: !p[k] }));
|
|
47
38
|
return;
|
|
48
39
|
}
|
|
49
|
-
if (key.return) {
|
|
50
|
-
onDone(perms);
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
return;
|
|
40
|
+
if (key.return) { onDone(perms); return; }
|
|
54
41
|
}
|
|
55
42
|
});
|
|
56
43
|
|
|
57
|
-
if (step
|
|
44
|
+
if (step === "welcome") {
|
|
58
45
|
return (
|
|
59
|
-
<Box flexDirection="column"
|
|
60
|
-
<Text
|
|
61
|
-
<Text>
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (step.type === "permissions") {
|
|
70
|
-
return (
|
|
71
|
-
<Box flexDirection="column" paddingTop={1} gap={1}>
|
|
72
|
-
<Text bold>what can the AI do?</Text>
|
|
73
|
-
<Text dimColor>space to toggle · enter to confirm</Text>
|
|
74
|
-
<Box flexDirection="column" marginTop={1}>
|
|
75
|
-
{PERM_KEYS.map((p, i) => {
|
|
76
|
-
const active = step.cursor === i;
|
|
77
|
-
const on = perms[p.key];
|
|
78
|
-
return (
|
|
79
|
-
<Box key={p.key} gap={2}>
|
|
80
|
-
<Text>{active ? "›" : " "}</Text>
|
|
81
|
-
<Text color={on ? undefined : "red"}>{on ? "✓" : "✗"}</Text>
|
|
82
|
-
<Text bold={active}>{p.label}</Text>
|
|
83
|
-
<Text dimColor>{p.description}</Text>
|
|
84
|
-
</Box>
|
|
85
|
-
);
|
|
86
|
-
})}
|
|
46
|
+
<Box flexDirection="column" paddingLeft={2} gap={1} paddingTop={1}>
|
|
47
|
+
<Text>terminal</Text>
|
|
48
|
+
<Text dimColor>speak plain english, run commands</Text>
|
|
49
|
+
<Box flexDirection="column" marginTop={1} gap={0}>
|
|
50
|
+
<Text dimColor>› type what you want</Text>
|
|
51
|
+
<Text dimColor>$ see the command before it runs</Text>
|
|
52
|
+
<Text dimColor>↑↓ browse history</Text>
|
|
53
|
+
<Text dimColor>? explain a command before running</Text>
|
|
54
|
+
<Text dimColor>ctrl+r toggle raw shell mode</Text>
|
|
87
55
|
</Box>
|
|
88
|
-
<Text dimColor>
|
|
56
|
+
<Box marginTop={1}><Text dimColor>any key to set permissions →</Text></Box>
|
|
89
57
|
</Box>
|
|
90
58
|
);
|
|
91
59
|
}
|
|
92
60
|
|
|
93
|
-
return
|
|
61
|
+
return (
|
|
62
|
+
<Box flexDirection="column" paddingLeft={2} gap={1} paddingTop={1}>
|
|
63
|
+
<Text dimColor>what can the AI run?</Text>
|
|
64
|
+
<Box flexDirection="column" marginTop={1}>
|
|
65
|
+
{PERM_KEYS.map((p, i) => {
|
|
66
|
+
const active = cursor === i;
|
|
67
|
+
const on = perms[p.key];
|
|
68
|
+
return (
|
|
69
|
+
<Box key={p.key} gap={2}>
|
|
70
|
+
<Text dimColor>{active ? "›" : " "}</Text>
|
|
71
|
+
<Text color={on ? undefined : "red"}>{on ? "✓" : "✗"}</Text>
|
|
72
|
+
<Text bold={active}>{p.label}</Text>
|
|
73
|
+
<Text dimColor>{p.hint}</Text>
|
|
74
|
+
</Box>
|
|
75
|
+
);
|
|
76
|
+
})}
|
|
77
|
+
</Box>
|
|
78
|
+
<Box marginTop={1}><Text dimColor>space toggle · enter confirm</Text></Box>
|
|
79
|
+
<Text dimColor>edit later: ~/.terminal/config.json</Text>
|
|
80
|
+
</Box>
|
|
81
|
+
);
|
|
94
82
|
}
|
package/src/Spinner.tsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
|
|
4
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function Spinner({ label }: Props) {
|
|
11
|
+
const [frame, setFrame] = useState(0);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const t = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
|
|
15
|
+
return () => clearInterval(t);
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Text dimColor>
|
|
20
|
+
{" "}
|
|
21
|
+
{FRAMES[frame]} {label}
|
|
22
|
+
</Text>
|
|
23
|
+
);
|
|
24
|
+
}
|