@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
@@ -1 +1 @@
1
- {"sessionId":"501c27b4-5243-40d6-8fb5-04ebdb3c8df8","pid":90841,"acquiredAt":1773556984409}
1
+ {"sessionId":"c1e414c7-f1a5-4b9e-bcc4-64c451584cb8","pid":54679,"acquiredAt":1773566918526}
package/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # open-terminal
2
+
3
+ Smart terminal wrapper for AI agents and humans. Speak plain English or let agents execute commands with structured output, token compression, and massive context savings.
4
+
5
+ ## Why?
6
+
7
+ AI agents waste tokens on terminal interaction. Every `npm test` dumps hundreds of lines into context. Every `find` returns noise. `open-terminal` sits between callers and the shell, making every interaction dramatically more efficient.
8
+
9
+ **For agents:** MCP server with structured output, token compression, diff-aware caching, smart search, process supervision. Cut token usage 50-90% on verbose commands.
10
+
11
+ **For humans:** Natural language terminal powered by Cerebras (free, open-source) or Anthropic. Type "count typescript files" instead of `find . -name '*.ts' | wc -l`.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install -g @hasna/terminal
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ### For Humans (TUI Mode)
22
+
23
+ ```bash
24
+ # Set your API key (pick one)
25
+ export CEREBRAS_API_KEY=your_key # free, open-source (default)
26
+ export ANTHROPIC_API_KEY=your_key # Claude
27
+
28
+ # Launch
29
+ t
30
+ ```
31
+
32
+ Type in plain English. The terminal translates, shows you the command, and runs it.
33
+
34
+ ### For AI Agents (MCP Server)
35
+
36
+ ```bash
37
+ # Install for your agent
38
+ t mcp install --claude # Claude Code
39
+ t mcp install --codex # OpenAI Codex
40
+ t mcp install --gemini # Gemini CLI
41
+ t mcp install --all # All agents
42
+
43
+ # Or start manually
44
+ t mcp serve
45
+ ```
46
+
47
+ ## MCP Tools
48
+
49
+ | Tool | Description | Token Savings |
50
+ |------|-------------|---------------|
51
+ | `execute` | Run command with structured output, compression, or AI summary | 50-90% |
52
+ | `execute_diff` | Run command, return only what changed since last run | 80-95% |
53
+ | `browse` | List files as structured JSON, auto-filter node_modules | 60-80% |
54
+ | `search_files` | Find files by pattern, categorized (source/config/other) | 70-90% |
55
+ | `search_content` | Grep with grouping by file and relevance ranking | 60-80% |
56
+ | `explain_error` | Structured error diagnosis with fix suggestions | N/A |
57
+ | `bg_start` | Start background process with port auto-detection | N/A |
58
+ | `bg_status` | List managed processes with health info | N/A |
59
+ | `bg_wait_port` | Wait for a port to be ready | N/A |
60
+ | `bg_stop` / `bg_logs` | Stop process / get recent output | N/A |
61
+ | `list_recipes` / `run_recipe` / `save_recipe` | Reusable command templates | N/A |
62
+ | `snapshot` | Capture terminal state for agent handoff | N/A |
63
+ | `token_stats` | Token economy dashboard | N/A |
64
+
65
+ ### Example: Structured Output
66
+
67
+ ```
68
+ Agent: execute("npm test", {format: "json"})
69
+
70
+ → {"passed": 142, "failed": 2, "failures": [{"test": "auth.test.ts:45", "error": "expected 200 got 401"}]}
71
+ (saved 847 tokens vs raw output)
72
+ ```
73
+
74
+ ### Example: Diff Mode
75
+
76
+ ```
77
+ Agent: execute_diff("npm test") # first run → full output
78
+ Agent: execute_diff("npm test") # second run → only changes
79
+
80
+ → {"diffSummary": "+1 new line, -1 removed", "added": ["PASS auth.test.ts:45"], "removed": ["FAIL auth.test.ts:45"], "tokensSaved": 892}
81
+ ```
82
+
83
+ ### Example: Smart Search
84
+
85
+ ```
86
+ Agent: search_files("*hooks*")
87
+
88
+ → {"source": ["src/lib/webhooks.ts", "src/hooks/useAuth.ts"], "filtered": [{"count": 47, "reason": "node_modules"}], "tokensSaved": 312}
89
+ ```
90
+
91
+ ## Recipes
92
+
93
+ Reusable command templates with variable substitution:
94
+
95
+ ```bash
96
+ # Save a recipe
97
+ t recipe add kill-port "lsof -i :{port} -t | xargs kill"
98
+
99
+ # Run it
100
+ t recipe run kill-port --port=3000
101
+
102
+ # List recipes
103
+ t recipe list
104
+
105
+ # Project-scoped recipes
106
+ t project init
107
+ t recipe add dev-start "npm run dev" --project
108
+
109
+ # Collections
110
+ t collection create docker "Docker commands"
111
+ t recipe add docker-build "docker build -t {tag} ." --collection=docker
112
+ ```
113
+
114
+ ## Token Economy
115
+
116
+ Track how many tokens you've saved:
117
+
118
+ ```bash
119
+ t stats
120
+ ```
121
+
122
+ ```
123
+ Token Economy:
124
+ Total saved: 124.5K
125
+ By feature:
126
+ Structured: 45.2K
127
+ Compressed: 32.1K
128
+ Diff cache: 28.7K
129
+ Search: 18.5K
130
+ ```
131
+
132
+ ## TUI Keyboard Shortcuts
133
+
134
+ | Key | Action |
135
+ |-----|--------|
136
+ | `ctrl+t` | New tab |
137
+ | `tab` | Switch tabs |
138
+ | `ctrl+w` | Close tab |
139
+ | `ctrl+b` | Browse mode (file navigator) |
140
+ | `ctrl+r` | Fuzzy history search |
141
+ | `ctrl+l` | Clear scrollback |
142
+ | `ctrl+c` | Cancel / exit |
143
+ | `?` | Explain command before running |
144
+ | `e` | Edit translated command |
145
+ | `→` | Accept ghost text suggestion |
146
+
147
+ ## Configuration
148
+
149
+ Config stored at `~/.terminal/config.json`:
150
+
151
+ ```json
152
+ {
153
+ "provider": "cerebras",
154
+ "permissions": {
155
+ "destructive": true,
156
+ "network": true,
157
+ "sudo": false,
158
+ "install": true,
159
+ "write_outside_cwd": false
160
+ }
161
+ }
162
+ ```
163
+
164
+ ## Architecture
165
+
166
+ ```
167
+ ┌──────────────────────────────────────────┐
168
+ │ open-terminal │
169
+ │ ┌──────────┐ ┌──────────┐ ┌────────┐ │
170
+ │ │ Human │ │ MCP │ │ CLI │ │
171
+ │ │ TUI │ │ Server │ │ Tools │ │
172
+ │ └────┬─────┘ └────┬─────┘ └───┬────┘ │
173
+ │ └──────────┬───┘────────────┘ │
174
+ │ ┌──────────────────────────────────┐ │
175
+ │ │ Output Intelligence Router │ │
176
+ │ │ Parsers → Compression → Diff │ │
177
+ │ └──────────────┬───────────────────┘ │
178
+ │ ┌──────────────────────────────────┐ │
179
+ │ │ Shell (zsh/bash) │ │
180
+ │ └──────────────────────────────────┘ │
181
+ └──────────────────────────────────────────┘
182
+ ```
183
+
184
+ ## License
185
+
186
+ MIT
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
  }