@hasna/terminal 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/App.js CHANGED
@@ -3,76 +3,124 @@ import { useState, useCallback, useRef } from "react";
3
3
  import { Box, Text, useInput, useApp } from "ink";
4
4
  import { spawn } from "child_process";
5
5
  import { translateToCommand, explainCommand, fixCommand, checkPermissions, isIrreversible } from "./ai.js";
6
- import { loadHistory, appendHistory, loadConfig, saveConfig, } from "./history.js";
6
+ import { loadHistory, appendHistory, loadConfig, saveConfig } from "./history.js";
7
7
  import { loadCache } from "./cache.js";
8
8
  import Onboarding from "./Onboarding.js";
9
9
  import StatusBar from "./StatusBar.js";
10
10
  import Spinner from "./Spinner.js";
11
- // warm cache on startup
11
+ import Browse from "./Browse.js";
12
+ import FuzzyPicker from "./FuzzyPicker.js";
12
13
  loadCache();
13
14
  const MAX_LINES = 20;
14
15
  // ── helpers ───────────────────────────────────────────────────────────────────
15
16
  function insertAt(s, pos, ch) { return s.slice(0, pos) + ch + s.slice(pos); }
16
17
  function deleteAt(s, pos) { return pos <= 0 ? s : s.slice(0, pos - 1) + s.slice(pos); }
17
- function runCommand(command, onLine, onDone, signal) {
18
- const proc = spawn("/bin/zsh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"] });
19
- const handleData = (data) => {
20
- const text = data.toString();
21
- text.split("\n").forEach((line) => { if (line)
22
- onLine(line); });
18
+ /** Detect if output lines look like file paths */
19
+ function extractFilePaths(lines) {
20
+ return lines.filter(l => /^\.?\//.test(l.trim()) || /\.(ts|tsx|js|json|md|py|sh|go|rs|txt|yaml|yml|env)$/.test(l.trim()));
21
+ }
22
+ /** Ghost text: find the best NL match that starts with the current input */
23
+ function ghostText(input, history) {
24
+ if (!input.trim())
25
+ return "";
26
+ const lower = input.toLowerCase();
27
+ const match = [...history].reverse().find(h => h.toLowerCase().startsWith(lower) && h.length > input.length);
28
+ return match ? match.slice(input.length) : "";
29
+ }
30
+ /** Detect cd and change process cwd */
31
+ function maybeCd(command) {
32
+ const m = command.match(/^\s*cd\s+(.+)\s*$/);
33
+ if (!m)
34
+ return null;
35
+ let target = m[1].trim().replace(/^['"]|['"]$/g, "");
36
+ if (target.startsWith("~"))
37
+ target = target.replace("~", process.env.HOME ?? "");
38
+ return target;
39
+ }
40
+ function newTab(id, cwd) {
41
+ return {
42
+ id, cwd,
43
+ scroll: [], sessionCmds: [], sessionNl: [],
44
+ phase: { type: "input", value: "", cursor: 0, histIdx: -1, raw: false },
45
+ streamLines: [],
23
46
  };
24
- proc.stdout?.on("data", handleData);
25
- proc.stderr?.on("data", handleData);
26
- proc.on("close", (code) => onDone(code ?? 0));
47
+ }
48
+ function runCommand(command, cwd, onLine, onDone, signal) {
49
+ const proc = spawn("/bin/zsh", ["-c", command], { cwd, stdio: ["ignore", "pipe", "pipe"] });
50
+ const handle = (d) => d.toString().split("\n").forEach(l => { if (l)
51
+ onLine(l); });
52
+ proc.stdout?.on("data", handle);
53
+ proc.stderr?.on("data", handle);
54
+ proc.on("close", code => onDone(code ?? 0));
27
55
  signal.addEventListener("abort", () => { try {
28
56
  proc.kill("SIGTERM");
29
57
  }
30
58
  catch { } });
31
- return proc;
32
59
  }
33
- // ── component ─────────────────────────────────────────────────────────────────
60
+ // ── App ───────────────────────────────────────────────────────────────────────
34
61
  export default function App() {
35
62
  const { exit } = useApp();
36
63
  const [config, setConfig] = useState(() => loadConfig());
37
- const [nlHistory] = useState(() => loadHistory().map((h) => h.nl).filter(Boolean));
38
- const [sessionCmds, setSessionCmds] = useState([]);
39
- const [sessionNl, setSessionNl] = useState([]);
40
- const [scroll, setScroll] = useState([]);
41
- const [streamLines, setStreamLines] = useState([]);
64
+ const [nlHistory] = useState(() => loadHistory().map(h => h.nl).filter(Boolean));
65
+ const [tabs, setTabs] = useState([newTab(1, process.cwd())]);
66
+ const [activeTab, setActiveTab] = useState(0);
42
67
  const abortRef = useRef(null);
43
- const [phase, setPhase] = useState({
44
- type: "input", value: "", cursor: 0, histIdx: -1, raw: false,
45
- });
46
- const allNl = [...nlHistory, ...sessionNl];
47
- const finishOnboarding = (perms) => {
48
- const next = { onboarded: true, confirm: false, permissions: perms };
49
- setConfig(next);
50
- saveConfig(next);
51
- };
68
+ let nextTabId = useRef(2);
69
+ const tab = tabs[activeTab];
70
+ const allNl = [...nlHistory, ...tab.sessionNl];
71
+ // ── tab helpers ─────────────────────────────────────────────────────────────
72
+ const updateTab = (updater) => setTabs(ts => ts.map((t, i) => i === activeTab ? updater(t) : t));
73
+ const setPhase = (phase) => updateTab(t => ({ ...t, phase }));
74
+ const setStreamLines = (lines) => updateTab(t => ({ ...t, streamLines: lines }));
52
75
  const inputPhase = (overrides = {}) => {
53
- setPhase({ type: "input", value: "", cursor: 0, histIdx: -1, raw: false, ...overrides });
54
- setStreamLines([]);
76
+ updateTab(t => ({
77
+ ...t,
78
+ streamLines: [],
79
+ phase: { type: "input", value: "", cursor: 0, histIdx: -1, raw: false, ...overrides },
80
+ }));
55
81
  };
56
- const pushScroll = (entry) => setScroll((s) => [...s, { ...entry, expanded: false }]);
82
+ const pushScroll = (entry) => updateTab(t => ({ ...t, scroll: [...t.scroll, { ...entry, expanded: false }] }));
57
83
  const commitStream = (nl, cmd, lines, error) => {
58
84
  const truncated = lines.length > MAX_LINES;
59
- pushScroll({ nl, cmd, lines: truncated ? lines.slice(0, MAX_LINES) : lines, truncated, error });
85
+ const filePaths = !error ? extractFilePaths(lines) : [];
86
+ updateTab(t => ({
87
+ ...t,
88
+ streamLines: [],
89
+ sessionCmds: [...t.sessionCmds.slice(-9), cmd],
90
+ scroll: [...t.scroll, {
91
+ nl, cmd,
92
+ lines: truncated ? lines.slice(0, MAX_LINES) : lines,
93
+ truncated, expanded: false,
94
+ error: error || undefined,
95
+ filePaths: filePaths.length ? filePaths : undefined,
96
+ }],
97
+ }));
60
98
  appendHistory({ nl, cmd, output: lines.join("\n"), ts: Date.now(), error });
61
- setSessionCmds((c) => [...c.slice(-9), cmd]);
62
- setStreamLines([]);
63
99
  };
100
+ // ── run command ─────────────────────────────────────────────────────────────
64
101
  const runPhase = async (nl, command, raw) => {
65
102
  setPhase({ type: "running", nl, command });
66
- setStreamLines([]);
103
+ updateTab(t => ({ ...t, streamLines: [] }));
67
104
  const abort = new AbortController();
68
105
  abortRef.current = abort;
69
106
  const lines = [];
70
- await new Promise((resolve) => {
71
- runCommand(command, (line) => { lines.push(line); setStreamLines([...lines]); }, (code) => {
107
+ const cwd = tabs[activeTab].cwd;
108
+ await new Promise(resolve => {
109
+ runCommand(command, cwd, line => { lines.push(line); setStreamLines([...lines]); }, code => {
110
+ // handle cd — update tab cwd
111
+ const cdTarget = maybeCd(command);
112
+ if (cdTarget) {
113
+ try {
114
+ const { resolve: resolvePath } = require("path");
115
+ const newCwd = require("path").resolve(cwd, cdTarget);
116
+ process.chdir(newCwd);
117
+ updateTab(t => ({ ...t, cwd: newCwd }));
118
+ }
119
+ catch { }
120
+ }
72
121
  commitStream(nl, command, lines, code !== 0);
73
122
  abortRef.current = null;
74
123
  if (code !== 0 && !raw) {
75
- // offer auto-fix
76
124
  setPhase({ type: "autofix", nl, command, errorOutput: lines.join("\n") });
77
125
  }
78
126
  else {
@@ -82,8 +130,43 @@ export default function App() {
82
130
  }, abort.signal);
83
131
  });
84
132
  };
133
+ // ── translate + run ─────────────────────────────────────────────────────────
134
+ const translateAndRun = async (nl, raw) => {
135
+ updateTab(t => ({ ...t, sessionNl: [...t.sessionNl, nl] }));
136
+ if (raw) {
137
+ await runPhase(nl, nl, true);
138
+ return;
139
+ }
140
+ const sessionCmds = tabs[activeTab].sessionCmds;
141
+ setPhase({ type: "thinking", nl, partial: "" });
142
+ try {
143
+ const command = await translateToCommand(nl, config.permissions, sessionCmds, partial => setPhase({ type: "thinking", nl, partial }));
144
+ const blocked = checkPermissions(command, config.permissions);
145
+ if (blocked) {
146
+ pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
147
+ inputPhase();
148
+ return;
149
+ }
150
+ const danger = isIrreversible(command);
151
+ if (!config.confirm && !danger) {
152
+ await runPhase(nl, command, false);
153
+ return;
154
+ }
155
+ setPhase({ type: "confirm", nl, command, danger });
156
+ }
157
+ catch (e) {
158
+ setPhase({ type: "error", message: e.message });
159
+ }
160
+ };
161
+ // ── input handler ───────────────────────────────────────────────────────────
85
162
  useInput(useCallback(async (input, key) => {
86
- // ── running: Ctrl+C kills process ─────────────────────────────────
163
+ const phase = tabs[activeTab].phase;
164
+ // ── global: ctrl+c always exits ─────────────────────────────────────────
165
+ if (key.ctrl && input === "c" && phase.type !== "running") {
166
+ exit();
167
+ return;
168
+ }
169
+ // ── running: ctrl+c cancels ──────────────────────────────────────────────
87
170
  if (phase.type === "running") {
88
171
  if (key.ctrl && input === "c") {
89
172
  abortRef.current?.abort();
@@ -91,20 +174,46 @@ export default function App() {
91
174
  }
92
175
  return;
93
176
  }
94
- // ── input ─────────────────────────────────────────────────────────
177
+ // ── browse ───────────────────────────────────────────────────────────────
178
+ if (phase.type === "browse")
179
+ return; // handled by Browse component
180
+ // ── fuzzy ────────────────────────────────────────────────────────────────
181
+ if (phase.type === "fuzzy")
182
+ return; // handled by FuzzyPicker component
183
+ // ── input ────────────────────────────────────────────────────────────────
95
184
  if (phase.type === "input") {
96
- if (key.ctrl && input === "c") {
97
- exit();
185
+ // global shortcuts
186
+ if (key.ctrl && input === "l") {
187
+ updateTab(t => ({ ...t, scroll: [] }));
98
188
  return;
99
189
  }
100
- if (key.ctrl && input === "l") {
101
- setScroll([]);
190
+ if (key.ctrl && input === "b") {
191
+ setPhase({ type: "browse", cwd: tab.cwd });
102
192
  return;
103
193
  }
104
194
  if (key.ctrl && input === "r") {
105
- setPhase({ ...phase, raw: !phase.raw, value: "", cursor: 0 });
195
+ setPhase({ type: "fuzzy" });
196
+ return;
197
+ }
198
+ // tab management
199
+ if (key.ctrl && input === "t") {
200
+ const id = nextTabId.current++;
201
+ setTabs(ts => [...ts, newTab(id, tab.cwd)]);
202
+ setActiveTab(tabs.length); // new tab index
106
203
  return;
107
204
  }
205
+ if (key.ctrl && input === "w") {
206
+ if (tabs.length > 1) {
207
+ setTabs(ts => ts.filter((_, i) => i !== activeTab));
208
+ setActiveTab(i => Math.min(i, tabs.length - 2));
209
+ }
210
+ return;
211
+ }
212
+ if (key.tab) {
213
+ setActiveTab(i => (i + 1) % tabs.length);
214
+ return;
215
+ }
216
+ // history nav
108
217
  if (key.upArrow) {
109
218
  const idx = Math.min(phase.histIdx + 1, allNl.length - 1);
110
219
  const val = allNl[allNl.length - 1 - idx] ?? "";
@@ -117,45 +226,37 @@ export default function App() {
117
226
  setPhase({ ...phase, value: val, cursor: val.length, histIdx: idx });
118
227
  return;
119
228
  }
229
+ // cursor movement
120
230
  if (key.leftArrow) {
121
231
  setPhase({ ...phase, cursor: Math.max(0, phase.cursor - 1) });
122
232
  return;
123
233
  }
124
234
  if (key.rightArrow) {
125
- setPhase({ ...phase, cursor: Math.min(phase.value.length, phase.cursor + 1) });
235
+ // right arrow at end accept ghost text
236
+ const ghost = ghostText(phase.value, allNl);
237
+ if (phase.cursor === phase.value.length && ghost) {
238
+ const full = phase.value + ghost;
239
+ setPhase({ ...phase, value: full, cursor: full.length });
240
+ }
241
+ else {
242
+ setPhase({ ...phase, cursor: Math.min(phase.value.length, phase.cursor + 1) });
243
+ }
126
244
  return;
127
245
  }
246
+ if (key.tab) {
247
+ // tab → accept ghost text
248
+ const ghost = ghostText(phase.value, allNl);
249
+ if (ghost) {
250
+ const full = phase.value + ghost;
251
+ setPhase({ ...phase, value: full, cursor: full.length });
252
+ return;
253
+ }
254
+ }
128
255
  if (key.return) {
129
256
  const nl = phase.value.trim();
130
257
  if (!nl)
131
258
  return;
132
- setSessionNl((h) => [...h, nl]);
133
- if (phase.raw) {
134
- await runPhase(nl, nl, true);
135
- return;
136
- }
137
- setPhase({ type: "thinking", nl, raw: false, partial: "" });
138
- try {
139
- const command = await translateToCommand(nl, config.permissions, sessionCmds, (partial) => {
140
- setPhase({ type: "thinking", nl, raw: false, partial });
141
- });
142
- const blocked = checkPermissions(command, config.permissions);
143
- if (blocked) {
144
- pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
145
- inputPhase();
146
- return;
147
- }
148
- const danger = isIrreversible(command);
149
- // skip confirm unless user opted in OR command is dangerous
150
- if (!config.confirm && !danger) {
151
- await runPhase(nl, command, false);
152
- return;
153
- }
154
- setPhase({ type: "confirm", nl, command, raw: false, danger });
155
- }
156
- catch (e) {
157
- setPhase({ type: "error", message: e.message });
158
- }
259
+ await translateAndRun(nl, phase.raw);
159
260
  return;
160
261
  }
161
262
  if (key.backspace || key.delete) {
@@ -169,21 +270,17 @@ export default function App() {
169
270
  }
170
271
  return;
171
272
  }
172
- // ── confirm ───────────────────────────────────────────────────────
273
+ // ── confirm ──────────────────────────────────────────────────────────────
173
274
  if (phase.type === "confirm") {
174
- if (key.ctrl && input === "c") {
175
- exit();
176
- return;
177
- }
178
275
  if (input === "?") {
179
276
  const { nl, command } = phase;
180
- setPhase({ type: "thinking", nl, raw: false, partial: "" });
277
+ setPhase({ type: "thinking", nl, partial: "" });
181
278
  try {
182
279
  const explanation = await explainCommand(command);
183
280
  setPhase({ type: "explain", nl, command, explanation });
184
281
  }
185
282
  catch {
186
- setPhase({ type: "confirm", nl, command, raw: false, danger: phase.danger });
283
+ setPhase({ type: "confirm", nl, command, danger: phase.danger });
187
284
  }
188
285
  return;
189
286
  }
@@ -201,32 +298,24 @@ export default function App() {
201
298
  }
202
299
  return;
203
300
  }
204
- // ── explain → back to confirm ─────────────────────────────────────
301
+ // ── explain ──────────────────────────────────────────────────────────────
205
302
  if (phase.type === "explain") {
206
- if (key.ctrl && input === "c") {
207
- exit();
208
- return;
209
- }
210
- setPhase({ type: "confirm", nl: phase.nl, command: phase.command, raw: false, danger: isIrreversible(phase.command) });
303
+ setPhase({ type: "confirm", nl: phase.nl, command: phase.command, danger: isIrreversible(phase.command) });
211
304
  return;
212
305
  }
213
- // ── autofix ───────────────────────────────────────────────────────
306
+ // ── autofix ──────────────────────────────────────────────────────────────
214
307
  if (phase.type === "autofix") {
215
- if (key.ctrl && input === "c") {
216
- exit();
217
- return;
218
- }
219
308
  if (input === "y" || input === "Y" || key.return) {
220
309
  const { nl, command, errorOutput } = phase;
221
- setPhase({ type: "thinking", nl, raw: false, partial: "" });
310
+ setPhase({ type: "thinking", nl, partial: "" });
222
311
  try {
223
- const fixed = await fixCommand(nl, command, errorOutput, config.permissions, sessionCmds);
312
+ const fixed = await fixCommand(nl, command, errorOutput, config.permissions, tab.sessionCmds);
224
313
  const danger = isIrreversible(fixed);
225
314
  if (!config.confirm && !danger) {
226
315
  await runPhase(nl, fixed, false);
227
316
  return;
228
317
  }
229
- setPhase({ type: "confirm", nl, command: fixed, raw: false, danger });
318
+ setPhase({ type: "confirm", nl, command: fixed, danger });
230
319
  }
231
320
  catch (e) {
232
321
  setPhase({ type: "error", message: e.message });
@@ -236,23 +325,46 @@ export default function App() {
236
325
  inputPhase();
237
326
  return;
238
327
  }
239
- // ── error ─────────────────────────────────────────────────────────
328
+ // ── error ────────────────────────────────────────────────────────────────
240
329
  if (phase.type === "error") {
241
- if (key.ctrl && input === "c") {
242
- exit();
243
- return;
244
- }
245
330
  inputPhase();
246
331
  return;
247
332
  }
248
- }, [phase, allNl, config, sessionCmds, exit]));
249
- // ── expand toggle ──────────────────────────────────────────────────────────
250
- const toggleExpand = (i) => setScroll((s) => s.map((e, idx) => idx === i ? { ...e, expanded: !e.expanded } : e));
251
- // ── onboarding ─────────────────────────────────────────────────────────────
333
+ }, [tabs, activeTab, allNl, config, exit]));
334
+ // ── onboarding ───────────────────────────────────────────────────────────────
252
335
  if (!config.onboarded) {
253
- return _jsx(Onboarding, { onDone: finishOnboarding });
336
+ return _jsx(Onboarding, { onDone: (perms) => {
337
+ const next = { onboarded: true, confirm: false, permissions: perms };
338
+ setConfig(next);
339
+ saveConfig(next);
340
+ } });
254
341
  }
342
+ const phase = tab.phase;
255
343
  const isRaw = phase.type === "input" && phase.raw;
256
- // ── render ─────────────────────────────────────────────────────────────────
257
- return (_jsxs(Box, { flexDirection: "column", children: [scroll.map((entry, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: entry.nl })] }), entry.nl !== entry.cmd && (_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { dimColor: true, children: entry.cmd })] })), entry.lines.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 4, children: [entry.lines.map((line, j) => (_jsx(Text, { color: entry.error ? "red" : undefined, children: line }, j))), entry.truncated && !entry.expanded && (_jsx(Text, { dimColor: true, children: "\u2026 (space to expand)" }))] }))] }, i))), phase.type === "confirm" && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: phase.nl })] }), _jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: phase.command }), phase.danger && _jsx(Text, { color: "red", children: " \u26A0 irreversible" })] }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: "enter n e ?" }) })] })), phase.type === "explain" && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: phase.command })] }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: phase.explanation }) }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: "any key to continue" }) })] })), phase.type === "autofix" && (_jsx(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: " command failed \u2014 retry with fix? [enter / n]" }) })), phase.type === "thinking" && (phase.partial ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: phase.nl })] }), _jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { dimColor: true, children: phase.partial })] })] })) : (_jsx(Spinner, { label: "translating" }))), phase.type === "running" && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { dimColor: true, children: phase.command })] }), streamLines.length > 0 && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: streamLines.slice(-MAX_LINES).map((line, i) => (_jsx(Text, { children: line }, i))) })), _jsx(Spinner, { label: "ctrl+c to cancel" })] })), phase.type === "error" && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: "red", children: phase.message }) })), phase.type === "input" && (_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: isRaw ? "$" : "›" }), _jsxs(Box, { children: [_jsx(Text, { children: phase.value.slice(0, phase.cursor) }), _jsx(Text, { inverse: true, children: phase.value[phase.cursor] ?? " " }), _jsx(Text, { children: phase.value.slice(phase.cursor + 1) })] })] })), _jsx(StatusBar, { permissions: config.permissions })] }));
344
+ const ghost = phase.type === "input" ? ghostText(phase.value, allNl) : "";
345
+ // ── browse overlay ────────────────────────────────────────────────────────
346
+ if (phase.type === "browse") {
347
+ return (_jsxs(Box, { flexDirection: "column", children: [tabs.length > 1 && _jsx(TabBar, { tabs: tabs, active: activeTab }), _jsx(Browse, { cwd: phase.cwd, onCd: path => setPhase({ type: "browse", cwd: path }), onSelect: path => {
348
+ // fill input with the path
349
+ setPhase({ type: "input", value: path, cursor: path.length, histIdx: -1, raw: false });
350
+ }, onExit: () => inputPhase() }), _jsx(StatusBar, { permissions: config.permissions })] }));
351
+ }
352
+ // ── fuzzy overlay ──────────────────────────────────────────────────────────
353
+ if (phase.type === "fuzzy") {
354
+ return (_jsxs(Box, { flexDirection: "column", children: [tabs.length > 1 && _jsx(TabBar, { tabs: tabs, active: activeTab }), _jsx(FuzzyPicker, { history: allNl, onSelect: nl => {
355
+ setPhase({ type: "input", value: nl, cursor: nl.length, histIdx: -1, raw: false });
356
+ }, onExit: () => inputPhase() }), _jsx(StatusBar, { permissions: config.permissions })] }));
357
+ }
358
+ // ── main render ───────────────────────────────────────────────────────────
359
+ return (_jsxs(Box, { flexDirection: "column", children: [tabs.length > 1 && _jsx(TabBar, { tabs: tabs, active: activeTab }), tab.scroll.map((entry, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: entry.nl })] }), entry.nl !== entry.cmd && (_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { dimColor: true, children: entry.cmd })] })), entry.lines.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 4, children: [(entry.expanded ? entry.lines : entry.lines).map((line, j) => (_jsx(Text, { color: entry.error ? "red" : undefined, children: line }, j))), entry.truncated && !entry.expanded && (_jsx(Text, { dimColor: true, children: " \u2026 more lines" }))] }))] }, i))), phase.type === "confirm" && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: phase.nl })] }), _jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: phase.command }), phase.danger && _jsx(Text, { color: "red", children: " \u26A0 irreversible" })] }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: "enter n e ?" }) })] })), phase.type === "explain" && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: phase.command })] }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: phase.explanation }) }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: "any key \u2192" }) })] })), phase.type === "autofix" && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: "failed \u2014 retry with fix? [enter / n]" }) })), phase.type === "thinking" && (phase.partial ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: phase.nl })] }), _jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { dimColor: true, children: phase.partial })] })] })) : _jsx(Spinner, { label: "translating" })), phase.type === "running" && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { dimColor: true, children: phase.command })] }), _jsx(Box, { flexDirection: "column", paddingLeft: 2, children: tab.streamLines.slice(-MAX_LINES).map((l, i) => _jsx(Text, { children: l }, i)) }), _jsx(Spinner, { label: "ctrl+c to cancel" })] })), phase.type === "error" && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: "red", children: phase.message }) })), phase.type === "input" && (_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: isRaw ? "$" : "›" }), _jsxs(Box, { children: [_jsx(Text, { children: phase.value.slice(0, phase.cursor) }), _jsx(Text, { inverse: true, children: phase.value[phase.cursor] ?? " " }), _jsx(Text, { children: phase.value.slice(phase.cursor + 1) }), ghost && phase.cursor === phase.value.length && (_jsx(Text, { dimColor: true, children: ghost }))] })] })), _jsx(StatusBar, { permissions: config.permissions, cwd: tab.cwd })] }));
360
+ }
361
+ // ── TabBar ────────────────────────────────────────────────────────────────────
362
+ function TabBar({ tabs, active }) {
363
+ return (_jsxs(Box, { gap: 1, paddingLeft: 2, marginBottom: 1, children: [tabs.map((t, i) => {
364
+ const label = ` ${i + 1} `;
365
+ const cwd = t.cwd.split("/").pop() || t.cwd;
366
+ return (_jsx(Box, { children: i === active
367
+ ? _jsxs(Text, { inverse: true, children: [label, cwd] })
368
+ : _jsxs(Text, { dimColor: true, children: [label, cwd] }) }, t.id));
369
+ }), _jsx(Text, { dimColor: true, children: " ctrl+t new tab switch ctrl+w close" })] }));
258
370
  }
package/dist/Browse.js ADDED
@@ -0,0 +1,79 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useCallback } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { readdirSync, statSync } from "fs";
5
+ import { join, dirname } from "path";
6
+ function readDir(dir) {
7
+ try {
8
+ const names = readdirSync(dir);
9
+ const entries = [];
10
+ for (const name of names) {
11
+ try {
12
+ const stat = statSync(join(dir, name));
13
+ entries.push({ name, isDir: stat.isDirectory() });
14
+ }
15
+ catch {
16
+ entries.push({ name, isDir: false });
17
+ }
18
+ }
19
+ entries.sort((a, b) => {
20
+ if (a.isDir !== b.isDir)
21
+ return a.isDir ? -1 : 1;
22
+ return a.name.localeCompare(b.name);
23
+ });
24
+ return entries;
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ }
30
+ const PAGE = 20;
31
+ export default function Browse({ cwd, onCd, onSelect, onExit }) {
32
+ const [cursor, setCursor] = useState(0);
33
+ const entries = readDir(cwd);
34
+ const total = entries.length;
35
+ const safeIndex = Math.min(cursor, Math.max(0, total - 1));
36
+ const start = Math.max(0, Math.min(safeIndex - Math.floor(PAGE / 2), total - PAGE));
37
+ const slice = entries.slice(Math.max(0, start), Math.max(0, start) + PAGE);
38
+ useInput(useCallback((_input, key) => {
39
+ if (key.upArrow) {
40
+ setCursor(c => (c <= 0 ? Math.max(0, total - 1) : c - 1));
41
+ return;
42
+ }
43
+ if (key.downArrow) {
44
+ setCursor(c => (total === 0 ? 0 : (c >= total - 1 ? 0 : c + 1)));
45
+ return;
46
+ }
47
+ if (key.return) {
48
+ if (total === 0)
49
+ return;
50
+ const entry = entries[safeIndex];
51
+ if (!entry)
52
+ return;
53
+ const full = join(cwd, entry.name);
54
+ if (entry.isDir) {
55
+ setCursor(0);
56
+ onCd(full);
57
+ }
58
+ else {
59
+ onSelect(full);
60
+ }
61
+ return;
62
+ }
63
+ if (key.backspace || key.delete || key.leftArrow) {
64
+ setCursor(0);
65
+ onCd(dirname(cwd));
66
+ return;
67
+ }
68
+ if (key.escape) {
69
+ onExit();
70
+ return;
71
+ }
72
+ }, [cwd, entries, safeIndex, total, onCd, onSelect, onExit]));
73
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: cwd }), total === 0 && _jsx(Text, { dimColor: true, children: " (empty)" }), slice.map((entry, i) => {
74
+ const absIdx = Math.max(0, start) + i;
75
+ const selected = absIdx === safeIndex;
76
+ const icon = entry.isDir ? "▸" : "·";
77
+ return (_jsx(Box, { children: _jsx(Text, { inverse: selected, children: ` ${icon} ${entry.name}${entry.isDir ? "/" : ""} ` }) }, entry.name));
78
+ }), _jsx(Text, { dimColor: true, children: " enter backspace esc" })] }));
79
+ }
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useCallback } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ const MAX_MATCHES = 10;
5
+ export default function FuzzyPicker({ history, onSelect, onExit }) {
6
+ const [query, setQuery] = useState("");
7
+ const [cursor, setCursor] = useState(0);
8
+ const matches = query === ""
9
+ ? history.slice().reverse().slice(0, MAX_MATCHES)
10
+ : history.slice().reverse().filter(h => h.toLowerCase().includes(query.toLowerCase())).slice(0, MAX_MATCHES);
11
+ const safeCursor = Math.min(cursor, Math.max(0, matches.length - 1));
12
+ useInput(useCallback((_input, key) => {
13
+ if (key.escape || key.ctrl && _input === "c") {
14
+ onExit();
15
+ return;
16
+ }
17
+ if (key.return) {
18
+ if (matches.length > 0) {
19
+ onSelect(matches[safeCursor]);
20
+ }
21
+ return;
22
+ }
23
+ if (key.upArrow) {
24
+ setCursor(c => Math.max(0, c - 1));
25
+ return;
26
+ }
27
+ if (key.downArrow) {
28
+ setCursor(c => Math.min(matches.length - 1, c + 1));
29
+ return;
30
+ }
31
+ if (key.backspace || key.delete) {
32
+ setQuery(q => q.slice(0, -1));
33
+ setCursor(0);
34
+ return;
35
+ }
36
+ if (!key.ctrl && !key.meta && _input && _input.length === 1) {
37
+ setQuery(q => q + _input);
38
+ setCursor(0);
39
+ }
40
+ }, [matches, safeCursor, onSelect, onExit]));
41
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: ` / ${query}_` }), matches.length === 0
42
+ ? _jsx(Text, { dimColor: true, children: " no matches" })
43
+ : matches.map((m, i) => {
44
+ const selected = i === safeCursor;
45
+ return (_jsx(Box, { children: _jsx(Text, { inverse: selected, dimColor: !selected, children: ` ${m} ` }) }, i));
46
+ }), _jsx(Text, { dimColor: true, children: " enter esc" })] }));
47
+ }
package/dist/StatusBar.js CHANGED
@@ -2,25 +2,27 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { execSync } from "child_process";
4
4
  import { homedir } from "os";
5
- function getCwd() {
6
- const cwd = process.cwd();
5
+ function formatCwd(cwd) {
7
6
  const home = homedir();
8
7
  return cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
9
8
  }
10
- function getGitBranch() {
9
+ function getGitBranch(cwd) {
11
10
  try {
12
- return execSync("git branch --show-current 2>/dev/null", { stdio: ["ignore", "pipe", "ignore"] })
13
- .toString()
14
- .trim() || null;
11
+ return execSync("git branch --show-current 2>/dev/null", {
12
+ cwd,
13
+ stdio: ["ignore", "pipe", "ignore"],
14
+ }).toString().trim() || null;
15
15
  }
16
16
  catch {
17
17
  return null;
18
18
  }
19
19
  }
20
- function getGitDirty() {
20
+ function getGitDirty(cwd) {
21
21
  try {
22
- const out = execSync("git status --porcelain 2>/dev/null", { stdio: ["ignore", "pipe", "ignore"] }).toString();
23
- return out.trim().length > 0;
22
+ return execSync("git status --porcelain 2>/dev/null", {
23
+ cwd,
24
+ stdio: ["ignore", "pipe", "ignore"],
25
+ }).toString().trim().length > 0;
24
26
  }
25
27
  catch {
26
28
  return false;
@@ -34,12 +36,14 @@ function activePerms(perms) {
34
36
  ["install", "pkg"],
35
37
  ["write_outside_cwd", "write"],
36
38
  ];
37
- return labels.filter(([k]) => perms[k]).map(([, l]) => l);
39
+ // only show disabled ones full access is the default so no need to clutter
40
+ const disabled = labels.filter(([k]) => !perms[k]).map(([, l]) => `no-${l}`);
41
+ return disabled;
38
42
  }
39
- export default function StatusBar({ permissions }) {
40
- const cwd = getCwd();
41
- const branch = getGitBranch();
42
- const dirty = branch ? getGitDirty() : false;
43
- const perms = activePerms(permissions);
44
- return (_jsxs(Box, { gap: 2, paddingLeft: 2, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: cwd }), branch && (_jsxs(Text, { dimColor: true, children: [branch, dirty ? " ●" : ""] })), perms.length > 0 && (_jsx(Text, { dimColor: true, children: perms.join(" · ") }))] }));
43
+ export default function StatusBar({ permissions, cwd: cwdProp }) {
44
+ const cwd = cwdProp ?? process.cwd();
45
+ const branch = getGitBranch(cwd);
46
+ const dirty = branch ? getGitDirty(cwd) : false;
47
+ const restricted = activePerms(permissions);
48
+ return (_jsxs(Box, { gap: 2, paddingLeft: 2, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: formatCwd(cwd) }), branch && _jsxs(Text, { dimColor: true, children: [branch, dirty ? " ●" : ""] }), restricted.length > 0 && _jsx(Text, { dimColor: true, children: restricted.join(" · ") })] }));
45
49
  }