@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/dist/App.js CHANGED
@@ -3,73 +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
+ import { loadCache } from "./cache.js";
7
8
  import Onboarding from "./Onboarding.js";
8
9
  import StatusBar from "./StatusBar.js";
9
10
  import Spinner from "./Spinner.js";
11
+ import Browse from "./Browse.js";
12
+ import FuzzyPicker from "./FuzzyPicker.js";
13
+ loadCache();
10
14
  const MAX_LINES = 20;
11
15
  // ── helpers ───────────────────────────────────────────────────────────────────
12
16
  function insertAt(s, pos, ch) { return s.slice(0, pos) + ch + s.slice(pos); }
13
17
  function deleteAt(s, pos) { return pos <= 0 ? s : s.slice(0, pos - 1) + s.slice(pos); }
14
- function runCommand(command, onLine, onDone, signal) {
15
- const proc = spawn("/bin/zsh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"] });
16
- const handleData = (data) => {
17
- const text = data.toString();
18
- text.split("\n").forEach((line) => { if (line)
19
- 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: [],
20
46
  };
21
- proc.stdout?.on("data", handleData);
22
- proc.stderr?.on("data", handleData);
23
- 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));
24
55
  signal.addEventListener("abort", () => { try {
25
56
  proc.kill("SIGTERM");
26
57
  }
27
58
  catch { } });
28
- return proc;
29
59
  }
30
- // ── component ─────────────────────────────────────────────────────────────────
60
+ // ── App ───────────────────────────────────────────────────────────────────────
31
61
  export default function App() {
32
62
  const { exit } = useApp();
33
63
  const [config, setConfig] = useState(() => loadConfig());
34
- const [nlHistory] = useState(() => loadHistory().map((h) => h.nl).filter(Boolean));
35
- const [sessionCmds, setSessionCmds] = useState([]);
36
- const [sessionNl, setSessionNl] = useState([]);
37
- const [scroll, setScroll] = useState([]);
38
- 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);
39
67
  const abortRef = useRef(null);
40
- const [phase, setPhase] = useState({
41
- type: "input", value: "", cursor: 0, histIdx: -1, raw: false,
42
- });
43
- const allNl = [...nlHistory, ...sessionNl];
44
- const finishOnboarding = (perms) => {
45
- const next = { onboarded: true, confirm: false, permissions: perms };
46
- setConfig(next);
47
- saveConfig(next);
48
- };
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 }));
49
75
  const inputPhase = (overrides = {}) => {
50
- setPhase({ type: "input", value: "", cursor: 0, histIdx: -1, raw: false, ...overrides });
51
- setStreamLines([]);
76
+ updateTab(t => ({
77
+ ...t,
78
+ streamLines: [],
79
+ phase: { type: "input", value: "", cursor: 0, histIdx: -1, raw: false, ...overrides },
80
+ }));
52
81
  };
53
- const pushScroll = (entry) => setScroll((s) => [...s, { ...entry, expanded: false }]);
82
+ const pushScroll = (entry) => updateTab(t => ({ ...t, scroll: [...t.scroll, { ...entry, expanded: false }] }));
54
83
  const commitStream = (nl, cmd, lines, error) => {
55
84
  const truncated = lines.length > MAX_LINES;
56
- 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
+ }));
57
98
  appendHistory({ nl, cmd, output: lines.join("\n"), ts: Date.now(), error });
58
- setSessionCmds((c) => [...c.slice(-9), cmd]);
59
- setStreamLines([]);
60
99
  };
100
+ // ── run command ─────────────────────────────────────────────────────────────
61
101
  const runPhase = async (nl, command, raw) => {
62
102
  setPhase({ type: "running", nl, command });
63
- setStreamLines([]);
103
+ updateTab(t => ({ ...t, streamLines: [] }));
64
104
  const abort = new AbortController();
65
105
  abortRef.current = abort;
66
106
  const lines = [];
67
- await new Promise((resolve) => {
68
- 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
+ }
69
121
  commitStream(nl, command, lines, code !== 0);
70
122
  abortRef.current = null;
71
123
  if (code !== 0 && !raw) {
72
- // offer auto-fix
73
124
  setPhase({ type: "autofix", nl, command, errorOutput: lines.join("\n") });
74
125
  }
75
126
  else {
@@ -79,8 +130,43 @@ export default function App() {
79
130
  }, abort.signal);
80
131
  });
81
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 ───────────────────────────────────────────────────────────
82
162
  useInput(useCallback(async (input, key) => {
83
- // ── 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 ──────────────────────────────────────────────
84
170
  if (phase.type === "running") {
85
171
  if (key.ctrl && input === "c") {
86
172
  abortRef.current?.abort();
@@ -88,20 +174,46 @@ export default function App() {
88
174
  }
89
175
  return;
90
176
  }
91
- // ── 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 ────────────────────────────────────────────────────────────────
92
184
  if (phase.type === "input") {
93
- if (key.ctrl && input === "c") {
94
- exit();
185
+ // global shortcuts
186
+ if (key.ctrl && input === "l") {
187
+ updateTab(t => ({ ...t, scroll: [] }));
95
188
  return;
96
189
  }
97
- if (key.ctrl && input === "l") {
98
- setScroll([]);
190
+ if (key.ctrl && input === "b") {
191
+ setPhase({ type: "browse", cwd: tab.cwd });
99
192
  return;
100
193
  }
101
194
  if (key.ctrl && input === "r") {
102
- 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
103
203
  return;
104
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
105
217
  if (key.upArrow) {
106
218
  const idx = Math.min(phase.histIdx + 1, allNl.length - 1);
107
219
  const val = allNl[allNl.length - 1 - idx] ?? "";
@@ -114,43 +226,37 @@ export default function App() {
114
226
  setPhase({ ...phase, value: val, cursor: val.length, histIdx: idx });
115
227
  return;
116
228
  }
229
+ // cursor movement
117
230
  if (key.leftArrow) {
118
231
  setPhase({ ...phase, cursor: Math.max(0, phase.cursor - 1) });
119
232
  return;
120
233
  }
121
234
  if (key.rightArrow) {
122
- 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
+ }
123
244
  return;
124
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
+ }
125
255
  if (key.return) {
126
256
  const nl = phase.value.trim();
127
257
  if (!nl)
128
258
  return;
129
- setSessionNl((h) => [...h, nl]);
130
- if (phase.raw) {
131
- await runPhase(nl, nl, true);
132
- return;
133
- }
134
- setPhase({ type: "thinking", nl, raw: false });
135
- try {
136
- const command = await translateToCommand(nl, config.permissions, sessionCmds);
137
- const blocked = checkPermissions(command, config.permissions);
138
- if (blocked) {
139
- pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
140
- inputPhase();
141
- return;
142
- }
143
- const danger = isIrreversible(command);
144
- // skip confirm unless user opted in OR command is dangerous
145
- if (!config.confirm && !danger) {
146
- await runPhase(nl, command, false);
147
- return;
148
- }
149
- setPhase({ type: "confirm", nl, command, raw: false, danger });
150
- }
151
- catch (e) {
152
- setPhase({ type: "error", message: e.message });
153
- }
259
+ await translateAndRun(nl, phase.raw);
154
260
  return;
155
261
  }
156
262
  if (key.backspace || key.delete) {
@@ -164,21 +270,17 @@ export default function App() {
164
270
  }
165
271
  return;
166
272
  }
167
- // ── confirm ───────────────────────────────────────────────────────
273
+ // ── confirm ──────────────────────────────────────────────────────────────
168
274
  if (phase.type === "confirm") {
169
- if (key.ctrl && input === "c") {
170
- exit();
171
- return;
172
- }
173
275
  if (input === "?") {
174
276
  const { nl, command } = phase;
175
- setPhase({ type: "thinking", nl, raw: false });
277
+ setPhase({ type: "thinking", nl, partial: "" });
176
278
  try {
177
279
  const explanation = await explainCommand(command);
178
280
  setPhase({ type: "explain", nl, command, explanation });
179
281
  }
180
282
  catch {
181
- setPhase({ type: "confirm", nl, command, raw: false, danger: phase.danger });
283
+ setPhase({ type: "confirm", nl, command, danger: phase.danger });
182
284
  }
183
285
  return;
184
286
  }
@@ -196,32 +298,24 @@ export default function App() {
196
298
  }
197
299
  return;
198
300
  }
199
- // ── explain → back to confirm ─────────────────────────────────────
301
+ // ── explain ──────────────────────────────────────────────────────────────
200
302
  if (phase.type === "explain") {
201
- if (key.ctrl && input === "c") {
202
- exit();
203
- return;
204
- }
205
- 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) });
206
304
  return;
207
305
  }
208
- // ── autofix ───────────────────────────────────────────────────────
306
+ // ── autofix ──────────────────────────────────────────────────────────────
209
307
  if (phase.type === "autofix") {
210
- if (key.ctrl && input === "c") {
211
- exit();
212
- return;
213
- }
214
308
  if (input === "y" || input === "Y" || key.return) {
215
309
  const { nl, command, errorOutput } = phase;
216
- setPhase({ type: "thinking", nl, raw: false });
310
+ setPhase({ type: "thinking", nl, partial: "" });
217
311
  try {
218
- const fixed = await fixCommand(nl, command, errorOutput, config.permissions, sessionCmds);
312
+ const fixed = await fixCommand(nl, command, errorOutput, config.permissions, tab.sessionCmds);
219
313
  const danger = isIrreversible(fixed);
220
314
  if (!config.confirm && !danger) {
221
315
  await runPhase(nl, fixed, false);
222
316
  return;
223
317
  }
224
- setPhase({ type: "confirm", nl, command: fixed, raw: false, danger });
318
+ setPhase({ type: "confirm", nl, command: fixed, danger });
225
319
  }
226
320
  catch (e) {
227
321
  setPhase({ type: "error", message: e.message });
@@ -231,23 +325,46 @@ export default function App() {
231
325
  inputPhase();
232
326
  return;
233
327
  }
234
- // ── error ─────────────────────────────────────────────────────────
328
+ // ── error ────────────────────────────────────────────────────────────────
235
329
  if (phase.type === "error") {
236
- if (key.ctrl && input === "c") {
237
- exit();
238
- return;
239
- }
240
330
  inputPhase();
241
331
  return;
242
332
  }
243
- }, [phase, allNl, config, sessionCmds, exit]));
244
- // ── expand toggle ──────────────────────────────────────────────────────────
245
- const toggleExpand = (i) => setScroll((s) => s.map((e, idx) => idx === i ? { ...e, expanded: !e.expanded } : e));
246
- // ── onboarding ─────────────────────────────────────────────────────────────
333
+ }, [tabs, activeTab, allNl, config, exit]));
334
+ // ── onboarding ───────────────────────────────────────────────────────────────
247
335
  if (!config.onboarded) {
248
- 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
+ } });
249
341
  }
342
+ const phase = tab.phase;
250
343
  const isRaw = phase.type === "input" && phase.raw;
251
- // ── render ─────────────────────────────────────────────────────────────────
252
- 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" && _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" })] }));
253
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
  }