@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 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().map((h) => h.nl).filter(Boolean));
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({ type: "input", value: "", histIdx: -1 });
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({ type: "input", value: val, histIdx: nextIdx });
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({ type: "input", value: val, histIdx: nextIdx });
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
- setPhase({ type: "thinking", nl });
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
- setPhase({ type: "input", value: "", histIdx: -1 });
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
- setPhase({ ...phase, value: phase.value.slice(0, -1), histIdx: -1 });
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
- setPhase({ ...phase, value: phase.value + input, histIdx: -1 });
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
- setPhase({ type: "input", value: "", histIdx: -1 });
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
- setPhase({ type: "input", value: "", histIdx: -1 });
162
+ inputPhase();
93
163
  }
94
164
  return;
95
165
  }
96
166
  if (input === "n" || input === "N" || key.escape) {
97
- setPhase({ type: "input", value: "", histIdx: -1 });
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
- setPhase({ type: "input", value: "", histIdx: -1 });
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
- 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" })] }))] }));
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
  }
@@ -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
@@ -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.0",
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": "./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",
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().map((h) => h.nl).filter(Boolean)
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>({ type: "input", value: "", histIdx: -1 });
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({ type: "input", value: val, histIdx: nextIdx });
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({ type: "input", value: val, histIdx: nextIdx });
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
- setPhase({ type: "thinking", nl });
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
- setPhase({ type: "input", value: "", histIdx: -1 });
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
- setPhase({ ...phase, value: phase.value.slice(0, -1), histIdx: -1 });
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
- setPhase({ ...phase, value: phase.value + input, histIdx: -1 });
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
- setPhase({ type: "input", value: "", histIdx: -1 });
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
- setPhase({ type: "input", value: "", histIdx: -1 });
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
- setPhase({ type: "input", value: "", histIdx: -1 });
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={1}>
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
- <Box gap={1}>
153
- <Text dimColor>$</Text>
154
- <Text>{entry.cmd}</Text>
155
- </Box>
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
- <Text color={entry.error ? "red" : undefined}>{entry.output}</Text>
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
- {phase.type === "input" && (
163
- <Box gap={1}>
164
- <Text dimColor>›</Text>
165
- <Text>{phase.value}</Text>
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
- <Text dimColor> translating…</Text>
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
- {phase.type === "confirm" && (
181
- <Box flexDirection="column">
182
- <Box gap={1}>
183
- <Text dimColor>›</Text>
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
- <Text dimColor> [enter] run [n] cancel [e] edit</Text>
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={1}>
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
- <Text dimColor> running…</Text>
298
+ <Spinner label="running" />
201
299
  </Box>
202
300
  )}
203
301
 
302
+ {/* error */}
204
303
  {phase.type === "error" && (
205
- <Box flexDirection="column">
206
- <Text color="red"> {phase.message}</Text>
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
  }
@@ -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; description: string }> = [
15
- { key: "destructive", label: "destructive", description: "rm, delete, drop table…" },
16
- { key: "network", label: "network", description: "curl, wget, ssh, ping…" },
17
- { key: "sudo", label: "sudo", description: "commands requiring root" },
18
- { key: "install", label: "install", description: "brew, npm -g, pip…" },
19
- { key: "write_outside_cwd", label: "write outside cwd", description: "files outside current directory" },
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>({ type: "welcome" });
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.type === "welcome") {
30
- setStep({ type: "permissions", cursor: 0 });
27
+ if (step === "welcome") {
28
+ setStep("permissions");
31
29
  return;
32
30
  }
33
31
 
34
- if (step.type === "permissions") {
35
- const { cursor } = step;
36
- if (key.upArrow) {
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.type === "welcome") {
44
+ if (step === "welcome") {
58
45
  return (
59
- <Box flexDirection="column" paddingTop={1} gap={1}>
60
- <Text bold>terminal</Text>
61
- <Text>Type anything in plain English.</Text>
62
- <Text>The AI translates it to a shell command and runs it.</Text>
63
- <Text>Use / to browse history. Enter to run, n to cancel, e to edit.</Text>
64
- <Text dimColor>press any key to set up permissions →</Text>
65
- </Box>
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>you can change this later in ~/.terminal/config.json</Text>
56
+ <Box marginTop={1}><Text dimColor>any key to set permissions →</Text></Box>
89
57
  </Box>
90
58
  );
91
59
  }
92
60
 
93
- return null;
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
  }
@@ -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",