@hasna/terminal 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/App.js CHANGED
@@ -1,42 +1,125 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useCallback } from "react";
2
+ import { useState, useCallback, useRef } from "react";
3
3
  import { Box, Text, useInput, useApp } from "ink";
4
- import { exec } from "child_process";
5
- import { promisify } from "util";
6
- import { translateToCommand, checkPermissions } from "./ai.js";
4
+ import { spawn } from "child_process";
5
+ import { translateToCommand, explainCommand, fixCommand, checkPermissions, isIrreversible } from "./ai.js";
7
6
  import { loadHistory, appendHistory, loadConfig, saveConfig, } from "./history.js";
8
7
  import Onboarding from "./Onboarding.js";
9
- const execAsync = promisify(exec);
8
+ import StatusBar from "./StatusBar.js";
9
+ import Spinner from "./Spinner.js";
10
+ const MAX_LINES = 20;
11
+ // ── helpers ───────────────────────────────────────────────────────────────────
12
+ function insertAt(s, pos, ch) { return s.slice(0, pos) + ch + s.slice(pos); }
13
+ function deleteAt(s, pos) { return pos <= 0 ? s : s.slice(0, pos - 1) + s.slice(pos); }
14
+ function runCommand(command, onLine, onDone, signal) {
15
+ const proc = spawn("/bin/zsh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"] });
16
+ const handleData = (data) => {
17
+ const text = data.toString();
18
+ text.split("\n").forEach((line) => { if (line)
19
+ onLine(line); });
20
+ };
21
+ proc.stdout?.on("data", handleData);
22
+ proc.stderr?.on("data", handleData);
23
+ proc.on("close", (code) => onDone(code ?? 0));
24
+ signal.addEventListener("abort", () => { try {
25
+ proc.kill("SIGTERM");
26
+ }
27
+ catch { } });
28
+ return proc;
29
+ }
30
+ // ── component ─────────────────────────────────────────────────────────────────
10
31
  export default function App() {
11
32
  const { exit } = useApp();
12
33
  const [config, setConfig] = useState(() => loadConfig());
13
34
  const [nlHistory] = useState(() => loadHistory().map((h) => h.nl).filter(Boolean));
35
+ const [sessionCmds, setSessionCmds] = useState([]);
14
36
  const [sessionNl, setSessionNl] = useState([]);
15
37
  const [scroll, setScroll] = useState([]);
16
- const [phase, setPhase] = useState({ type: "input", value: "", histIdx: -1 });
38
+ const [streamLines, setStreamLines] = useState([]);
39
+ const abortRef = useRef(null);
40
+ const [phase, setPhase] = useState({
41
+ type: "input", value: "", cursor: 0, histIdx: -1, raw: false,
42
+ });
17
43
  const allNl = [...nlHistory, ...sessionNl];
18
44
  const finishOnboarding = (perms) => {
19
45
  const next = { onboarded: true, permissions: perms };
20
46
  setConfig(next);
21
47
  saveConfig(next);
22
48
  };
23
- const pushScroll = (entry) => setScroll((s) => [...s, entry]);
49
+ const inputPhase = (overrides = {}) => {
50
+ setPhase({ type: "input", value: "", cursor: 0, histIdx: -1, raw: false, ...overrides });
51
+ setStreamLines([]);
52
+ };
53
+ const pushScroll = (entry) => setScroll((s) => [...s, { ...entry, expanded: false }]);
54
+ const commitStream = (nl, cmd, lines, error) => {
55
+ const truncated = lines.length > MAX_LINES;
56
+ pushScroll({ nl, cmd, lines: truncated ? lines.slice(0, MAX_LINES) : lines, truncated, error });
57
+ appendHistory({ nl, cmd, output: lines.join("\n"), ts: Date.now(), error });
58
+ setSessionCmds((c) => [...c.slice(-9), cmd]);
59
+ setStreamLines([]);
60
+ };
61
+ const runPhase = async (nl, command, raw) => {
62
+ setPhase({ type: "running", nl, command });
63
+ setStreamLines([]);
64
+ const abort = new AbortController();
65
+ abortRef.current = abort;
66
+ const lines = [];
67
+ await new Promise((resolve) => {
68
+ runCommand(command, (line) => { lines.push(line); setStreamLines([...lines]); }, (code) => {
69
+ commitStream(nl, command, lines, code !== 0);
70
+ abortRef.current = null;
71
+ if (code !== 0 && !raw) {
72
+ // offer auto-fix
73
+ setPhase({ type: "autofix", nl, command, errorOutput: lines.join("\n") });
74
+ }
75
+ else {
76
+ inputPhase({ raw });
77
+ }
78
+ resolve();
79
+ }, abort.signal);
80
+ });
81
+ };
24
82
  useInput(useCallback(async (input, key) => {
83
+ // ── running: Ctrl+C kills process ─────────────────────────────────
84
+ if (phase.type === "running") {
85
+ if (key.ctrl && input === "c") {
86
+ abortRef.current?.abort();
87
+ inputPhase();
88
+ }
89
+ return;
90
+ }
91
+ // ── input ─────────────────────────────────────────────────────────
25
92
  if (phase.type === "input") {
26
93
  if (key.ctrl && input === "c") {
27
94
  exit();
28
95
  return;
29
96
  }
97
+ if (key.ctrl && input === "l") {
98
+ setScroll([]);
99
+ return;
100
+ }
101
+ if (key.ctrl && input === "r") {
102
+ setPhase({ ...phase, raw: !phase.raw, value: "", cursor: 0 });
103
+ return;
104
+ }
30
105
  if (key.upArrow) {
31
- const nextIdx = Math.min(phase.histIdx + 1, allNl.length - 1);
32
- const val = allNl[allNl.length - 1 - nextIdx] ?? "";
33
- setPhase({ type: "input", value: val, histIdx: nextIdx });
106
+ const idx = Math.min(phase.histIdx + 1, allNl.length - 1);
107
+ const val = allNl[allNl.length - 1 - idx] ?? "";
108
+ setPhase({ ...phase, value: val, cursor: val.length, histIdx: idx });
34
109
  return;
35
110
  }
36
111
  if (key.downArrow) {
37
- const nextIdx = Math.max(phase.histIdx - 1, -1);
38
- const val = nextIdx === -1 ? "" : allNl[allNl.length - 1 - nextIdx] ?? "";
39
- setPhase({ type: "input", value: val, histIdx: nextIdx });
112
+ const idx = Math.max(phase.histIdx - 1, -1);
113
+ const val = idx === -1 ? "" : allNl[allNl.length - 1 - idx] ?? "";
114
+ setPhase({ ...phase, value: val, cursor: val.length, histIdx: idx });
115
+ return;
116
+ }
117
+ if (key.leftArrow) {
118
+ setPhase({ ...phase, cursor: Math.max(0, phase.cursor - 1) });
119
+ return;
120
+ }
121
+ if (key.rightArrow) {
122
+ setPhase({ ...phase, cursor: Math.min(phase.value.length, phase.cursor + 1) });
40
123
  return;
41
124
  }
42
125
  if (key.return) {
@@ -44,17 +127,21 @@ export default function App() {
44
127
  if (!nl)
45
128
  return;
46
129
  setSessionNl((h) => [...h, nl]);
47
- setPhase({ type: "thinking", nl });
130
+ if (phase.raw) {
131
+ await runPhase(nl, nl, true);
132
+ return;
133
+ }
134
+ setPhase({ type: "thinking", nl, raw: false });
48
135
  try {
49
- const command = await translateToCommand(nl, config.permissions);
50
- // Local permission guard on the returned command
136
+ const command = await translateToCommand(nl, config.permissions, sessionCmds);
51
137
  const blocked = checkPermissions(command, config.permissions);
52
138
  if (blocked) {
53
- pushScroll({ nl, cmd: command, output: `blocked: ${blocked}`, error: true });
54
- setPhase({ type: "input", value: "", histIdx: -1 });
139
+ pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
140
+ inputPhase();
55
141
  return;
56
142
  }
57
- setPhase({ type: "confirm", nl, command });
143
+ const danger = isIrreversible(command);
144
+ setPhase({ type: "confirm", nl, command, raw: false, danger });
58
145
  }
59
146
  catch (e) {
60
147
  setPhase({ type: "error", message: e.message });
@@ -62,58 +149,96 @@ export default function App() {
62
149
  return;
63
150
  }
64
151
  if (key.backspace || key.delete) {
65
- setPhase({ ...phase, value: phase.value.slice(0, -1), histIdx: -1 });
152
+ const val = deleteAt(phase.value, phase.cursor);
153
+ setPhase({ ...phase, value: val, cursor: Math.max(0, phase.cursor - 1), histIdx: -1 });
66
154
  return;
67
155
  }
68
156
  if (input && !key.ctrl && !key.meta) {
69
- setPhase({ ...phase, value: phase.value + input, histIdx: -1 });
157
+ const val = insertAt(phase.value, phase.cursor, input);
158
+ setPhase({ ...phase, value: val, cursor: phase.cursor + 1, histIdx: -1 });
70
159
  }
71
160
  return;
72
161
  }
162
+ // ── confirm ───────────────────────────────────────────────────────
73
163
  if (phase.type === "confirm") {
74
164
  if (key.ctrl && input === "c") {
75
165
  exit();
76
166
  return;
77
167
  }
78
- if (input === "y" || input === "Y" || key.return) {
168
+ if (input === "?") {
79
169
  const { nl, command } = phase;
80
- setPhase({ type: "running", nl, command });
170
+ setPhase({ type: "thinking", nl, raw: false });
81
171
  try {
82
- const { stdout, stderr } = await execAsync(command, { shell: "/bin/zsh" });
83
- const output = (stdout + stderr).trim();
84
- pushScroll({ nl, cmd: command, output });
85
- appendHistory({ nl, cmd: command, output, ts: Date.now() });
86
- setPhase({ type: "input", value: "", histIdx: -1 });
172
+ const explanation = await explainCommand(command);
173
+ setPhase({ type: "explain", nl, command, explanation });
87
174
  }
88
- catch (e) {
89
- const output = ((e.stdout ?? "") + (e.stderr ?? "")).trim() || e.message;
90
- pushScroll({ nl, cmd: command, output, error: true });
91
- appendHistory({ nl, cmd: command, output, ts: Date.now(), error: true });
92
- setPhase({ type: "input", value: "", histIdx: -1 });
175
+ catch {
176
+ setPhase({ type: "confirm", nl, command, raw: false, danger: phase.danger });
93
177
  }
94
178
  return;
95
179
  }
180
+ if (input === "y" || input === "Y" || key.return) {
181
+ await runPhase(phase.nl, phase.command, false);
182
+ return;
183
+ }
96
184
  if (input === "n" || input === "N" || key.escape) {
97
- setPhase({ type: "input", value: "", histIdx: -1 });
185
+ inputPhase();
98
186
  return;
99
187
  }
100
188
  if (input === "e" || input === "E") {
101
- setPhase({ type: "input", value: phase.command, histIdx: -1 });
189
+ setPhase({ type: "input", value: phase.command, cursor: phase.command.length, histIdx: -1, raw: false });
190
+ return;
191
+ }
192
+ return;
193
+ }
194
+ // ── explain → back to confirm ─────────────────────────────────────
195
+ if (phase.type === "explain") {
196
+ if (key.ctrl && input === "c") {
197
+ exit();
198
+ return;
199
+ }
200
+ setPhase({ type: "confirm", nl: phase.nl, command: phase.command, raw: false, danger: isIrreversible(phase.command) });
201
+ return;
202
+ }
203
+ // ── autofix ───────────────────────────────────────────────────────
204
+ if (phase.type === "autofix") {
205
+ if (key.ctrl && input === "c") {
206
+ exit();
207
+ return;
208
+ }
209
+ if (input === "y" || input === "Y" || key.return) {
210
+ const { nl, command, errorOutput } = phase;
211
+ setPhase({ type: "thinking", nl, raw: false });
212
+ try {
213
+ const fixed = await fixCommand(nl, command, errorOutput, config.permissions, sessionCmds);
214
+ const danger = isIrreversible(fixed);
215
+ setPhase({ type: "confirm", nl, command: fixed, raw: false, danger });
216
+ }
217
+ catch (e) {
218
+ setPhase({ type: "error", message: e.message });
219
+ }
102
220
  return;
103
221
  }
222
+ inputPhase();
104
223
  return;
105
224
  }
225
+ // ── error ─────────────────────────────────────────────────────────
106
226
  if (phase.type === "error") {
107
227
  if (key.ctrl && input === "c") {
108
228
  exit();
109
229
  return;
110
230
  }
111
- setPhase({ type: "input", value: "", histIdx: -1 });
231
+ inputPhase();
112
232
  return;
113
233
  }
114
- }, [phase, allNl, config, exit]));
234
+ }, [phase, allNl, config, sessionCmds, exit]));
235
+ // ── expand toggle ──────────────────────────────────────────────────────────
236
+ const toggleExpand = (i) => setScroll((s) => s.map((e, idx) => idx === i ? { ...e, expanded: !e.expanded } : e));
237
+ // ── onboarding ─────────────────────────────────────────────────────────────
115
238
  if (!config.onboarded) {
116
239
  return _jsx(Onboarding, { onDone: finishOnboarding });
117
240
  }
118
- return (_jsxs(Box, { flexDirection: "column", children: [scroll.map((entry, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: entry.nl })] }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: entry.cmd })] }), entry.output && (_jsx(Text, { color: entry.error ? "red" : undefined, children: entry.output }))] }, i))), phase.type === "input" && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { children: phase.value }), _jsx(Text, { inverse: true, children: " " })] })), phase.type === "thinking" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: phase.nl })] }), _jsx(Text, { dimColor: true, children: " translating\u2026" })] })), phase.type === "confirm" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: phase.nl })] }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: phase.command })] }), _jsx(Text, { dimColor: true, children: " [enter] run [n] cancel [e] edit" })] })), phase.type === "running" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: phase.command })] }), _jsx(Text, { dimColor: true, children: " running\u2026" })] })), phase.type === "error" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", children: [" ", phase.message] }), _jsx(Text, { dimColor: true, children: " press any key" })] }))] }));
241
+ const isRaw = phase.type === "input" && phase.raw;
242
+ // ── render ─────────────────────────────────────────────────────────────────
243
+ return (_jsxs(Box, { flexDirection: "column", children: [scroll.map((entry, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: entry.nl })] }), entry.nl !== entry.cmd && (_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { dimColor: true, children: entry.cmd })] })), entry.lines.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 4, children: [entry.lines.map((line, j) => (_jsx(Text, { color: entry.error ? "red" : undefined, children: line }, j))), entry.truncated && !entry.expanded && (_jsx(Text, { dimColor: true, children: "\u2026 (space to expand)" }))] }))] }, i))), phase.type === "confirm" && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: phase.nl })] }), _jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: phase.command }), phase.danger && _jsx(Text, { color: "red", children: " \u26A0 irreversible" })] }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: "enter n e ?" }) })] })), phase.type === "explain" && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: phase.command })] }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: phase.explanation }) }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: "any key to continue" }) })] })), phase.type === "autofix" && (_jsx(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: " command failed \u2014 retry with fix? [enter / n]" }) })), phase.type === "thinking" && _jsx(Spinner, { label: "translating" }), phase.type === "running" && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { dimColor: true, children: phase.command })] }), streamLines.length > 0 && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: streamLines.slice(-MAX_LINES).map((line, i) => (_jsx(Text, { children: line }, i))) })), _jsx(Spinner, { label: "ctrl+c to cancel" })] })), phase.type === "error" && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: "red", children: phase.message }) })), phase.type === "input" && (_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: isRaw ? "$" : "›" }), _jsxs(Box, { children: [_jsx(Text, { children: phase.value.slice(0, phase.cursor) }), _jsx(Text, { inverse: true, children: phase.value[phase.cursor] ?? " " }), _jsx(Text, { children: phase.value.slice(phase.cursor + 1) })] })] })), _jsx(StatusBar, { permissions: config.permissions })] }));
119
244
  }
@@ -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", description: "rm, delete, drop table…" },
7
- { key: "network", label: "network", description: "curl, wget, ssh, ping…" },
8
- { key: "sudo", label: "sudo", description: "commands requiring root" },
9
- { key: "install", label: "install", description: "brew, npm -g, pip…" },
10
- { key: "write_outside_cwd", label: "write outside cwd", description: "files outside current directory" },
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({ type: "welcome" });
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.type === "welcome") {
19
- setStep({ type: "permissions", cursor: 0 });
19
+ if (step === "welcome") {
20
+ setStep("permissions");
20
21
  return;
21
22
  }
22
- if (step.type === "permissions") {
23
- const { cursor } = step;
23
+ if (step === "permissions") {
24
24
  if (key.upArrow) {
25
- setStep({ type: "permissions", cursor: Math.max(0, cursor - 1) });
25
+ setCursor((c) => Math.max(0, c - 1));
26
26
  return;
27
27
  }
28
28
  if (key.downArrow) {
29
- setStep({ type: "permissions", cursor: Math.min(PERM_KEYS.length - 1, cursor + 1) });
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.type === "welcome") {
45
- return (_jsxs(Box, { flexDirection: "column", paddingTop: 1, gap: 1, children: [_jsx(Text, { bold: true, children: "terminal" }), _jsx(Text, { children: "Type anything in plain English." }), _jsx(Text, { children: "The AI translates it to a shell command and runs it." }), _jsx(Text, { children: "Use \u2191 / \u2193 to browse history. Enter to run, n to cancel, e to edit." }), _jsx(Text, { dimColor: true, children: "press any key to set up permissions \u2192" })] }));
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 null;
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
  }
@@ -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
@@ -1,6 +1,35 @@
1
1
  import Anthropic from "@anthropic-ai/sdk";
2
2
  const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
3
- function buildSystemPrompt(perms) {
3
+ // ── irreversibility ───────────────────────────────────────────────────────────
4
+ const IRREVERSIBLE_PATTERNS = [
5
+ /\brm\s/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i,
6
+ /\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/, // overwrite redirect
7
+ /\bdd\b/, /\bmkfs\b/, /\bformat\b/, /\bshred\b/,
8
+ ];
9
+ export function isIrreversible(command) {
10
+ return IRREVERSIBLE_PATTERNS.some((r) => r.test(command));
11
+ }
12
+ // ── permissions ───────────────────────────────────────────────────────────────
13
+ const DESTRUCTIVE_PATTERNS = [/\brm\b/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i, /\bdelete\s+from\b/i];
14
+ const NETWORK_PATTERNS = [/\bcurl\b/, /\bwget\b/, /\bssh\b/, /\bscp\b/, /\bping\b/, /\bnc\b/, /\bnetcat\b/];
15
+ const SUDO_PATTERNS = [/\bsudo\b/];
16
+ const INSTALL_PATTERNS = [/\bbrew\s+install\b/, /\bnpm\s+install\s+-g\b/, /\bpip\s+install\b/, /\bapt\s+install\b/, /\byum\s+install\b/];
17
+ const WRITE_OUTSIDE_PATTERNS = [/\s(\/etc|\/usr|\/var|\/opt|\/root|~\/[^.])/, />\s*\//];
18
+ export function checkPermissions(command, perms) {
19
+ if (!perms.destructive && DESTRUCTIVE_PATTERNS.some((r) => r.test(command)))
20
+ return "destructive commands are disabled";
21
+ if (!perms.network && NETWORK_PATTERNS.some((r) => r.test(command)))
22
+ return "network commands are disabled";
23
+ if (!perms.sudo && SUDO_PATTERNS.some((r) => r.test(command)))
24
+ return "sudo is disabled";
25
+ if (!perms.install && INSTALL_PATTERNS.some((r) => r.test(command)))
26
+ return "package installation is disabled";
27
+ if (!perms.write_outside_cwd && WRITE_OUTSIDE_PATTERNS.some((r) => r.test(command)))
28
+ return "writing outside cwd is disabled";
29
+ return null;
30
+ }
31
+ // ── system prompt ─────────────────────────────────────────────────────────────
32
+ function buildSystemPrompt(perms, sessionCmds) {
4
33
  const restrictions = [];
5
34
  if (!perms.destructive)
6
35
  restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data (rm, rmdir, truncate, DROP TABLE, etc.)");
@@ -13,38 +42,59 @@ function buildSystemPrompt(perms) {
13
42
  if (!perms.install)
14
43
  restrictions.push("- NEVER generate commands that install packages (brew install, npm install -g, pip install, apt install, etc.)");
15
44
  const restrictionBlock = restrictions.length > 0
16
- ? `\n\nCURRENT RESTRICTIONS (respect these absolutely):\n${restrictions.join("\n")}\nIf the user asks for something restricted, output exactly: BLOCKED: <reason>`
45
+ ? `\n\nCURRENT RESTRICTIONS (respect absolutely):\n${restrictions.join("\n")}\nIf restricted, output exactly: BLOCKED: <reason>`
17
46
  : "";
47
+ const contextBlock = sessionCmds.length > 0
48
+ ? `\n\nRECENT COMMANDS THIS SESSION (for context — e.g. "undo that", "do the same for X"):\n${sessionCmds.map((c) => `$ ${c}`).join("\n")}`
49
+ : "";
50
+ const cwd = process.cwd();
18
51
  return `You are a terminal assistant. The user will describe what they want to do in plain English.
19
52
  Your job is to output ONLY the exact shell command(s) to accomplish this — nothing else.
20
53
  No explanation. No markdown. No backticks. Just the raw command.
21
54
  If multiple commands are needed, join them with && or use a newline.
22
- Assume macOS/Linux zsh environment.${restrictionBlock}`;
55
+ Assume macOS/Linux zsh environment.
56
+ Current working directory: ${cwd}${restrictionBlock}${contextBlock}`;
23
57
  }
24
- /** Regex patterns for permission checks — fast local guard before even calling AI */
25
- const DESTRUCTIVE_PATTERNS = [/\brm\b/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i, /\bdelete\s+from\b/i];
26
- const NETWORK_PATTERNS = [/\bcurl\b/, /\bwget\b/, /\bssh\b/, /\bscp\b/, /\bping\b/, /\bnc\b/, /\bnetcat\b/];
27
- const SUDO_PATTERNS = [/\bsudo\b/];
28
- const INSTALL_PATTERNS = [/\bbrew\s+install\b/, /\bnpm\s+install\s+-g\b/, /\bpip\s+install\b/, /\bapt\s+install\b/, /\byum\s+install\b/];
29
- const WRITE_OUTSIDE_PATTERNS = [/\s(\/etc|\/usr|\/var|\/opt|\/root|~\/[^.])/, />\s*\//];
30
- export function checkPermissions(command, perms) {
31
- if (!perms.destructive && DESTRUCTIVE_PATTERNS.some((r) => r.test(command)))
32
- return "destructive commands are disabled in your permissions";
33
- if (!perms.network && NETWORK_PATTERNS.some((r) => r.test(command)))
34
- return "network commands are disabled in your permissions";
35
- if (!perms.sudo && SUDO_PATTERNS.some((r) => r.test(command)))
36
- return "sudo is disabled in your permissions";
37
- if (!perms.install && INSTALL_PATTERNS.some((r) => r.test(command)))
38
- return "package installation is disabled in your permissions";
39
- if (!perms.write_outside_cwd && WRITE_OUTSIDE_PATTERNS.some((r) => r.test(command)))
40
- return "writing outside cwd is disabled in your permissions";
41
- return null;
58
+ // ── explain ───────────────────────────────────────────────────────────────────
59
+ export async function explainCommand(command) {
60
+ const message = await client.messages.create({
61
+ model: "claude-haiku-4-5-20251001",
62
+ max_tokens: 128,
63
+ system: "Explain what this shell command does in one plain English sentence. No markdown. No code blocks. Just a sentence.",
64
+ messages: [{ role: "user", content: command }],
65
+ });
66
+ const block = message.content[0];
67
+ if (block.type !== "text")
68
+ return "";
69
+ return block.text.trim();
70
+ }
71
+ // ── auto-fix ──────────────────────────────────────────────────────────────────
72
+ export async function fixCommand(originalNl, failedCommand, errorOutput, perms, sessionCmds) {
73
+ const message = await client.messages.create({
74
+ model: "claude-opus-4-6",
75
+ max_tokens: 256,
76
+ system: buildSystemPrompt(perms, sessionCmds),
77
+ messages: [
78
+ {
79
+ role: "user",
80
+ content: `I wanted to: ${originalNl}\nI ran: ${failedCommand}\nIt failed with:\n${errorOutput}\n\nGive me the corrected command.`,
81
+ },
82
+ ],
83
+ });
84
+ const block = message.content[0];
85
+ if (block.type !== "text")
86
+ throw new Error("Unexpected response type");
87
+ const text = block.text.trim();
88
+ if (text.startsWith("BLOCKED:"))
89
+ throw new Error(text);
90
+ return text;
42
91
  }
43
- export async function translateToCommand(nl, perms) {
92
+ // ── translate ─────────────────────────────────────────────────────────────────
93
+ export async function translateToCommand(nl, perms, sessionCmds) {
44
94
  const message = await client.messages.create({
45
95
  model: "claude-opus-4-6",
46
96
  max_tokens: 256,
47
- system: buildSystemPrompt(perms),
97
+ system: buildSystemPrompt(perms, sessionCmds),
48
98
  messages: [{ role: "user", content: nl }],
49
99
  });
50
100
  const block = message.content[0];
package/dist/history.js CHANGED
@@ -5,11 +5,11 @@ const DIR = join(homedir(), ".terminal");
5
5
  const HISTORY_FILE = join(DIR, "history.json");
6
6
  const CONFIG_FILE = join(DIR, "config.json");
7
7
  export const DEFAULT_PERMISSIONS = {
8
- destructive: false,
8
+ destructive: true,
9
9
  network: true,
10
- sudo: false,
11
- write_outside_cwd: false,
12
- install: false,
10
+ sudo: true,
11
+ write_outside_cwd: true,
12
+ install: true,
13
13
  };
14
14
  export const DEFAULT_CONFIG = {
15
15
  onboarded: false,
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Natural language terminal — speak plain English, get shell commands",
5
5
  "type": "module",
6
6
  "bin": {
7
- "t": "./dist/cli.js",
8
- "terminal": "./dist/cli.js"
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",