@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/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 { exec } from "child_process";
4
- import { promisify } from "util";
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
- const execAsync = promisify(exec);
16
+ // ── types ─────────────────────────────────────────────────────────────────────
17
17
 
18
18
  type Phase =
19
- | { type: "input"; value: string; histIdx: number }
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: "error"; message: string };
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
- output: string;
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
- loadHistory().map((h) => h.nl).filter(Boolean)
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 [phase, setPhase] = useState<Phase>({ type: "input", value: "", histIdx: -1 });
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 pushScroll = (entry: ScrollEntry) => setScroll((s) => [...s, entry]);
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 nextIdx = Math.min(phase.histIdx + 1, allNl.length - 1);
60
- const val = allNl[allNl.length - 1 - nextIdx] ?? "";
61
- setPhase({ type: "input", value: val, histIdx: nextIdx });
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 nextIdx = Math.max(phase.histIdx - 1, -1);
66
- const val = nextIdx === -1 ? "" : allNl[allNl.length - 1 - nextIdx] ?? "";
67
- setPhase({ type: "input", value: val, histIdx: nextIdx });
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
- setPhase({ type: "thinking", nl });
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, output: `blocked: ${blocked}`, error: true });
81
- setPhase({ type: "input", value: "", histIdx: -1 });
183
+ pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
184
+ inputPhase();
82
185
  return;
83
186
  }
84
- setPhase({ type: "confirm", nl, command });
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
- setPhase({ ...phase, value: phase.value.slice(0, -1), histIdx: -1 });
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
- setPhase({ ...phase, value: phase.value + input, histIdx: -1 });
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
- if (input === "y" || input === "Y" || key.return) {
210
+
211
+ if (input === "?") {
103
212
  const { nl, command } = phase;
104
- setPhase({ type: "running", nl, command });
213
+ setPhase({ type: "thinking", nl, raw: false });
105
214
  try {
106
- const { stdout, stderr } = await execAsync(command, { shell: "/bin/zsh" });
107
- const output = (stdout + stderr).trim();
108
- pushScroll({ nl, cmd: command, output });
109
- appendHistory({ nl, cmd: command, output, ts: Date.now() });
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 === "n" || input === "N" || key.escape) {
120
- setPhase({ type: "input", value: "", histIdx: -1 });
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
- setPhase({ type: "input", value: "", histIdx: -1 });
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={1}>
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
- <Box gap={1}>
153
- <Text dimColor>$</Text>
154
- <Text>{entry.cmd}</Text>
155
- </Box>
156
- {entry.output && (
157
- <Text color={entry.error ? "red" : undefined}>{entry.output}</Text>
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
- {phase.type === "input" && (
163
- <Box gap={1}>
164
- <Text dimColor>›</Text>
165
- <Text>{phase.value}</Text>
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
- <Text dimColor> translating…</Text>
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
- {phase.type === "confirm" && (
181
- <Box flexDirection="column">
182
- <Box gap={1}>
183
- <Text dimColor>›</Text>
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
- <Text dimColor> [enter] run [n] cancel [e] edit</Text>
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={1}>
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
- <Text dimColor> running…</Text>
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 flexDirection="column">
206
- <Text color="red"> {phase.message}</Text>
207
- <Text dimColor> press any key</Text>
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
  }
@@ -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; description: string }> = [
15
- { key: "destructive", label: "destructive", description: "rm, delete, drop table…" },
16
- { key: "network", label: "network", description: "curl, wget, ssh, ping…" },
17
- { key: "sudo", label: "sudo", description: "commands requiring root" },
18
- { key: "install", label: "install", description: "brew, npm -g, pip…" },
19
- { key: "write_outside_cwd", label: "write outside cwd", description: "files outside current directory" },
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>({ type: "welcome" });
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.type === "welcome") {
30
- setStep({ type: "permissions", cursor: 0 });
27
+ if (step === "welcome") {
28
+ setStep("permissions");
31
29
  return;
32
30
  }
33
31
 
34
- if (step.type === "permissions") {
35
- const { cursor } = step;
36
- if (key.upArrow) {
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.type === "welcome") {
44
+ if (step === "welcome") {
58
45
  return (
59
- <Box flexDirection="column" paddingTop={1} gap={1}>
60
- <Text bold>terminal</Text>
61
- <Text>Type anything in plain English.</Text>
62
- <Text>The AI translates it to a shell command and runs it.</Text>
63
- <Text>Use / to browse history. Enter to run, n to cancel, e to edit.</Text>
64
- <Text dimColor>press any key to set up permissions →</Text>
65
- </Box>
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>you can change this later in ~/.terminal/config.json</Text>
56
+ <Box marginTop={1}><Text dimColor>any key to set permissions →</Text></Box>
89
57
  </Box>
90
58
  );
91
59
  }
92
60
 
93
- return null;
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
  }
@@ -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
+ }