@hasna/terminal 0.1.2 → 0.1.4

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
@@ -4,9 +4,12 @@ import { Box, Text, useInput, useApp } from "ink";
4
4
  import { spawn } from "child_process";
5
5
  import { translateToCommand, explainCommand, fixCommand, checkPermissions, isIrreversible } from "./ai.js";
6
6
  import { loadHistory, appendHistory, loadConfig, saveConfig, } from "./history.js";
7
+ import { loadCache } from "./cache.js";
7
8
  import Onboarding from "./Onboarding.js";
8
9
  import StatusBar from "./StatusBar.js";
9
10
  import Spinner from "./Spinner.js";
11
+ // warm cache on startup
12
+ loadCache();
10
13
  const MAX_LINES = 20;
11
14
  // ── helpers ───────────────────────────────────────────────────────────────────
12
15
  function insertAt(s, pos, ch) { return s.slice(0, pos) + ch + s.slice(pos); }
@@ -42,7 +45,7 @@ export default function App() {
42
45
  });
43
46
  const allNl = [...nlHistory, ...sessionNl];
44
47
  const finishOnboarding = (perms) => {
45
- const next = { onboarded: true, permissions: perms };
48
+ const next = { onboarded: true, confirm: false, permissions: perms };
46
49
  setConfig(next);
47
50
  saveConfig(next);
48
51
  };
@@ -131,9 +134,11 @@ export default function App() {
131
134
  await runPhase(nl, nl, true);
132
135
  return;
133
136
  }
134
- setPhase({ type: "thinking", nl, raw: false });
137
+ setPhase({ type: "thinking", nl, raw: false, partial: "" });
135
138
  try {
136
- const command = await translateToCommand(nl, config.permissions, sessionCmds);
139
+ const command = await translateToCommand(nl, config.permissions, sessionCmds, (partial) => {
140
+ setPhase({ type: "thinking", nl, raw: false, partial });
141
+ });
137
142
  const blocked = checkPermissions(command, config.permissions);
138
143
  if (blocked) {
139
144
  pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
@@ -141,6 +146,11 @@ export default function App() {
141
146
  return;
142
147
  }
143
148
  const danger = isIrreversible(command);
149
+ // skip confirm unless user opted in OR command is dangerous
150
+ if (!config.confirm && !danger) {
151
+ await runPhase(nl, command, false);
152
+ return;
153
+ }
144
154
  setPhase({ type: "confirm", nl, command, raw: false, danger });
145
155
  }
146
156
  catch (e) {
@@ -167,7 +177,7 @@ export default function App() {
167
177
  }
168
178
  if (input === "?") {
169
179
  const { nl, command } = phase;
170
- setPhase({ type: "thinking", nl, raw: false });
180
+ setPhase({ type: "thinking", nl, raw: false, partial: "" });
171
181
  try {
172
182
  const explanation = await explainCommand(command);
173
183
  setPhase({ type: "explain", nl, command, explanation });
@@ -208,10 +218,14 @@ export default function App() {
208
218
  }
209
219
  if (input === "y" || input === "Y" || key.return) {
210
220
  const { nl, command, errorOutput } = phase;
211
- setPhase({ type: "thinking", nl, raw: false });
221
+ setPhase({ type: "thinking", nl, raw: false, partial: "" });
212
222
  try {
213
223
  const fixed = await fixCommand(nl, command, errorOutput, config.permissions, sessionCmds);
214
224
  const danger = isIrreversible(fixed);
225
+ if (!config.confirm && !danger) {
226
+ await runPhase(nl, fixed, false);
227
+ return;
228
+ }
215
229
  setPhase({ type: "confirm", nl, command: fixed, raw: false, danger });
216
230
  }
217
231
  catch (e) {
@@ -240,5 +254,5 @@ export default function App() {
240
254
  }
241
255
  const isRaw = phase.type === "input" && phase.raw;
242
256
  // ── 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 })] }));
257
+ return (_jsxs(Box, { flexDirection: "column", children: [scroll.map((entry, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: entry.nl })] }), entry.nl !== entry.cmd && (_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { dimColor: true, children: entry.cmd })] })), entry.lines.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 4, children: [entry.lines.map((line, j) => (_jsx(Text, { color: entry.error ? "red" : undefined, children: line }, j))), entry.truncated && !entry.expanded && (_jsx(Text, { dimColor: true, children: "\u2026 (space to expand)" }))] }))] }, i))), phase.type === "confirm" && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: phase.nl })] }), _jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: phase.command }), phase.danger && _jsx(Text, { color: "red", children: " \u26A0 irreversible" })] }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: "enter n e ?" }) })] })), phase.type === "explain" && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: [_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { children: phase.command })] }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: phase.explanation }) }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: "any key to continue" }) })] })), phase.type === "autofix" && (_jsx(Box, { flexDirection: "column", marginBottom: 1, paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: " command failed \u2014 retry with fix? [enter / n]" }) })), phase.type === "thinking" && (phase.partial ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "\u203A" }), _jsx(Text, { dimColor: true, children: phase.nl })] }), _jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { dimColor: true, children: phase.partial })] })] })) : (_jsx(Spinner, { label: "translating" }))), phase.type === "running" && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "$" }), _jsx(Text, { dimColor: true, children: phase.command })] }), streamLines.length > 0 && (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: streamLines.slice(-MAX_LINES).map((line, i) => (_jsx(Text, { children: line }, i))) })), _jsx(Spinner, { label: "ctrl+c to cancel" })] })), phase.type === "error" && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: "red", children: phase.message }) })), phase.type === "input" && (_jsxs(Box, { gap: 2, paddingLeft: 2, children: [_jsx(Text, { dimColor: true, children: isRaw ? "$" : "›" }), _jsxs(Box, { children: [_jsx(Text, { children: phase.value.slice(0, phase.cursor) }), _jsx(Text, { inverse: true, children: phase.value[phase.cursor] ?? " " }), _jsx(Text, { children: phase.value.slice(phase.cursor + 1) })] })] })), _jsx(StatusBar, { permissions: config.permissions })] }));
244
258
  }
package/dist/ai.js CHANGED
@@ -1,9 +1,23 @@
1
1
  import Anthropic from "@anthropic-ai/sdk";
2
+ import { cacheGet, cacheSet } from "./cache.js";
2
3
  const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
4
+ // ── model routing ─────────────────────────────────────────────────────────────
5
+ // Simple queries → haiku (fast). Complex/ambiguous → sonnet.
6
+ const COMPLEX_SIGNALS = [
7
+ /\b(undo|revert|rollback|previous|last)\b/i,
8
+ /\b(all files?|recursively|bulk|batch)\b/i,
9
+ /\b(pipeline|chain|then|and then|after)\b/i,
10
+ /\b(if|when|unless|only if)\b/i,
11
+ /[|&;]{2}/, // pipes / && in NL (unusual = complex intent)
12
+ ];
13
+ function pickModel(nl) {
14
+ const isComplex = COMPLEX_SIGNALS.some((r) => r.test(nl)) || nl.split(" ").length > 10;
15
+ return isComplex ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001";
16
+ }
3
17
  // ── irreversibility ───────────────────────────────────────────────────────────
4
18
  const IRREVERSIBLE_PATTERNS = [
5
19
  /\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
20
+ /\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/,
7
21
  /\bdd\b/, /\bmkfs\b/, /\bformat\b/, /\bshred\b/,
8
22
  ];
9
23
  export function isIrreversible(command) {
@@ -32,35 +46,82 @@ export function checkPermissions(command, perms) {
32
46
  function buildSystemPrompt(perms, sessionCmds) {
33
47
  const restrictions = [];
34
48
  if (!perms.destructive)
35
- restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data (rm, rmdir, truncate, DROP TABLE, etc.)");
49
+ restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data");
36
50
  if (!perms.network)
37
- restrictions.push("- NEVER generate commands that make network requests (curl, wget, ssh, scp, ping, nc, etc.)");
51
+ restrictions.push("- NEVER generate commands that make network requests (curl, wget, ssh, etc.)");
38
52
  if (!perms.sudo)
39
- restrictions.push("- NEVER generate commands that use sudo or require root privileges");
53
+ restrictions.push("- NEVER generate commands requiring sudo");
40
54
  if (!perms.write_outside_cwd)
41
- restrictions.push("- NEVER generate commands that write to paths outside the current working directory");
55
+ restrictions.push("- NEVER write to paths outside the current working directory");
42
56
  if (!perms.install)
43
- restrictions.push("- NEVER generate commands that install packages (brew install, npm install -g, pip install, apt install, etc.)");
57
+ restrictions.push("- NEVER install packages (brew, npm -g, pip, apt, etc.)");
44
58
  const restrictionBlock = restrictions.length > 0
45
- ? `\n\nCURRENT RESTRICTIONS (respect absolutely):\n${restrictions.join("\n")}\nIf restricted, output exactly: BLOCKED: <reason>`
59
+ ? `\n\nRESTRICTIONS:\n${restrictions.join("\n")}\nIf restricted, output: BLOCKED: <reason>`
46
60
  : "";
47
61
  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")}`
62
+ ? `\n\nSESSION HISTORY:\n${sessionCmds.map((c) => `$ ${c}`).join("\n")}`
49
63
  : "";
50
- const cwd = process.cwd();
51
- return `You are a terminal assistant. The user will describe what they want to do in plain English.
52
- Your job is to output ONLY the exact shell command(s) to accomplish this — nothing else.
53
- No explanation. No markdown. No backticks. Just the raw command.
54
- If multiple commands are needed, join them with && or use a newline.
55
- Assume macOS/Linux zsh environment.
56
- Current working directory: ${cwd}${restrictionBlock}${contextBlock}`;
64
+ return `You are a terminal assistant. Output ONLY the exact shell command — no explanation, no markdown, no backticks.
65
+ cwd: ${process.cwd()}
66
+ shell: zsh / macOS${restrictionBlock}${contextBlock}`;
67
+ }
68
+ // ── streaming translate ───────────────────────────────────────────────────────
69
+ export async function translateToCommand(nl, perms, sessionCmds, onToken) {
70
+ // cache hit — instant
71
+ const cached = cacheGet(nl);
72
+ if (cached) {
73
+ onToken?.(cached);
74
+ return cached;
75
+ }
76
+ const model = pickModel(nl);
77
+ let result = "";
78
+ if (onToken) {
79
+ // streaming path
80
+ const stream = await client.messages.stream({
81
+ model,
82
+ max_tokens: 256,
83
+ system: buildSystemPrompt(perms, sessionCmds),
84
+ messages: [{ role: "user", content: nl }],
85
+ });
86
+ for await (const chunk of stream) {
87
+ if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
88
+ result += chunk.delta.text;
89
+ onToken(result.trim());
90
+ }
91
+ }
92
+ }
93
+ else {
94
+ const message = await client.messages.create({
95
+ model,
96
+ max_tokens: 256,
97
+ system: buildSystemPrompt(perms, sessionCmds),
98
+ messages: [{ role: "user", content: nl }],
99
+ });
100
+ const block = message.content[0];
101
+ if (block.type !== "text")
102
+ throw new Error("Unexpected response type");
103
+ result = block.text;
104
+ }
105
+ const text = result.trim();
106
+ if (text.startsWith("BLOCKED:"))
107
+ throw new Error(text);
108
+ cacheSet(nl, text);
109
+ return text;
110
+ }
111
+ // ── prefetch ──────────────────────────────────────────────────────────────────
112
+ // Silently warm the cache after a command runs — no await, fire and forget
113
+ export function prefetchNext(lastNl, perms, sessionCmds) {
114
+ // Only prefetch if we don't have it cached already
115
+ if (cacheGet(lastNl))
116
+ return;
117
+ translateToCommand(lastNl, perms, sessionCmds).catch(() => { });
57
118
  }
58
119
  // ── explain ───────────────────────────────────────────────────────────────────
59
120
  export async function explainCommand(command) {
60
121
  const message = await client.messages.create({
61
122
  model: "claude-haiku-4-5-20251001",
62
123
  max_tokens: 128,
63
- system: "Explain what this shell command does in one plain English sentence. No markdown. No code blocks. Just a sentence.",
124
+ system: "Explain what this shell command does in one plain English sentence. No markdown, no code blocks.",
64
125
  messages: [{ role: "user", content: command }],
65
126
  });
66
127
  const block = message.content[0];
@@ -71,31 +132,13 @@ export async function explainCommand(command) {
71
132
  // ── auto-fix ──────────────────────────────────────────────────────────────────
72
133
  export async function fixCommand(originalNl, failedCommand, errorOutput, perms, sessionCmds) {
73
134
  const message = await client.messages.create({
74
- model: "claude-opus-4-6",
135
+ model: "claude-sonnet-4-6",
75
136
  max_tokens: 256,
76
137
  system: buildSystemPrompt(perms, sessionCmds),
77
- messages: [
78
- {
138
+ messages: [{
79
139
  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;
91
- }
92
- // ── translate ─────────────────────────────────────────────────────────────────
93
- export async function translateToCommand(nl, perms, sessionCmds) {
94
- const message = await client.messages.create({
95
- model: "claude-opus-4-6",
96
- max_tokens: 256,
97
- system: buildSystemPrompt(perms, sessionCmds),
98
- messages: [{ role: "user", content: nl }],
140
+ content: `I wanted to: ${originalNl}\nI ran: ${failedCommand}\nError:\n${errorOutput}\n\nGive me the corrected command only.`,
141
+ }],
99
142
  });
100
143
  const block = message.content[0];
101
144
  if (block.type !== "text")
package/dist/cache.js ADDED
@@ -0,0 +1,41 @@
1
+ // In-memory LRU cache + disk persistence for command translations
2
+ import { existsSync, readFileSync, writeFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ const CACHE_FILE = join(homedir(), ".terminal", "cache.json");
6
+ const MAX_ENTRIES = 500;
7
+ let mem = {};
8
+ export function loadCache() {
9
+ if (!existsSync(CACHE_FILE))
10
+ return;
11
+ try {
12
+ mem = JSON.parse(readFileSync(CACHE_FILE, "utf8"));
13
+ }
14
+ catch { }
15
+ }
16
+ function persistCache() {
17
+ try {
18
+ writeFileSync(CACHE_FILE, JSON.stringify(mem));
19
+ }
20
+ catch { }
21
+ }
22
+ /** Normalize a natural language query for cache lookup */
23
+ export function normalizeNl(nl) {
24
+ return nl
25
+ .toLowerCase()
26
+ .trim()
27
+ .replace(/[^a-z0-9\s]/g, "") // strip punctuation
28
+ .replace(/\s+/g, " ");
29
+ }
30
+ export function cacheGet(nl) {
31
+ return mem[normalizeNl(nl)] ?? null;
32
+ }
33
+ export function cacheSet(nl, command) {
34
+ const key = normalizeNl(nl);
35
+ // evict oldest if full
36
+ const keys = Object.keys(mem);
37
+ if (keys.length >= MAX_ENTRIES)
38
+ delete mem[keys[0]];
39
+ mem[key] = command;
40
+ persistCache();
41
+ }
package/dist/history.js CHANGED
@@ -13,6 +13,7 @@ export const DEFAULT_PERMISSIONS = {
13
13
  };
14
14
  export const DEFAULT_CONFIG = {
15
15
  onboarded: false,
16
+ confirm: false,
16
17
  permissions: DEFAULT_PERMISSIONS,
17
18
  };
18
19
  function ensureDir() {
@@ -47,6 +48,7 @@ export function loadConfig() {
47
48
  return {
48
49
  ...DEFAULT_CONFIG,
49
50
  ...saved,
51
+ confirm: saved.confirm ?? false,
50
52
  permissions: { ...DEFAULT_PERMISSIONS, ...(saved.permissions ?? {}) },
51
53
  };
52
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Natural language terminal — speak plain English, get shell commands",
5
5
  "type": "module",
6
6
  "bin": {
package/src/App.tsx CHANGED
@@ -9,15 +9,19 @@ import {
9
9
  saveConfig,
10
10
  type Permissions,
11
11
  } from "./history.js";
12
+ import { loadCache } from "./cache.js";
12
13
  import Onboarding from "./Onboarding.js";
13
14
  import StatusBar from "./StatusBar.js";
14
15
  import Spinner from "./Spinner.js";
15
16
 
17
+ // warm cache on startup
18
+ loadCache();
19
+
16
20
  // ── types ─────────────────────────────────────────────────────────────────────
17
21
 
18
22
  type Phase =
19
23
  | { type: "input"; value: string; cursor: number; histIdx: number; raw: boolean }
20
- | { type: "thinking"; nl: string; raw: boolean }
24
+ | { type: "thinking"; nl: string; raw: boolean; partial: string }
21
25
  | { type: "confirm"; nl: string; command: string; raw: boolean; danger: boolean }
22
26
  | { type: "explain"; nl: string; command: string; explanation: string }
23
27
  | { type: "running"; nl: string; command: string }
@@ -80,7 +84,7 @@ export default function App() {
80
84
  const allNl = [...nlHistory, ...sessionNl];
81
85
 
82
86
  const finishOnboarding = (perms: Permissions) => {
83
- const next = { onboarded: true, permissions: perms };
87
+ const next = { onboarded: true, confirm: false, permissions: perms };
84
88
  setConfig(next);
85
89
  saveConfig(next);
86
90
  };
@@ -175,9 +179,11 @@ export default function App() {
175
179
  return;
176
180
  }
177
181
 
178
- setPhase({ type: "thinking", nl, raw: false });
182
+ setPhase({ type: "thinking", nl, raw: false, partial: "" });
179
183
  try {
180
- const command = await translateToCommand(nl, config.permissions, sessionCmds);
184
+ const command = await translateToCommand(nl, config.permissions, sessionCmds, (partial) => {
185
+ setPhase({ type: "thinking", nl, raw: false, partial });
186
+ });
181
187
  const blocked = checkPermissions(command, config.permissions);
182
188
  if (blocked) {
183
189
  pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
@@ -185,6 +191,11 @@ export default function App() {
185
191
  return;
186
192
  }
187
193
  const danger = isIrreversible(command);
194
+ // skip confirm unless user opted in OR command is dangerous
195
+ if (!config.confirm && !danger) {
196
+ await runPhase(nl, command, false);
197
+ return;
198
+ }
188
199
  setPhase({ type: "confirm", nl, command, raw: false, danger });
189
200
  } catch (e: any) {
190
201
  setPhase({ type: "error", message: e.message });
@@ -210,7 +221,7 @@ export default function App() {
210
221
 
211
222
  if (input === "?") {
212
223
  const { nl, command } = phase;
213
- setPhase({ type: "thinking", nl, raw: false });
224
+ setPhase({ type: "thinking", nl, raw: false, partial: "" });
214
225
  try {
215
226
  const explanation = await explainCommand(command);
216
227
  setPhase({ type: "explain", nl, command, explanation });
@@ -243,10 +254,14 @@ export default function App() {
243
254
  if (key.ctrl && input === "c") { exit(); return; }
244
255
  if (input === "y" || input === "Y" || key.return) {
245
256
  const { nl, command, errorOutput } = phase;
246
- setPhase({ type: "thinking", nl, raw: false });
257
+ setPhase({ type: "thinking", nl, raw: false, partial: "" });
247
258
  try {
248
259
  const fixed = await fixCommand(nl, command, errorOutput, config.permissions, sessionCmds);
249
260
  const danger = isIrreversible(fixed);
261
+ if (!config.confirm && !danger) {
262
+ await runPhase(nl, fixed, false);
263
+ return;
264
+ }
250
265
  setPhase({ type: "confirm", nl, command: fixed, raw: false, danger });
251
266
  } catch (e: any) {
252
267
  setPhase({ type: "error", message: e.message });
@@ -346,8 +361,23 @@ export default function App() {
346
361
  </Box>
347
362
  )}
348
363
 
349
- {/* spinners */}
350
- {phase.type === "thinking" && <Spinner label="translating" />}
364
+ {/* thinking — show streaming partial or spinner */}
365
+ {phase.type === "thinking" && (
366
+ phase.partial ? (
367
+ <Box flexDirection="column" paddingLeft={2}>
368
+ <Box gap={2}>
369
+ <Text dimColor>›</Text>
370
+ <Text dimColor>{phase.nl}</Text>
371
+ </Box>
372
+ <Box gap={2} paddingLeft={2}>
373
+ <Text dimColor>$</Text>
374
+ <Text dimColor>{phase.partial}</Text>
375
+ </Box>
376
+ </Box>
377
+ ) : (
378
+ <Spinner label="translating" />
379
+ )
380
+ )}
351
381
 
352
382
  {/* running — live stream */}
353
383
  {phase.type === "running" && (
package/src/ai.ts CHANGED
@@ -1,13 +1,30 @@
1
1
  import Anthropic from "@anthropic-ai/sdk";
2
2
  import type { Permissions } from "./history.js";
3
+ import { cacheGet, cacheSet } from "./cache.js";
3
4
 
4
5
  const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
5
6
 
7
+ // ── model routing ─────────────────────────────────────────────────────────────
8
+ // Simple queries → haiku (fast). Complex/ambiguous → sonnet.
9
+
10
+ const COMPLEX_SIGNALS = [
11
+ /\b(undo|revert|rollback|previous|last)\b/i,
12
+ /\b(all files?|recursively|bulk|batch)\b/i,
13
+ /\b(pipeline|chain|then|and then|after)\b/i,
14
+ /\b(if|when|unless|only if)\b/i,
15
+ /[|&;]{2}/, // pipes / && in NL (unusual = complex intent)
16
+ ];
17
+
18
+ function pickModel(nl: string): string {
19
+ const isComplex = COMPLEX_SIGNALS.some((r) => r.test(nl)) || nl.split(" ").length > 10;
20
+ return isComplex ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001";
21
+ }
22
+
6
23
  // ── irreversibility ───────────────────────────────────────────────────────────
7
24
 
8
25
  const IRREVERSIBLE_PATTERNS = [
9
26
  /\brm\s/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i,
10
- /\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/, // overwrite redirect
27
+ /\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\b>\s*[^>]/,
11
28
  /\bdd\b/, /\bmkfs\b/, /\bformat\b/, /\bshred\b/,
12
29
  ];
13
30
 
@@ -17,11 +34,11 @@ export function isIrreversible(command: string): boolean {
17
34
 
18
35
  // ── permissions ───────────────────────────────────────────────────────────────
19
36
 
20
- const DESTRUCTIVE_PATTERNS = [/\brm\b/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i, /\bdelete\s+from\b/i];
21
- const NETWORK_PATTERNS = [/\bcurl\b/, /\bwget\b/, /\bssh\b/, /\bscp\b/, /\bping\b/, /\bnc\b/, /\bnetcat\b/];
22
- const SUDO_PATTERNS = [/\bsudo\b/];
23
- 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/];
24
- const WRITE_OUTSIDE_PATTERNS = [/\s(\/etc|\/usr|\/var|\/opt|\/root|~\/[^.])/, />\s*\//];
37
+ const DESTRUCTIVE_PATTERNS = [/\brm\b/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i, /\bdelete\s+from\b/i];
38
+ const NETWORK_PATTERNS = [/\bcurl\b/, /\bwget\b/, /\bssh\b/, /\bscp\b/, /\bping\b/, /\bnc\b/, /\bnetcat\b/];
39
+ const SUDO_PATTERNS = [/\bsudo\b/];
40
+ 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/];
41
+ const WRITE_OUTSIDE_PATTERNS = [/\s(\/etc|\/usr|\/var|\/opt|\/root|~\/[^.])/, />\s*\//];
25
42
 
26
43
  export function checkPermissions(command: string, perms: Permissions): string | null {
27
44
  if (!perms.destructive && DESTRUCTIVE_PATTERNS.some((r) => r.test(command)))
@@ -42,32 +59,87 @@ export function checkPermissions(command: string, perms: Permissions): string |
42
59
  function buildSystemPrompt(perms: Permissions, sessionCmds: string[]): string {
43
60
  const restrictions: string[] = [];
44
61
  if (!perms.destructive)
45
- restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data (rm, rmdir, truncate, DROP TABLE, etc.)");
62
+ restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data");
46
63
  if (!perms.network)
47
- restrictions.push("- NEVER generate commands that make network requests (curl, wget, ssh, scp, ping, nc, etc.)");
64
+ restrictions.push("- NEVER generate commands that make network requests (curl, wget, ssh, etc.)");
48
65
  if (!perms.sudo)
49
- restrictions.push("- NEVER generate commands that use sudo or require root privileges");
66
+ restrictions.push("- NEVER generate commands requiring sudo");
50
67
  if (!perms.write_outside_cwd)
51
- restrictions.push("- NEVER generate commands that write to paths outside the current working directory");
68
+ restrictions.push("- NEVER write to paths outside the current working directory");
52
69
  if (!perms.install)
53
- restrictions.push("- NEVER generate commands that install packages (brew install, npm install -g, pip install, apt install, etc.)");
70
+ restrictions.push("- NEVER install packages (brew, npm -g, pip, apt, etc.)");
54
71
 
55
72
  const restrictionBlock = restrictions.length > 0
56
- ? `\n\nCURRENT RESTRICTIONS (respect absolutely):\n${restrictions.join("\n")}\nIf restricted, output exactly: BLOCKED: <reason>`
73
+ ? `\n\nRESTRICTIONS:\n${restrictions.join("\n")}\nIf restricted, output: BLOCKED: <reason>`
57
74
  : "";
58
75
 
59
76
  const contextBlock = sessionCmds.length > 0
60
- ? `\n\nRECENT COMMANDS THIS SESSION (for context — e.g. "undo that", "do the same for X"):\n${sessionCmds.map((c) => `$ ${c}`).join("\n")}`
77
+ ? `\n\nSESSION HISTORY:\n${sessionCmds.map((c) => `$ ${c}`).join("\n")}`
61
78
  : "";
62
79
 
63
- const cwd = process.cwd();
80
+ return `You are a terminal assistant. Output ONLY the exact shell command — no explanation, no markdown, no backticks.
81
+ cwd: ${process.cwd()}
82
+ shell: zsh / macOS${restrictionBlock}${contextBlock}`;
83
+ }
84
+
85
+ // ── streaming translate ───────────────────────────────────────────────────────
86
+
87
+ export async function translateToCommand(
88
+ nl: string,
89
+ perms: Permissions,
90
+ sessionCmds: string[],
91
+ onToken?: (partial: string) => void
92
+ ): Promise<string> {
93
+ // cache hit — instant
94
+ const cached = cacheGet(nl);
95
+ if (cached) { onToken?.(cached); return cached; }
96
+
97
+ const model = pickModel(nl);
98
+ let result = "";
99
+
100
+ if (onToken) {
101
+ // streaming path
102
+ const stream = await client.messages.stream({
103
+ model,
104
+ max_tokens: 256,
105
+ system: buildSystemPrompt(perms, sessionCmds),
106
+ messages: [{ role: "user", content: nl }],
107
+ });
108
+ for await (const chunk of stream) {
109
+ if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
110
+ result += chunk.delta.text;
111
+ onToken(result.trim());
112
+ }
113
+ }
114
+ } else {
115
+ const message = await client.messages.create({
116
+ model,
117
+ max_tokens: 256,
118
+ system: buildSystemPrompt(perms, sessionCmds),
119
+ messages: [{ role: "user", content: nl }],
120
+ });
121
+ const block = message.content[0];
122
+ if (block.type !== "text") throw new Error("Unexpected response type");
123
+ result = block.text;
124
+ }
125
+
126
+ const text = result.trim();
127
+ if (text.startsWith("BLOCKED:")) throw new Error(text);
128
+ cacheSet(nl, text);
129
+ return text;
130
+ }
131
+
132
+ // ── prefetch ──────────────────────────────────────────────────────────────────
133
+ // Silently warm the cache after a command runs — no await, fire and forget
64
134
 
65
- return `You are a terminal assistant. The user will describe what they want to do in plain English.
66
- Your job is to output ONLY the exact shell command(s) to accomplish this — nothing else.
67
- No explanation. No markdown. No backticks. Just the raw command.
68
- If multiple commands are needed, join them with && or use a newline.
69
- Assume macOS/Linux zsh environment.
70
- Current working directory: ${cwd}${restrictionBlock}${contextBlock}`;
135
+ export function prefetchNext(
136
+ lastNl: string,
137
+ perms: Permissions,
138
+ sessionCmds: string[]
139
+ ) {
140
+ // Only prefetch if we don't have it cached already
141
+ if (cacheGet(lastNl)) return;
142
+ translateToCommand(lastNl, perms, sessionCmds).catch(() => {});
71
143
  }
72
144
 
73
145
  // ── explain ───────────────────────────────────────────────────────────────────
@@ -76,7 +148,7 @@ export async function explainCommand(command: string): Promise<string> {
76
148
  const message = await client.messages.create({
77
149
  model: "claude-haiku-4-5-20251001",
78
150
  max_tokens: 128,
79
- system: "Explain what this shell command does in one plain English sentence. No markdown. No code blocks. Just a sentence.",
151
+ system: "Explain what this shell command does in one plain English sentence. No markdown, no code blocks.",
80
152
  messages: [{ role: "user", content: command }],
81
153
  });
82
154
  const block = message.content[0];
@@ -94,35 +166,13 @@ export async function fixCommand(
94
166
  sessionCmds: string[]
95
167
  ): Promise<string> {
96
168
  const message = await client.messages.create({
97
- model: "claude-opus-4-6",
98
- max_tokens: 256,
99
- system: buildSystemPrompt(perms, sessionCmds),
100
- messages: [
101
- {
102
- role: "user",
103
- content: `I wanted to: ${originalNl}\nI ran: ${failedCommand}\nIt failed with:\n${errorOutput}\n\nGive me the corrected command.`,
104
- },
105
- ],
106
- });
107
- const block = message.content[0];
108
- if (block.type !== "text") throw new Error("Unexpected response type");
109
- const text = block.text.trim();
110
- if (text.startsWith("BLOCKED:")) throw new Error(text);
111
- return text;
112
- }
113
-
114
- // ── translate ─────────────────────────────────────────────────────────────────
115
-
116
- export async function translateToCommand(
117
- nl: string,
118
- perms: Permissions,
119
- sessionCmds: string[]
120
- ): Promise<string> {
121
- const message = await client.messages.create({
122
- model: "claude-opus-4-6",
169
+ model: "claude-sonnet-4-6",
123
170
  max_tokens: 256,
124
171
  system: buildSystemPrompt(perms, sessionCmds),
125
- messages: [{ role: "user", content: nl }],
172
+ messages: [{
173
+ role: "user",
174
+ content: `I wanted to: ${originalNl}\nI ran: ${failedCommand}\nError:\n${errorOutput}\n\nGive me the corrected command only.`,
175
+ }],
126
176
  });
127
177
  const block = message.content[0];
128
178
  if (block.type !== "text") throw new Error("Unexpected response type");
package/src/cache.ts ADDED
@@ -0,0 +1,43 @@
1
+ // In-memory LRU cache + disk persistence for command translations
2
+
3
+ import { existsSync, readFileSync, writeFileSync } from "fs";
4
+ import { homedir } from "os";
5
+ import { join } from "path";
6
+
7
+ const CACHE_FILE = join(homedir(), ".terminal", "cache.json");
8
+ const MAX_ENTRIES = 500;
9
+
10
+ type CacheMap = Record<string, string>;
11
+
12
+ let mem: CacheMap = {};
13
+
14
+ export function loadCache() {
15
+ if (!existsSync(CACHE_FILE)) return;
16
+ try { mem = JSON.parse(readFileSync(CACHE_FILE, "utf8")); } catch {}
17
+ }
18
+
19
+ function persistCache() {
20
+ try { writeFileSync(CACHE_FILE, JSON.stringify(mem)); } catch {}
21
+ }
22
+
23
+ /** Normalize a natural language query for cache lookup */
24
+ export function normalizeNl(nl: string): string {
25
+ return nl
26
+ .toLowerCase()
27
+ .trim()
28
+ .replace(/[^a-z0-9\s]/g, "") // strip punctuation
29
+ .replace(/\s+/g, " ");
30
+ }
31
+
32
+ export function cacheGet(nl: string): string | null {
33
+ return mem[normalizeNl(nl)] ?? null;
34
+ }
35
+
36
+ export function cacheSet(nl: string, command: string) {
37
+ const key = normalizeNl(nl);
38
+ // evict oldest if full
39
+ const keys = Object.keys(mem);
40
+ if (keys.length >= MAX_ENTRIES) delete mem[keys[0]];
41
+ mem[key] = command;
42
+ persistCache();
43
+ }
package/src/history.ts CHANGED
@@ -29,6 +29,7 @@ export interface Permissions {
29
29
 
30
30
  export interface Config {
31
31
  onboarded: boolean;
32
+ confirm: boolean; // ask before running — false = run immediately
32
33
  permissions: Permissions;
33
34
  }
34
35
 
@@ -42,6 +43,7 @@ export const DEFAULT_PERMISSIONS: Permissions = {
42
43
 
43
44
  export const DEFAULT_CONFIG: Config = {
44
45
  onboarded: false,
46
+ confirm: false,
45
47
  permissions: DEFAULT_PERMISSIONS,
46
48
  };
47
49
 
@@ -77,6 +79,7 @@ export function loadConfig(): Config {
77
79
  return {
78
80
  ...DEFAULT_CONFIG,
79
81
  ...saved,
82
+ confirm: saved.confirm ?? false,
80
83
  permissions: { ...DEFAULT_PERMISSIONS, ...(saved.permissions ?? {}) },
81
84
  };
82
85
  } catch {