@hasna/terminal 0.1.0 → 0.1.1
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 +101 -17
- package/dist/Onboarding.js +19 -23
- package/dist/Spinner.js +12 -0
- package/dist/StatusBar.js +45 -0
- package/dist/ai.js +12 -0
- package/package.json +4 -4
- package/src/App.tsx +171 -57
- package/src/Onboarding.tsx +47 -59
- package/src/Spinner.tsx +24 -0
- package/src/StatusBar.tsx +67 -0
- package/src/ai.ts +12 -0
package/dist/App.js
CHANGED
|
@@ -3,17 +3,37 @@ import { useState, useCallback } from "react";
|
|
|
3
3
|
import { Box, Text, useInput, useApp } from "ink";
|
|
4
4
|
import { exec } from "child_process";
|
|
5
5
|
import { promisify } from "util";
|
|
6
|
-
import { translateToCommand, checkPermissions } from "./ai.js";
|
|
6
|
+
import { translateToCommand, explainCommand, checkPermissions } from "./ai.js";
|
|
7
7
|
import { loadHistory, appendHistory, loadConfig, saveConfig, } from "./history.js";
|
|
8
8
|
import Onboarding from "./Onboarding.js";
|
|
9
|
+
import StatusBar from "./StatusBar.js";
|
|
10
|
+
import Spinner from "./Spinner.js";
|
|
9
11
|
const execAsync = promisify(exec);
|
|
12
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
13
|
+
function insertAt(str, pos, ch) {
|
|
14
|
+
return str.slice(0, pos) + ch + str.slice(pos);
|
|
15
|
+
}
|
|
16
|
+
function deleteAt(str, pos) {
|
|
17
|
+
if (pos <= 0)
|
|
18
|
+
return str;
|
|
19
|
+
return str.slice(0, pos - 1) + str.slice(pos);
|
|
20
|
+
}
|
|
21
|
+
// ── component ────────────────────────────────────────────────────────────────
|
|
10
22
|
export default function App() {
|
|
11
23
|
const { exit } = useApp();
|
|
12
24
|
const [config, setConfig] = useState(() => loadConfig());
|
|
13
|
-
const [nlHistory] = useState(() => loadHistory()
|
|
25
|
+
const [nlHistory] = useState(() => loadHistory()
|
|
26
|
+
.map((h) => h.nl)
|
|
27
|
+
.filter(Boolean));
|
|
14
28
|
const [sessionNl, setSessionNl] = useState([]);
|
|
15
29
|
const [scroll, setScroll] = useState([]);
|
|
16
|
-
const [phase, setPhase] = useState({
|
|
30
|
+
const [phase, setPhase] = useState({
|
|
31
|
+
type: "input",
|
|
32
|
+
value: "",
|
|
33
|
+
cursor: 0,
|
|
34
|
+
histIdx: -1,
|
|
35
|
+
raw: false,
|
|
36
|
+
});
|
|
17
37
|
const allNl = [...nlHistory, ...sessionNl];
|
|
18
38
|
const finishOnboarding = (perms) => {
|
|
19
39
|
const next = { onboarded: true, permissions: perms };
|
|
@@ -21,40 +41,74 @@ export default function App() {
|
|
|
21
41
|
saveConfig(next);
|
|
22
42
|
};
|
|
23
43
|
const pushScroll = (entry) => setScroll((s) => [...s, entry]);
|
|
44
|
+
const inputPhase = (overrides = {}) => setPhase({ type: "input", value: "", cursor: 0, histIdx: -1, raw: false, ...overrides });
|
|
24
45
|
useInput(useCallback(async (input, key) => {
|
|
46
|
+
// ── input ─────────────────────────────────────────────────────────
|
|
25
47
|
if (phase.type === "input") {
|
|
26
48
|
if (key.ctrl && input === "c") {
|
|
27
49
|
exit();
|
|
28
50
|
return;
|
|
29
51
|
}
|
|
52
|
+
// toggle raw mode
|
|
53
|
+
if (key.ctrl && input === "r") {
|
|
54
|
+
setPhase({ ...phase, raw: !phase.raw, value: "", cursor: 0 });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// history navigation
|
|
30
58
|
if (key.upArrow) {
|
|
31
59
|
const nextIdx = Math.min(phase.histIdx + 1, allNl.length - 1);
|
|
32
60
|
const val = allNl[allNl.length - 1 - nextIdx] ?? "";
|
|
33
|
-
setPhase({
|
|
61
|
+
setPhase({ ...phase, value: val, cursor: val.length, histIdx: nextIdx });
|
|
34
62
|
return;
|
|
35
63
|
}
|
|
36
64
|
if (key.downArrow) {
|
|
37
65
|
const nextIdx = Math.max(phase.histIdx - 1, -1);
|
|
38
66
|
const val = nextIdx === -1 ? "" : allNl[allNl.length - 1 - nextIdx] ?? "";
|
|
39
|
-
setPhase({
|
|
67
|
+
setPhase({ ...phase, value: val, cursor: val.length, histIdx: nextIdx });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// cursor movement
|
|
71
|
+
if (key.leftArrow) {
|
|
72
|
+
setPhase({ ...phase, cursor: Math.max(0, phase.cursor - 1) });
|
|
40
73
|
return;
|
|
41
74
|
}
|
|
75
|
+
if (key.rightArrow) {
|
|
76
|
+
setPhase({ ...phase, cursor: Math.min(phase.value.length, phase.cursor + 1) });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// submit
|
|
42
80
|
if (key.return) {
|
|
43
81
|
const nl = phase.value.trim();
|
|
44
82
|
if (!nl)
|
|
45
83
|
return;
|
|
46
84
|
setSessionNl((h) => [...h, nl]);
|
|
47
|
-
|
|
85
|
+
if (phase.raw) {
|
|
86
|
+
// raw mode — run directly
|
|
87
|
+
setPhase({ type: "running", nl, command: nl });
|
|
88
|
+
try {
|
|
89
|
+
const { stdout, stderr } = await execAsync(nl, { shell: "/bin/zsh" });
|
|
90
|
+
const output = (stdout + stderr).trim();
|
|
91
|
+
pushScroll({ nl, cmd: nl, output });
|
|
92
|
+
appendHistory({ nl, cmd: nl, output, ts: Date.now() });
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
const output = ((e.stdout ?? "") + (e.stderr ?? "")).trim() || e.message;
|
|
96
|
+
pushScroll({ nl, cmd: nl, output, error: true });
|
|
97
|
+
appendHistory({ nl, cmd: nl, output, ts: Date.now(), error: true });
|
|
98
|
+
}
|
|
99
|
+
inputPhase({ raw: true });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
setPhase({ type: "thinking", nl, raw: false });
|
|
48
103
|
try {
|
|
49
104
|
const command = await translateToCommand(nl, config.permissions);
|
|
50
|
-
// Local permission guard on the returned command
|
|
51
105
|
const blocked = checkPermissions(command, config.permissions);
|
|
52
106
|
if (blocked) {
|
|
53
107
|
pushScroll({ nl, cmd: command, output: `blocked: ${blocked}`, error: true });
|
|
54
|
-
|
|
108
|
+
inputPhase();
|
|
55
109
|
return;
|
|
56
110
|
}
|
|
57
|
-
setPhase({ type: "confirm", nl, command });
|
|
111
|
+
setPhase({ type: "confirm", nl, command, raw: false });
|
|
58
112
|
}
|
|
59
113
|
catch (e) {
|
|
60
114
|
setPhase({ type: "error", message: e.message });
|
|
@@ -62,19 +116,35 @@ export default function App() {
|
|
|
62
116
|
return;
|
|
63
117
|
}
|
|
64
118
|
if (key.backspace || key.delete) {
|
|
65
|
-
|
|
119
|
+
const val = deleteAt(phase.value, phase.cursor);
|
|
120
|
+
setPhase({ ...phase, value: val, cursor: Math.max(0, phase.cursor - 1), histIdx: -1 });
|
|
66
121
|
return;
|
|
67
122
|
}
|
|
68
123
|
if (input && !key.ctrl && !key.meta) {
|
|
69
|
-
|
|
124
|
+
const val = insertAt(phase.value, phase.cursor, input);
|
|
125
|
+
setPhase({ ...phase, value: val, cursor: phase.cursor + 1, histIdx: -1 });
|
|
70
126
|
}
|
|
71
127
|
return;
|
|
72
128
|
}
|
|
129
|
+
// ── confirm ───────────────────────────────────────────────────────
|
|
73
130
|
if (phase.type === "confirm") {
|
|
74
131
|
if (key.ctrl && input === "c") {
|
|
75
132
|
exit();
|
|
76
133
|
return;
|
|
77
134
|
}
|
|
135
|
+
// explain
|
|
136
|
+
if (input === "?") {
|
|
137
|
+
const { nl, command } = phase;
|
|
138
|
+
setPhase({ type: "thinking", nl, raw: false });
|
|
139
|
+
try {
|
|
140
|
+
const explanation = await explainCommand(command);
|
|
141
|
+
setPhase({ type: "explain", nl, command, explanation });
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
setPhase({ type: "confirm", nl, command, raw: false });
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
78
148
|
if (input === "y" || input === "Y" || key.return) {
|
|
79
149
|
const { nl, command } = phase;
|
|
80
150
|
setPhase({ type: "running", nl, command });
|
|
@@ -83,37 +153,51 @@ export default function App() {
|
|
|
83
153
|
const output = (stdout + stderr).trim();
|
|
84
154
|
pushScroll({ nl, cmd: command, output });
|
|
85
155
|
appendHistory({ nl, cmd: command, output, ts: Date.now() });
|
|
86
|
-
|
|
156
|
+
inputPhase();
|
|
87
157
|
}
|
|
88
158
|
catch (e) {
|
|
89
159
|
const output = ((e.stdout ?? "") + (e.stderr ?? "")).trim() || e.message;
|
|
90
160
|
pushScroll({ nl, cmd: command, output, error: true });
|
|
91
161
|
appendHistory({ nl, cmd: command, output, ts: Date.now(), error: true });
|
|
92
|
-
|
|
162
|
+
inputPhase();
|
|
93
163
|
}
|
|
94
164
|
return;
|
|
95
165
|
}
|
|
96
166
|
if (input === "n" || input === "N" || key.escape) {
|
|
97
|
-
|
|
167
|
+
inputPhase();
|
|
98
168
|
return;
|
|
99
169
|
}
|
|
100
170
|
if (input === "e" || input === "E") {
|
|
101
|
-
setPhase({ type: "input", value: phase.command, histIdx: -1 });
|
|
171
|
+
setPhase({ type: "input", value: phase.command, cursor: phase.command.length, histIdx: -1, raw: false });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// ── explain ───────────────────────────────────────────────────────
|
|
177
|
+
if (phase.type === "explain") {
|
|
178
|
+
if (key.ctrl && input === "c") {
|
|
179
|
+
exit();
|
|
102
180
|
return;
|
|
103
181
|
}
|
|
182
|
+
// any key → back to confirm
|
|
183
|
+
setPhase({ type: "confirm", nl: phase.nl, command: phase.command, raw: false });
|
|
104
184
|
return;
|
|
105
185
|
}
|
|
186
|
+
// ── error ─────────────────────────────────────────────────────────
|
|
106
187
|
if (phase.type === "error") {
|
|
107
188
|
if (key.ctrl && input === "c") {
|
|
108
189
|
exit();
|
|
109
190
|
return;
|
|
110
191
|
}
|
|
111
|
-
|
|
192
|
+
inputPhase();
|
|
112
193
|
return;
|
|
113
194
|
}
|
|
114
195
|
}, [phase, allNl, config, exit]));
|
|
196
|
+
// ── onboarding ─────────────────────────────────────────────────────────────
|
|
115
197
|
if (!config.onboarded) {
|
|
116
198
|
return _jsx(Onboarding, { onDone: finishOnboarding });
|
|
117
199
|
}
|
|
118
|
-
|
|
200
|
+
// ── render ─────────────────────────────────────────────────────────────────
|
|
201
|
+
const isRaw = phase.type === "input" && phase.raw;
|
|
202
|
+
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.output && (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { color: entry.error ? "red" : undefined, children: entry.output }) }))] }, 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 })] }), _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 === "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 })] }), _jsx(Spinner, { label: "running" })] })), 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 })] }));
|
|
119
203
|
}
|
package/dist/Onboarding.js
CHANGED
|
@@ -3,30 +3,30 @@ import { useState } from "react";
|
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
import { DEFAULT_PERMISSIONS } from "./history.js";
|
|
5
5
|
const PERM_KEYS = [
|
|
6
|
-
{ key: "destructive", label: "destructive",
|
|
7
|
-
{ key: "network", label: "network",
|
|
8
|
-
{ key: "sudo", label: "sudo",
|
|
9
|
-
{ key: "install", label: "install",
|
|
10
|
-
{ key: "write_outside_cwd", label: "write outside
|
|
6
|
+
{ key: "destructive", label: "destructive", hint: "rm, delete, drop…" },
|
|
7
|
+
{ key: "network", label: "network", hint: "curl, wget, ssh…" },
|
|
8
|
+
{ key: "sudo", label: "sudo", hint: "root-level commands" },
|
|
9
|
+
{ key: "install", label: "install", hint: "brew, npm -g, pip…" },
|
|
10
|
+
{ key: "write_outside_cwd", label: "write outside", hint: "files outside current dir" },
|
|
11
11
|
];
|
|
12
12
|
export default function Onboarding({ onDone }) {
|
|
13
|
-
const [step, setStep] = useState(
|
|
13
|
+
const [step, setStep] = useState("welcome");
|
|
14
|
+
const [cursor, setCursor] = useState(0);
|
|
14
15
|
const [perms, setPerms] = useState({ ...DEFAULT_PERMISSIONS });
|
|
15
16
|
useInput((input, key) => {
|
|
16
17
|
if (key.ctrl && input === "c")
|
|
17
18
|
process.exit(0);
|
|
18
|
-
if (step
|
|
19
|
-
setStep(
|
|
19
|
+
if (step === "welcome") {
|
|
20
|
+
setStep("permissions");
|
|
20
21
|
return;
|
|
21
22
|
}
|
|
22
|
-
if (step
|
|
23
|
-
const { cursor } = step;
|
|
23
|
+
if (step === "permissions") {
|
|
24
24
|
if (key.upArrow) {
|
|
25
|
-
|
|
25
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
26
26
|
return;
|
|
27
27
|
}
|
|
28
28
|
if (key.downArrow) {
|
|
29
|
-
|
|
29
|
+
setCursor((c) => Math.min(PERM_KEYS.length - 1, c + 1));
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
32
32
|
if (input === " ") {
|
|
@@ -38,18 +38,14 @@ export default function Onboarding({ onDone }) {
|
|
|
38
38
|
onDone(perms);
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
41
|
-
return;
|
|
42
41
|
}
|
|
43
42
|
});
|
|
44
|
-
if (step
|
|
45
|
-
return (_jsxs(Box, { flexDirection: "column",
|
|
46
|
-
}
|
|
47
|
-
if (step.type === "permissions") {
|
|
48
|
-
return (_jsxs(Box, { flexDirection: "column", paddingTop: 1, gap: 1, children: [_jsx(Text, { bold: true, children: "what can the AI do?" }), _jsx(Text, { dimColor: true, children: "space to toggle \u00B7 enter to confirm" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: PERM_KEYS.map((p, i) => {
|
|
49
|
-
const active = step.cursor === i;
|
|
50
|
-
const on = perms[p.key];
|
|
51
|
-
return (_jsxs(Box, { gap: 2, children: [_jsx(Text, { children: active ? "›" : " " }), _jsx(Text, { color: on ? undefined : "red", children: on ? "✓" : "✗" }), _jsx(Text, { bold: active, children: p.label }), _jsx(Text, { dimColor: true, children: p.description })] }, p.key));
|
|
52
|
-
}) }), _jsx(Text, { dimColor: true, children: "you can change this later in ~/.terminal/config.json" })] }));
|
|
43
|
+
if (step === "welcome") {
|
|
44
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, gap: 1, paddingTop: 1, children: [_jsx(Text, { children: "terminal" }), _jsx(Text, { dimColor: true, children: "speak plain english, run commands" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, gap: 0, children: [_jsx(Text, { dimColor: true, children: "\u203A type what you want" }), _jsx(Text, { dimColor: true, children: "$ see the command before it runs" }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 browse history" }), _jsx(Text, { dimColor: true, children: "? explain a command before running" }), _jsx(Text, { dimColor: true, children: "ctrl+r toggle raw shell mode" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "any key to set permissions \u2192" }) })] }));
|
|
53
45
|
}
|
|
54
|
-
return
|
|
46
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, gap: 1, paddingTop: 1, children: [_jsx(Text, { dimColor: true, children: "what can the AI run?" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: PERM_KEYS.map((p, i) => {
|
|
47
|
+
const active = cursor === i;
|
|
48
|
+
const on = perms[p.key];
|
|
49
|
+
return (_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: active ? "›" : " " }), _jsx(Text, { color: on ? undefined : "red", children: on ? "✓" : "✗" }), _jsx(Text, { bold: active, children: p.label }), _jsx(Text, { dimColor: true, children: p.hint })] }, p.key));
|
|
50
|
+
}) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "space toggle \u00B7 enter confirm" }) }), _jsx(Text, { dimColor: true, children: "edit later: ~/.terminal/config.json" })] }));
|
|
55
51
|
}
|
package/dist/Spinner.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from "react";
|
|
3
|
+
import { Text } from "ink";
|
|
4
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
5
|
+
export default function Spinner({ label }) {
|
|
6
|
+
const [frame, setFrame] = useState(0);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const t = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
|
|
9
|
+
return () => clearInterval(t);
|
|
10
|
+
}, []);
|
|
11
|
+
return (_jsxs(Text, { dimColor: true, children: [" ", FRAMES[frame], " ", label] }));
|
|
12
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
function getCwd() {
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
const home = homedir();
|
|
8
|
+
return cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
|
|
9
|
+
}
|
|
10
|
+
function getGitBranch() {
|
|
11
|
+
try {
|
|
12
|
+
return execSync("git branch --show-current 2>/dev/null", { stdio: ["ignore", "pipe", "ignore"] })
|
|
13
|
+
.toString()
|
|
14
|
+
.trim() || null;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function getGitDirty() {
|
|
21
|
+
try {
|
|
22
|
+
const out = execSync("git status --porcelain 2>/dev/null", { stdio: ["ignore", "pipe", "ignore"] }).toString();
|
|
23
|
+
return out.trim().length > 0;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function activePerms(perms) {
|
|
30
|
+
const labels = [
|
|
31
|
+
["destructive", "del"],
|
|
32
|
+
["network", "net"],
|
|
33
|
+
["sudo", "sudo"],
|
|
34
|
+
["install", "pkg"],
|
|
35
|
+
["write_outside_cwd", "write"],
|
|
36
|
+
];
|
|
37
|
+
return labels.filter(([k]) => perms[k]).map(([, l]) => l);
|
|
38
|
+
}
|
|
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(" · ") }))] }));
|
|
45
|
+
}
|
package/dist/ai.js
CHANGED
|
@@ -40,6 +40,18 @@ export function checkPermissions(command, perms) {
|
|
|
40
40
|
return "writing outside cwd is disabled in your permissions";
|
|
41
41
|
return null;
|
|
42
42
|
}
|
|
43
|
+
export async function explainCommand(command) {
|
|
44
|
+
const message = await client.messages.create({
|
|
45
|
+
model: "claude-haiku-4-5-20251001",
|
|
46
|
+
max_tokens: 128,
|
|
47
|
+
system: "Explain what this shell command does in one plain English sentence. No markdown. No code blocks. Just a sentence.",
|
|
48
|
+
messages: [{ role: "user", content: command }],
|
|
49
|
+
});
|
|
50
|
+
const block = message.content[0];
|
|
51
|
+
if (block.type !== "text")
|
|
52
|
+
return "";
|
|
53
|
+
return block.text.trim();
|
|
54
|
+
}
|
|
43
55
|
export async function translateToCommand(nl, perms) {
|
|
44
56
|
const message = await client.messages.create({
|
|
45
57
|
model: "claude-opus-4-6",
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/terminal",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Natural language terminal — speak plain English, get shell commands",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"t": "
|
|
8
|
-
"terminal": "
|
|
7
|
+
"t": "dist/cli.js",
|
|
8
|
+
"terminal": "dist/cli.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "tsc",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|
|
26
|
-
"url": "https://github.com/hasna/terminal.git"
|
|
26
|
+
"url": "git+https://github.com/hasna/terminal.git"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^20.0.0",
|
package/src/App.tsx
CHANGED
|
@@ -2,23 +2,27 @@ import React, { useState, useCallback } from "react";
|
|
|
2
2
|
import { Box, Text, useInput, useApp } from "ink";
|
|
3
3
|
import { exec } from "child_process";
|
|
4
4
|
import { promisify } from "util";
|
|
5
|
-
import { translateToCommand, checkPermissions } from "./ai.js";
|
|
5
|
+
import { translateToCommand, explainCommand, checkPermissions } from "./ai.js";
|
|
6
6
|
import {
|
|
7
7
|
loadHistory,
|
|
8
8
|
appendHistory,
|
|
9
9
|
loadConfig,
|
|
10
10
|
saveConfig,
|
|
11
|
-
type HistoryEntry,
|
|
12
11
|
type Permissions,
|
|
13
12
|
} from "./history.js";
|
|
14
13
|
import Onboarding from "./Onboarding.js";
|
|
14
|
+
import StatusBar from "./StatusBar.js";
|
|
15
|
+
import Spinner from "./Spinner.js";
|
|
15
16
|
|
|
16
17
|
const execAsync = promisify(exec);
|
|
17
18
|
|
|
19
|
+
// ── types ────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
18
21
|
type Phase =
|
|
19
|
-
| { type: "input"; value: string; histIdx: number }
|
|
20
|
-
| { type: "thinking"; nl: string }
|
|
21
|
-
| { type: "confirm"; nl: string; command: string }
|
|
22
|
+
| { type: "input"; value: string; cursor: number; histIdx: number; raw: boolean }
|
|
23
|
+
| { type: "thinking"; nl: string; raw: boolean }
|
|
24
|
+
| { type: "confirm"; nl: string; command: string; raw: boolean }
|
|
25
|
+
| { type: "explain"; nl: string; command: string; explanation: string }
|
|
22
26
|
| { type: "running"; nl: string; command: string }
|
|
23
27
|
| { type: "error"; message: string };
|
|
24
28
|
|
|
@@ -29,15 +33,35 @@ interface ScrollEntry {
|
|
|
29
33
|
error?: boolean;
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
function insertAt(str: string, pos: number, ch: string) {
|
|
39
|
+
return str.slice(0, pos) + ch + str.slice(pos);
|
|
40
|
+
}
|
|
41
|
+
function deleteAt(str: string, pos: number) {
|
|
42
|
+
if (pos <= 0) return str;
|
|
43
|
+
return str.slice(0, pos - 1) + str.slice(pos);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── component ────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
32
48
|
export default function App() {
|
|
33
49
|
const { exit } = useApp();
|
|
34
50
|
const [config, setConfig] = useState(() => loadConfig());
|
|
35
51
|
const [nlHistory] = useState<string[]>(() =>
|
|
36
|
-
loadHistory()
|
|
52
|
+
loadHistory()
|
|
53
|
+
.map((h) => h.nl)
|
|
54
|
+
.filter(Boolean)
|
|
37
55
|
);
|
|
38
56
|
const [sessionNl, setSessionNl] = useState<string[]>([]);
|
|
39
57
|
const [scroll, setScroll] = useState<ScrollEntry[]>([]);
|
|
40
|
-
const [phase, setPhase] = useState<Phase>({
|
|
58
|
+
const [phase, setPhase] = useState<Phase>({
|
|
59
|
+
type: "input",
|
|
60
|
+
value: "",
|
|
61
|
+
cursor: 0,
|
|
62
|
+
histIdx: -1,
|
|
63
|
+
raw: false,
|
|
64
|
+
});
|
|
41
65
|
|
|
42
66
|
const allNl = [...nlHistory, ...sessionNl];
|
|
43
67
|
|
|
@@ -49,56 +73,115 @@ export default function App() {
|
|
|
49
73
|
|
|
50
74
|
const pushScroll = (entry: ScrollEntry) => setScroll((s) => [...s, entry]);
|
|
51
75
|
|
|
76
|
+
const inputPhase = (overrides: Partial<Extract<Phase, { type: "input" }>> = {}) =>
|
|
77
|
+
setPhase({ type: "input", value: "", cursor: 0, histIdx: -1, raw: false, ...overrides });
|
|
78
|
+
|
|
52
79
|
useInput(
|
|
53
80
|
useCallback(
|
|
54
81
|
async (input: string, key: any) => {
|
|
82
|
+
// ── input ─────────────────────────────────────────────────────────
|
|
55
83
|
if (phase.type === "input") {
|
|
56
84
|
if (key.ctrl && input === "c") { exit(); return; }
|
|
57
85
|
|
|
86
|
+
// toggle raw mode
|
|
87
|
+
if (key.ctrl && input === "r") {
|
|
88
|
+
setPhase({ ...phase, raw: !phase.raw, value: "", cursor: 0 });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// history navigation
|
|
58
93
|
if (key.upArrow) {
|
|
59
94
|
const nextIdx = Math.min(phase.histIdx + 1, allNl.length - 1);
|
|
60
95
|
const val = allNl[allNl.length - 1 - nextIdx] ?? "";
|
|
61
|
-
setPhase({
|
|
96
|
+
setPhase({ ...phase, value: val, cursor: val.length, histIdx: nextIdx });
|
|
62
97
|
return;
|
|
63
98
|
}
|
|
64
99
|
if (key.downArrow) {
|
|
65
100
|
const nextIdx = Math.max(phase.histIdx - 1, -1);
|
|
66
101
|
const val = nextIdx === -1 ? "" : allNl[allNl.length - 1 - nextIdx] ?? "";
|
|
67
|
-
setPhase({
|
|
102
|
+
setPhase({ ...phase, value: val, cursor: val.length, histIdx: nextIdx });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// cursor movement
|
|
107
|
+
if (key.leftArrow) {
|
|
108
|
+
setPhase({ ...phase, cursor: Math.max(0, phase.cursor - 1) });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (key.rightArrow) {
|
|
112
|
+
setPhase({ ...phase, cursor: Math.min(phase.value.length, phase.cursor + 1) });
|
|
68
113
|
return;
|
|
69
114
|
}
|
|
115
|
+
|
|
116
|
+
// submit
|
|
70
117
|
if (key.return) {
|
|
71
118
|
const nl = phase.value.trim();
|
|
72
119
|
if (!nl) return;
|
|
73
120
|
setSessionNl((h) => [...h, nl]);
|
|
74
|
-
|
|
121
|
+
|
|
122
|
+
if (phase.raw) {
|
|
123
|
+
// raw mode — run directly
|
|
124
|
+
setPhase({ type: "running", nl, command: nl });
|
|
125
|
+
try {
|
|
126
|
+
const { stdout, stderr } = await execAsync(nl, { shell: "/bin/zsh" });
|
|
127
|
+
const output = (stdout + stderr).trim();
|
|
128
|
+
pushScroll({ nl, cmd: nl, output });
|
|
129
|
+
appendHistory({ nl, cmd: nl, output, ts: Date.now() });
|
|
130
|
+
} catch (e: any) {
|
|
131
|
+
const output = ((e.stdout ?? "") + (e.stderr ?? "")).trim() || e.message;
|
|
132
|
+
pushScroll({ nl, cmd: nl, output, error: true });
|
|
133
|
+
appendHistory({ nl, cmd: nl, output, ts: Date.now(), error: true });
|
|
134
|
+
}
|
|
135
|
+
inputPhase({ raw: true });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
setPhase({ type: "thinking", nl, raw: false });
|
|
75
140
|
try {
|
|
76
141
|
const command = await translateToCommand(nl, config.permissions);
|
|
77
|
-
// Local permission guard on the returned command
|
|
78
142
|
const blocked = checkPermissions(command, config.permissions);
|
|
79
143
|
if (blocked) {
|
|
80
144
|
pushScroll({ nl, cmd: command, output: `blocked: ${blocked}`, error: true });
|
|
81
|
-
|
|
145
|
+
inputPhase();
|
|
82
146
|
return;
|
|
83
147
|
}
|
|
84
|
-
setPhase({ type: "confirm", nl, command });
|
|
148
|
+
setPhase({ type: "confirm", nl, command, raw: false });
|
|
85
149
|
} catch (e: any) {
|
|
86
150
|
setPhase({ type: "error", message: e.message });
|
|
87
151
|
}
|
|
88
152
|
return;
|
|
89
153
|
}
|
|
154
|
+
|
|
90
155
|
if (key.backspace || key.delete) {
|
|
91
|
-
|
|
156
|
+
const val = deleteAt(phase.value, phase.cursor);
|
|
157
|
+
setPhase({ ...phase, value: val, cursor: Math.max(0, phase.cursor - 1), histIdx: -1 });
|
|
92
158
|
return;
|
|
93
159
|
}
|
|
160
|
+
|
|
94
161
|
if (input && !key.ctrl && !key.meta) {
|
|
95
|
-
|
|
162
|
+
const val = insertAt(phase.value, phase.cursor, input);
|
|
163
|
+
setPhase({ ...phase, value: val, cursor: phase.cursor + 1, histIdx: -1 });
|
|
96
164
|
}
|
|
97
165
|
return;
|
|
98
166
|
}
|
|
99
167
|
|
|
168
|
+
// ── confirm ───────────────────────────────────────────────────────
|
|
100
169
|
if (phase.type === "confirm") {
|
|
101
170
|
if (key.ctrl && input === "c") { exit(); return; }
|
|
171
|
+
|
|
172
|
+
// explain
|
|
173
|
+
if (input === "?") {
|
|
174
|
+
const { nl, command } = phase;
|
|
175
|
+
setPhase({ type: "thinking", nl, raw: false });
|
|
176
|
+
try {
|
|
177
|
+
const explanation = await explainCommand(command);
|
|
178
|
+
setPhase({ type: "explain", nl, command, explanation });
|
|
179
|
+
} catch {
|
|
180
|
+
setPhase({ type: "confirm", nl, command, raw: false });
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
102
185
|
if (input === "y" || input === "Y" || key.return) {
|
|
103
186
|
const { nl, command } = phase;
|
|
104
187
|
setPhase({ type: "running", nl, command });
|
|
@@ -107,29 +190,35 @@ export default function App() {
|
|
|
107
190
|
const output = (stdout + stderr).trim();
|
|
108
191
|
pushScroll({ nl, cmd: command, output });
|
|
109
192
|
appendHistory({ nl, cmd: command, output, ts: Date.now() });
|
|
110
|
-
|
|
193
|
+
inputPhase();
|
|
111
194
|
} catch (e: any) {
|
|
112
195
|
const output = ((e.stdout ?? "") + (e.stderr ?? "")).trim() || e.message;
|
|
113
196
|
pushScroll({ nl, cmd: command, output, error: true });
|
|
114
197
|
appendHistory({ nl, cmd: command, output, ts: Date.now(), error: true });
|
|
115
|
-
|
|
198
|
+
inputPhase();
|
|
116
199
|
}
|
|
117
200
|
return;
|
|
118
201
|
}
|
|
119
|
-
if (input === "n" || input === "N" || key.escape) {
|
|
120
|
-
setPhase({ type: "input", value: "", histIdx: -1 });
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
202
|
+
if (input === "n" || input === "N" || key.escape) { inputPhase(); return; }
|
|
123
203
|
if (input === "e" || input === "E") {
|
|
124
|
-
setPhase({ type: "input", value: phase.command, histIdx: -1 });
|
|
204
|
+
setPhase({ type: "input", value: phase.command, cursor: phase.command.length, histIdx: -1, raw: false });
|
|
125
205
|
return;
|
|
126
206
|
}
|
|
127
207
|
return;
|
|
128
208
|
}
|
|
129
209
|
|
|
210
|
+
// ── explain ───────────────────────────────────────────────────────
|
|
211
|
+
if (phase.type === "explain") {
|
|
212
|
+
if (key.ctrl && input === "c") { exit(); return; }
|
|
213
|
+
// any key → back to confirm
|
|
214
|
+
setPhase({ type: "confirm", nl: phase.nl, command: phase.command, raw: false });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── error ─────────────────────────────────────────────────────────
|
|
130
219
|
if (phase.type === "error") {
|
|
131
220
|
if (key.ctrl && input === "c") { exit(); return; }
|
|
132
|
-
|
|
221
|
+
inputPhase();
|
|
133
222
|
return;
|
|
134
223
|
}
|
|
135
224
|
},
|
|
@@ -137,76 +226,101 @@ export default function App() {
|
|
|
137
226
|
)
|
|
138
227
|
);
|
|
139
228
|
|
|
229
|
+
// ── onboarding ─────────────────────────────────────────────────────────────
|
|
140
230
|
if (!config.onboarded) {
|
|
141
231
|
return <Onboarding onDone={finishOnboarding} />;
|
|
142
232
|
}
|
|
143
233
|
|
|
234
|
+
// ── render ─────────────────────────────────────────────────────────────────
|
|
235
|
+
const isRaw = phase.type === "input" && phase.raw;
|
|
236
|
+
|
|
144
237
|
return (
|
|
145
238
|
<Box flexDirection="column">
|
|
239
|
+
|
|
240
|
+
{/* scrollback */}
|
|
146
241
|
{scroll.map((entry, i) => (
|
|
147
|
-
<Box key={i} flexDirection="column" marginBottom={1}>
|
|
148
|
-
<Box gap={
|
|
242
|
+
<Box key={i} flexDirection="column" marginBottom={1} paddingLeft={2}>
|
|
243
|
+
<Box gap={2}>
|
|
149
244
|
<Text dimColor>›</Text>
|
|
150
245
|
<Text dimColor>{entry.nl}</Text>
|
|
151
246
|
</Box>
|
|
152
|
-
|
|
153
|
-
<
|
|
154
|
-
|
|
155
|
-
|
|
247
|
+
{entry.nl !== entry.cmd && (
|
|
248
|
+
<Box gap={2} paddingLeft={2}>
|
|
249
|
+
<Text dimColor>$</Text>
|
|
250
|
+
<Text dimColor>{entry.cmd}</Text>
|
|
251
|
+
</Box>
|
|
252
|
+
)}
|
|
156
253
|
{entry.output && (
|
|
157
|
-
<
|
|
254
|
+
<Box paddingLeft={4}>
|
|
255
|
+
<Text color={entry.error ? "red" : undefined}>{entry.output}</Text>
|
|
256
|
+
</Box>
|
|
158
257
|
)}
|
|
159
258
|
</Box>
|
|
160
259
|
))}
|
|
161
260
|
|
|
162
|
-
{
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
<
|
|
166
|
-
<Text inverse> </Text>
|
|
167
|
-
</Box>
|
|
168
|
-
)}
|
|
169
|
-
|
|
170
|
-
{phase.type === "thinking" && (
|
|
171
|
-
<Box flexDirection="column">
|
|
172
|
-
<Box gap={1}>
|
|
261
|
+
{/* confirm */}
|
|
262
|
+
{phase.type === "confirm" && (
|
|
263
|
+
<Box flexDirection="column" marginBottom={1} paddingLeft={2}>
|
|
264
|
+
<Box gap={2}>
|
|
173
265
|
<Text dimColor>›</Text>
|
|
174
266
|
<Text dimColor>{phase.nl}</Text>
|
|
175
267
|
</Box>
|
|
176
|
-
<
|
|
268
|
+
<Box gap={2} paddingLeft={2}>
|
|
269
|
+
<Text dimColor>$</Text>
|
|
270
|
+
<Text>{phase.command}</Text>
|
|
271
|
+
</Box>
|
|
272
|
+
<Box paddingLeft={4}><Text dimColor>enter n e ?</Text></Box>
|
|
177
273
|
</Box>
|
|
178
274
|
)}
|
|
179
275
|
|
|
180
|
-
{
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
<Text dimColor>{phase.nl}</Text>
|
|
185
|
-
</Box>
|
|
186
|
-
<Box gap={1}>
|
|
276
|
+
{/* explain */}
|
|
277
|
+
{phase.type === "explain" && (
|
|
278
|
+
<Box flexDirection="column" marginBottom={1} paddingLeft={2}>
|
|
279
|
+
<Box gap={2} paddingLeft={2}>
|
|
187
280
|
<Text dimColor>$</Text>
|
|
188
281
|
<Text>{phase.command}</Text>
|
|
189
282
|
</Box>
|
|
190
|
-
<
|
|
283
|
+
<Box paddingLeft={4}>
|
|
284
|
+
<Text dimColor>{phase.explanation}</Text>
|
|
285
|
+
</Box>
|
|
286
|
+
<Box paddingLeft={4}><Text dimColor>any key to continue</Text></Box>
|
|
191
287
|
</Box>
|
|
192
288
|
)}
|
|
193
289
|
|
|
290
|
+
{/* spinner states */}
|
|
291
|
+
{phase.type === "thinking" && <Spinner label="translating" />}
|
|
194
292
|
{phase.type === "running" && (
|
|
195
|
-
<Box flexDirection="column">
|
|
196
|
-
<Box gap={
|
|
293
|
+
<Box flexDirection="column" paddingLeft={2}>
|
|
294
|
+
<Box gap={2}>
|
|
197
295
|
<Text dimColor>$</Text>
|
|
198
|
-
<Text>{phase.command}</Text>
|
|
296
|
+
<Text dimColor>{phase.command}</Text>
|
|
199
297
|
</Box>
|
|
200
|
-
<
|
|
298
|
+
<Spinner label="running" />
|
|
201
299
|
</Box>
|
|
202
300
|
)}
|
|
203
301
|
|
|
302
|
+
{/* error */}
|
|
204
303
|
{phase.type === "error" && (
|
|
205
|
-
<Box
|
|
206
|
-
<Text color="red">
|
|
207
|
-
<Text dimColor> press any key</Text>
|
|
304
|
+
<Box paddingLeft={2}>
|
|
305
|
+
<Text color="red">{phase.message}</Text>
|
|
208
306
|
</Box>
|
|
209
307
|
)}
|
|
308
|
+
|
|
309
|
+
{/* input line */}
|
|
310
|
+
{phase.type === "input" && (
|
|
311
|
+
<Box gap={2} paddingLeft={2}>
|
|
312
|
+
<Text dimColor>{isRaw ? "$" : "›"}</Text>
|
|
313
|
+
<Box>
|
|
314
|
+
<Text>{phase.value.slice(0, phase.cursor)}</Text>
|
|
315
|
+
<Text inverse>{phase.value[phase.cursor] ?? " "}</Text>
|
|
316
|
+
<Text>{phase.value.slice(phase.cursor + 1)}</Text>
|
|
317
|
+
</Box>
|
|
318
|
+
</Box>
|
|
319
|
+
)}
|
|
320
|
+
|
|
321
|
+
{/* status bar */}
|
|
322
|
+
<StatusBar permissions={config.permissions} />
|
|
323
|
+
|
|
210
324
|
</Box>
|
|
211
325
|
);
|
|
212
326
|
}
|
package/src/Onboarding.tsx
CHANGED
|
@@ -6,89 +6,77 @@ interface Props {
|
|
|
6
6
|
onDone: (perms: Permissions) => void;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
type Step =
|
|
10
|
-
| { type: "welcome" }
|
|
11
|
-
| { type: "permissions"; cursor: number }
|
|
12
|
-
| { type: "done" };
|
|
9
|
+
type Step = "welcome" | "permissions";
|
|
13
10
|
|
|
14
|
-
const PERM_KEYS: Array<{ key: keyof Permissions; label: string;
|
|
15
|
-
{ key: "destructive",
|
|
16
|
-
{ key: "network",
|
|
17
|
-
{ key: "sudo",
|
|
18
|
-
{ key: "install",
|
|
19
|
-
{ key: "write_outside_cwd", label: "write outside
|
|
11
|
+
const PERM_KEYS: Array<{ key: keyof Permissions; label: string; hint: string }> = [
|
|
12
|
+
{ key: "destructive", label: "destructive", hint: "rm, delete, drop…" },
|
|
13
|
+
{ key: "network", label: "network", hint: "curl, wget, ssh…" },
|
|
14
|
+
{ key: "sudo", label: "sudo", hint: "root-level commands" },
|
|
15
|
+
{ key: "install", label: "install", hint: "brew, npm -g, pip…" },
|
|
16
|
+
{ key: "write_outside_cwd", label: "write outside", hint: "files outside current dir" },
|
|
20
17
|
];
|
|
21
18
|
|
|
22
19
|
export default function Onboarding({ onDone }: Props) {
|
|
23
|
-
const [step, setStep] = useState<Step>(
|
|
20
|
+
const [step, setStep] = useState<Step>("welcome");
|
|
21
|
+
const [cursor, setCursor] = useState(0);
|
|
24
22
|
const [perms, setPerms] = useState<Permissions>({ ...DEFAULT_PERMISSIONS });
|
|
25
23
|
|
|
26
24
|
useInput((input, key) => {
|
|
27
25
|
if (key.ctrl && input === "c") process.exit(0);
|
|
28
26
|
|
|
29
|
-
if (step
|
|
30
|
-
setStep(
|
|
27
|
+
if (step === "welcome") {
|
|
28
|
+
setStep("permissions");
|
|
31
29
|
return;
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
if (step
|
|
35
|
-
|
|
36
|
-
if (key.
|
|
37
|
-
setStep({ type: "permissions", cursor: Math.max(0, cursor - 1) });
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
if (key.downArrow) {
|
|
41
|
-
setStep({ type: "permissions", cursor: Math.min(PERM_KEYS.length - 1, cursor + 1) });
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
32
|
+
if (step === "permissions") {
|
|
33
|
+
if (key.upArrow) { setCursor((c) => Math.max(0, c - 1)); return; }
|
|
34
|
+
if (key.downArrow) { setCursor((c) => Math.min(PERM_KEYS.length - 1, c + 1)); return; }
|
|
44
35
|
if (input === " ") {
|
|
45
36
|
const k = PERM_KEYS[cursor].key;
|
|
46
37
|
setPerms((p) => ({ ...p, [k]: !p[k] }));
|
|
47
38
|
return;
|
|
48
39
|
}
|
|
49
|
-
if (key.return) {
|
|
50
|
-
onDone(perms);
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
return;
|
|
40
|
+
if (key.return) { onDone(perms); return; }
|
|
54
41
|
}
|
|
55
42
|
});
|
|
56
43
|
|
|
57
|
-
if (step
|
|
44
|
+
if (step === "welcome") {
|
|
58
45
|
return (
|
|
59
|
-
<Box flexDirection="column"
|
|
60
|
-
<Text
|
|
61
|
-
<Text>
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (step.type === "permissions") {
|
|
70
|
-
return (
|
|
71
|
-
<Box flexDirection="column" paddingTop={1} gap={1}>
|
|
72
|
-
<Text bold>what can the AI do?</Text>
|
|
73
|
-
<Text dimColor>space to toggle · enter to confirm</Text>
|
|
74
|
-
<Box flexDirection="column" marginTop={1}>
|
|
75
|
-
{PERM_KEYS.map((p, i) => {
|
|
76
|
-
const active = step.cursor === i;
|
|
77
|
-
const on = perms[p.key];
|
|
78
|
-
return (
|
|
79
|
-
<Box key={p.key} gap={2}>
|
|
80
|
-
<Text>{active ? "›" : " "}</Text>
|
|
81
|
-
<Text color={on ? undefined : "red"}>{on ? "✓" : "✗"}</Text>
|
|
82
|
-
<Text bold={active}>{p.label}</Text>
|
|
83
|
-
<Text dimColor>{p.description}</Text>
|
|
84
|
-
</Box>
|
|
85
|
-
);
|
|
86
|
-
})}
|
|
46
|
+
<Box flexDirection="column" paddingLeft={2} gap={1} paddingTop={1}>
|
|
47
|
+
<Text>terminal</Text>
|
|
48
|
+
<Text dimColor>speak plain english, run commands</Text>
|
|
49
|
+
<Box flexDirection="column" marginTop={1} gap={0}>
|
|
50
|
+
<Text dimColor>› type what you want</Text>
|
|
51
|
+
<Text dimColor>$ see the command before it runs</Text>
|
|
52
|
+
<Text dimColor>↑↓ browse history</Text>
|
|
53
|
+
<Text dimColor>? explain a command before running</Text>
|
|
54
|
+
<Text dimColor>ctrl+r toggle raw shell mode</Text>
|
|
87
55
|
</Box>
|
|
88
|
-
<Text dimColor>
|
|
56
|
+
<Box marginTop={1}><Text dimColor>any key to set permissions →</Text></Box>
|
|
89
57
|
</Box>
|
|
90
58
|
);
|
|
91
59
|
}
|
|
92
60
|
|
|
93
|
-
return
|
|
61
|
+
return (
|
|
62
|
+
<Box flexDirection="column" paddingLeft={2} gap={1} paddingTop={1}>
|
|
63
|
+
<Text dimColor>what can the AI run?</Text>
|
|
64
|
+
<Box flexDirection="column" marginTop={1}>
|
|
65
|
+
{PERM_KEYS.map((p, i) => {
|
|
66
|
+
const active = cursor === i;
|
|
67
|
+
const on = perms[p.key];
|
|
68
|
+
return (
|
|
69
|
+
<Box key={p.key} gap={2}>
|
|
70
|
+
<Text dimColor>{active ? "›" : " "}</Text>
|
|
71
|
+
<Text color={on ? undefined : "red"}>{on ? "✓" : "✗"}</Text>
|
|
72
|
+
<Text bold={active}>{p.label}</Text>
|
|
73
|
+
<Text dimColor>{p.hint}</Text>
|
|
74
|
+
</Box>
|
|
75
|
+
);
|
|
76
|
+
})}
|
|
77
|
+
</Box>
|
|
78
|
+
<Box marginTop={1}><Text dimColor>space toggle · enter confirm</Text></Box>
|
|
79
|
+
<Text dimColor>edit later: ~/.terminal/config.json</Text>
|
|
80
|
+
</Box>
|
|
81
|
+
);
|
|
94
82
|
}
|
package/src/Spinner.tsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
|
|
4
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function Spinner({ label }: Props) {
|
|
11
|
+
const [frame, setFrame] = useState(0);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const t = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
|
|
15
|
+
return () => clearInterval(t);
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Text dimColor>
|
|
20
|
+
{" "}
|
|
21
|
+
{FRAMES[frame]} {label}
|
|
22
|
+
</Text>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { type Permissions } from "./history.js";
|
|
6
|
+
|
|
7
|
+
function getCwd(): string {
|
|
8
|
+
const cwd = process.cwd();
|
|
9
|
+
const home = homedir();
|
|
10
|
+
return cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getGitBranch(): string | null {
|
|
14
|
+
try {
|
|
15
|
+
return execSync("git branch --show-current 2>/dev/null", { stdio: ["ignore", "pipe", "ignore"] })
|
|
16
|
+
.toString()
|
|
17
|
+
.trim() || null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getGitDirty(): boolean {
|
|
24
|
+
try {
|
|
25
|
+
const out = execSync("git status --porcelain 2>/dev/null", { stdio: ["ignore", "pipe", "ignore"] }).toString();
|
|
26
|
+
return out.trim().length > 0;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function activePerms(perms: Permissions): string[] {
|
|
33
|
+
const labels: Array<[keyof Permissions, string]> = [
|
|
34
|
+
["destructive", "del"],
|
|
35
|
+
["network", "net"],
|
|
36
|
+
["sudo", "sudo"],
|
|
37
|
+
["install", "pkg"],
|
|
38
|
+
["write_outside_cwd", "write"],
|
|
39
|
+
];
|
|
40
|
+
return labels.filter(([k]) => perms[k]).map(([, l]) => l);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Props {
|
|
44
|
+
permissions: Permissions;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function StatusBar({ permissions }: Props) {
|
|
48
|
+
const cwd = getCwd();
|
|
49
|
+
const branch = getGitBranch();
|
|
50
|
+
const dirty = branch ? getGitDirty() : false;
|
|
51
|
+
const perms = activePerms(permissions);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Box gap={2} paddingLeft={2} marginTop={1}>
|
|
55
|
+
<Text dimColor>{cwd}</Text>
|
|
56
|
+
{branch && (
|
|
57
|
+
<Text dimColor>
|
|
58
|
+
{branch}
|
|
59
|
+
{dirty ? " ●" : ""}
|
|
60
|
+
</Text>
|
|
61
|
+
)}
|
|
62
|
+
{perms.length > 0 && (
|
|
63
|
+
<Text dimColor>{perms.join(" · ")}</Text>
|
|
64
|
+
)}
|
|
65
|
+
</Box>
|
|
66
|
+
);
|
|
67
|
+
}
|
package/src/ai.ts
CHANGED
|
@@ -49,6 +49,18 @@ export function checkPermissions(command: string, perms: Permissions): string |
|
|
|
49
49
|
return null;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export async function explainCommand(command: string): Promise<string> {
|
|
53
|
+
const message = await client.messages.create({
|
|
54
|
+
model: "claude-haiku-4-5-20251001",
|
|
55
|
+
max_tokens: 128,
|
|
56
|
+
system: "Explain what this shell command does in one plain English sentence. No markdown. No code blocks. Just a sentence.",
|
|
57
|
+
messages: [{ role: "user", content: command }],
|
|
58
|
+
});
|
|
59
|
+
const block = message.content[0];
|
|
60
|
+
if (block.type !== "text") return "";
|
|
61
|
+
return block.text.trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
52
64
|
export async function translateToCommand(nl: string, perms: Permissions): Promise<string> {
|
|
53
65
|
const message = await client.messages.create({
|
|
54
66
|
model: "claude-opus-4-6",
|