@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/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, type ChildProcess } from "child_process";
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";
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; raw: boolean }
21
- | { type: "confirm"; nl: string; command: string; raw: boolean; danger: boolean }
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
- 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"] });
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
- const handleData = (data: Buffer) => {
52
- const text = data.toString();
53
- text.split("\n").forEach((line) => { if (line) onLine(line); });
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
- proc.stdout?.on("data", handleData);
57
- proc.stderr?.on("data", handleData);
58
- proc.on("close", (code) => onDone(code ?? 0));
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
- // ── component ─────────────────────────────────────────────────────────────────
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((h) => h.nl).filter(Boolean));
70
- const [sessionCmds, setSessionCmds] = useState<string[]>([]);
71
- const [sessionNl, setSessionNl] = useState<string[]>([]);
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 [phase, setPhase] = useState<Phase>({
77
- type: "input", value: "", cursor: 0, histIdx: -1, raw: false,
78
- });
111
+ const tab = tabs[activeTab];
112
+ const allNl = [...nlHistory, ...tab.sessionNl];
79
113
 
80
- const allNl = [...nlHistory, ...sessionNl];
114
+ // ── tab helpers ─────────────────────────────────────────────────────────────
81
115
 
82
- const finishOnboarding = (perms: Permissions) => {
83
- const next = { onboarded: true, confirm: false, permissions: perms };
84
- setConfig(next);
85
- saveConfig(next);
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
- setPhase({ type: "input", value: "", cursor: 0, histIdx: -1, raw: false, ...overrides });
90
- setStreamLines([]);
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
- setScroll((s) => [...s, { ...entry, expanded: false }]);
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
- pushScroll({ nl, cmd, lines: truncated ? lines.slice(0, MAX_LINES) : lines, truncated, error });
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
- setStreamLines([]);
155
+ updateTab(t => ({ ...t, streamLines: [] }));
107
156
  const abort = new AbortController();
108
157
  abortRef.current = abort;
109
158
  const lines: string[] = [];
110
-
111
- await new Promise<void>((resolve) => {
112
- runCommand(
113
- command,
114
- (line) => { lines.push(line); setStreamLines([...lines]); },
115
- (code) => {
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
- useInput(
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
- // ── confirm ───────────────────────────────────────────────────────
213
- if (phase.type === "confirm") {
214
- if (key.ctrl && input === "c") { exit(); return; }
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
- if (input === "?") {
217
- const { nl, command } = phase;
218
- setPhase({ type: "thinking", nl, raw: false });
219
- try {
220
- const explanation = await explainCommand(command);
221
- setPhase({ type: "explain", nl, command, explanation });
222
- } catch {
223
- setPhase({ type: "confirm", nl, command, raw: false, danger: phase.danger });
224
- }
225
- return;
226
- }
227
- if (input === "y" || input === "Y" || key.return) {
228
- await runPhase(phase.nl, phase.command, false);
229
- return;
230
- }
231
- if (input === "n" || input === "N" || key.escape) { inputPhase(); return; }
232
- if (input === "e" || input === "E") {
233
- setPhase({ type: "input", value: phase.command, cursor: phase.command.length, histIdx: -1, raw: false });
234
- return;
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
- // ── explain back to confirm ─────────────────────────────────────
240
- if (phase.type === "explain") {
241
- if (key.ctrl && input === "c") { exit(); return; }
242
- setPhase({ type: "confirm", nl: phase.nl, command: phase.command, raw: false, danger: isIrreversible(phase.command) });
243
- return;
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
- // ── autofix ───────────────────────────────────────────────────────
247
- if (phase.type === "autofix") {
248
- if (key.ctrl && input === "c") { exit(); return; }
249
- if (input === "y" || input === "Y" || key.return) {
250
- const { nl, command, errorOutput } = phase;
251
- setPhase({ type: "thinking", nl, raw: false });
252
- try {
253
- const fixed = await fixCommand(nl, command, errorOutput, config.permissions, sessionCmds);
254
- const danger = isIrreversible(fixed);
255
- if (!config.confirm && !danger) {
256
- await runPhase(nl, fixed, false);
257
- return;
258
- }
259
- setPhase({ type: "confirm", nl, command: fixed, raw: false, danger });
260
- } catch (e: any) {
261
- setPhase({ type: "error", message: e.message });
262
- }
263
- return;
264
- }
265
- inputPhase();
266
- return;
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
- // ── error ─────────────────────────────────────────────────────────
270
- if (phase.type === "error") {
271
- if (key.ctrl && input === "c") { exit(); return; }
272
- inputPhase();
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
- [phase, allNl, config, sessionCmds, exit]
277
- )
278
- );
279
-
280
- // ── expand toggle ──────────────────────────────────────────────────────────
281
- const toggleExpand = (i: number) =>
282
- setScroll((s) => s.map((e, idx) => idx === i ? { ...e, expanded: !e.expanded } : e));
283
-
284
- // ── onboarding ─────────────────────────────────────────────────────────────
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={finishOnboarding} />;
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>… (space to expand)</Text>
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"> ⚠ irreversible</Text>}
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
- <Text dimColor>{phase.explanation}</Text>
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 flexDirection="column" marginBottom={1} paddingLeft={2}>
354
- <Text dimColor> command failed — retry with fix? [enter / n]</Text>
477
+ <Box paddingLeft={2}>
478
+ <Text dimColor>failed — retry with fix? [enter / n]</Text>
355
479
  </Box>
356
480
  )}
357
481
 
358
- {/* spinners */}
359
- {phase.type === "thinking" && <Spinner label="translating" />}
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 — live stream */}
492
+ {/* running */}
362
493
  {phase.type === "running" && (
363
494
  <Box flexDirection="column" paddingLeft={2}>
364
- <Box gap={2}>
365
- <Text dimColor>$</Text>
366
- <Text dimColor>{phase.command}</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
  }