@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.
- package/.claude/scheduled_tasks.lock +1 -1
- package/README.md +186 -0
- package/dist/App.js +217 -105
- package/dist/Browse.js +79 -0
- package/dist/FuzzyPicker.js +47 -0
- package/dist/StatusBar.js +20 -16
- package/dist/ai.js +45 -50
- package/dist/cli.js +138 -6
- package/dist/compression.js +107 -0
- package/dist/compression.test.js +42 -0
- package/dist/diff-cache.js +87 -0
- package/dist/diff-cache.test.js +27 -0
- package/dist/economy.js +79 -0
- package/dist/economy.test.js +13 -0
- package/dist/mcp/install.js +98 -0
- package/dist/mcp/server.js +333 -0
- package/dist/output-router.js +41 -0
- package/dist/parsers/base.js +2 -0
- package/dist/parsers/build.js +64 -0
- package/dist/parsers/errors.js +101 -0
- package/dist/parsers/files.js +78 -0
- package/dist/parsers/git.js +86 -0
- package/dist/parsers/index.js +48 -0
- package/dist/parsers/parsers.test.js +136 -0
- package/dist/parsers/tests.js +89 -0
- package/dist/providers/anthropic.js +39 -0
- package/dist/providers/base.js +4 -0
- package/dist/providers/cerebras.js +95 -0
- package/dist/providers/index.js +49 -0
- package/dist/providers/providers.test.js +14 -0
- package/dist/recipes/model.js +20 -0
- package/dist/recipes/recipes.test.js +36 -0
- package/dist/recipes/storage.js +118 -0
- package/dist/search/content-search.js +61 -0
- package/dist/search/file-search.js +61 -0
- package/dist/search/filters.js +34 -0
- package/dist/search/index.js +4 -0
- package/dist/search/search.test.js +22 -0
- package/dist/snapshots.js +51 -0
- package/dist/supervisor.js +112 -0
- package/dist/tree.js +94 -0
- package/package.json +7 -4
- package/src/App.tsx +371 -245
- package/src/Browse.tsx +103 -0
- package/src/FuzzyPicker.tsx +69 -0
- package/src/StatusBar.tsx +28 -34
- package/src/ai.ts +63 -51
- package/src/cli.tsx +132 -6
- package/src/compression.test.ts +50 -0
- package/src/compression.ts +140 -0
- package/src/diff-cache.test.ts +30 -0
- package/src/diff-cache.ts +125 -0
- package/src/economy.test.ts +16 -0
- package/src/economy.ts +99 -0
- package/src/mcp/install.ts +94 -0
- package/src/mcp/server.ts +476 -0
- package/src/output-router.ts +56 -0
- package/src/parsers/base.ts +72 -0
- package/src/parsers/build.ts +73 -0
- package/src/parsers/errors.ts +107 -0
- package/src/parsers/files.ts +91 -0
- package/src/parsers/git.ts +86 -0
- package/src/parsers/index.ts +66 -0
- package/src/parsers/parsers.test.ts +153 -0
- package/src/parsers/tests.ts +98 -0
- package/src/providers/anthropic.ts +44 -0
- package/src/providers/base.ts +34 -0
- package/src/providers/cerebras.ts +108 -0
- package/src/providers/index.ts +60 -0
- package/src/providers/providers.test.ts +16 -0
- package/src/recipes/model.ts +55 -0
- package/src/recipes/recipes.test.ts +44 -0
- package/src/recipes/storage.ts +142 -0
- package/src/search/content-search.ts +97 -0
- package/src/search/file-search.ts +86 -0
- package/src/search/filters.ts +36 -0
- package/src/search/index.ts +7 -0
- package/src/search/search.test.ts +25 -0
- package/src/snapshots.ts +67 -0
- package/src/supervisor.ts +129 -0
- package/src/tree.ts +101 -0
- package/tsconfig.json +2 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"sessionId":"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
proc
|
|
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
|
-
// ──
|
|
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(
|
|
38
|
-
const [
|
|
39
|
-
const [
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
103
|
+
updateTab(t => ({ ...t, streamLines: [] }));
|
|
67
104
|
const abort = new AbortController();
|
|
68
105
|
abortRef.current = abort;
|
|
69
106
|
const lines = [];
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
97
|
-
|
|
185
|
+
// global shortcuts
|
|
186
|
+
if (key.ctrl && input === "l") {
|
|
187
|
+
updateTab(t => ({ ...t, scroll: [] }));
|
|
98
188
|
return;
|
|
99
189
|
}
|
|
100
|
-
if (key.ctrl && input === "
|
|
101
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
301
|
+
// ── explain ──────────────────────────────────────────────────────────────
|
|
205
302
|
if (phase.type === "explain") {
|
|
206
|
-
|
|
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,
|
|
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,
|
|
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
|
-
}, [
|
|
249
|
-
// ──
|
|
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:
|
|
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
|
-
|
|
257
|
-
|
|
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
|
}
|