@hasna/terminal 0.1.4 → 0.2.0

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