nou-cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -29,6 +29,7 @@
29
29
  # A LiteLLM proxy, or any hosted OpenAI-compatible gateway (full cloud context, unlike NOU_BASE_URL):
30
30
  # LITELLM_BASE_URL=http://localhost:4000/v1
31
31
  # LITELLM_API_KEY=
32
+ # (LITELLM_PROXY_API_BASE and LITELLM_PROXY_API_KEY, the names LiteLLM's SDK uses, work too.)
32
33
  # NOU_MODEL=litellm:<a model name from your proxy>
33
34
  # Or skip this file: /connect <provider> inside Nou saves keys to ~/.nou/credentials.json.
34
35
 
package/README.md CHANGED
@@ -76,22 +76,25 @@ Every change is reviewed like a code review:
76
76
 
77
77
  | Key | Does |
78
78
  | --- | --- |
79
- | 🖱️ Mouse | wheel scrolls the pane under the pointer · click a pane to focus it · click a file to open it (hold <kbd>Shift</kbd> to select text; turn off in settings) |
79
+ | 🖱️ Mouse | wheel scrolls the pane under the pointer · click a pane to focus it · click a file to open it · drag a border between panes to resize them (hold <kbd>Shift</kbd> to select text, or press <kbd>m</kbd> to turn the mouse off) |
80
80
  | <kbd>Shift</kbd>+<kbd>↑</kbd>/<kbd>↓</kbd>, <kbd>PgUp</kbd>/<kbd>PgDn</kbd> | scroll the chat history |
81
81
  | <kbd>Tab</kbd> | switch between files, code and chat |
82
+ | <kbd>Shift</kbd>+<kbd>Tab</kbd> | ask → auto-edit (edits apply without asking) → auto (commands too); everything stays undoable |
82
83
  | <kbd>Ctrl</kbd>+<kbd>P</kbd> | fuzzy-find a file |
83
84
  | <kbd>e</kbd> | edit the open file (<kbd>Ctrl</kbd>+<kbd>S</kbd> save · <kbd>Ctrl</kbd>+<kbd>Z</kbd>/<kbd>Y</kbd> undo/redo · <kbd>Ctrl</kbd>+<kbd>K</kbd> info · <kbd>Ctrl</kbd>+<kbd>G</kbd> definition · <kbd>Esc</kbd> close) |
84
85
  | <kbd>p</kbd> | Problems panel: every error and warning, <kbd>Enter</kbd> jumps to it |
85
86
  | <kbd>u</kbd> | undo Nou's last edit |
86
87
  | <kbd>v</kbd> | diff the open file vs git `HEAD`, Nou's last edit, or this session · <kbd>n</kbd>/<kbd>N</kbd> jump between changes · <kbd>[</kbd>/<kbd>]</kbd> previous/next file in a session diff |
87
88
  | <kbd>c</kbd> | in the file tree: show only changed files |
88
- | <kbd>f</kbd> | follow mode: jump to files as Nou reads and edits them |
89
+ | <kbd>f</kbd> | follow mode (off by default): jump to files as Nou reads and edits them |
90
+ | <kbd>&lt;</kbd> / <kbd>&gt;</kbd> / <kbd>=</kbd> | make the focused pane narrower / wider / reset the layout |
91
+ | <kbd>m</kbd> | mouse on/off, so you can select and copy text in the terminal |
89
92
  | <kbd>s</kbd> | settings: model, preferences, plugins |
90
- | <kbd>y</kbd> / <kbd>n</kbd> | accept / reject a proposed edit or command |
93
+ | <kbd>y</kbd> / <kbd>n</kbd> | accept / reject a proposed edit or command, from any pane; the rest of Nou keeps working while it waits (<kbd>o</kbd> shows the edit) |
91
94
  | <kbd>Esc</kbd> | stop Nou · close a diff · back to chat |
92
95
  | <kbd>Ctrl</kbd>+<kbd>C</kbd> | stop Nou while it works · press twice to quit |
93
96
 
94
- Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/help`
97
+ Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]` · `/pull <model>` · `/engine [name]` · `/auto [ask|auto-edit|auto]` · `/copy [code|file]` · `/undo` · `/rewind` · `/history` · `/diff [file or n]` · `/sessions` · `/problems` · `/compact` · `/clear` · `/help`
95
98
 
96
99
  ## 🧠 Models
97
100
 
@@ -108,9 +111,9 @@ If you haven't set a key, Nou uses the API key from Claude Code (`~/.claude/sett
108
111
  | 🖥️ LM Studio | 🟢 local | start its local server |
109
112
  | ⚙️ llama.cpp server | 🟢 local | start `llama-server` |
110
113
  | 🔧 Any OpenAI-compatible server | 🟢 local | `NOU_BASE_URL`, optional `NOU_API_KEY` |
111
- | 🚅 LiteLLM proxy or any hosted gateway | ☁️ cloud | `/connect litellm`, or `LITELLM_BASE_URL` + `LITELLM_API_KEY` |
114
+ | 🚅 LiteLLM proxy or any hosted gateway | ☁️ cloud | `/connect litellm`, or `LITELLM_BASE_URL` + `LITELLM_API_KEY` (LiteLLM's `LITELLM_PROXY_API_BASE` + `LITELLM_PROXY_API_KEY` work too) |
112
115
  | 🟣 Anthropic (Claude) | ☁️ cloud | `ANTHROPIC_API_KEY` |
113
- | ⚪ OpenAI | ☁️ cloud | `OPENAI_API_KEY` |
116
+ | ⚪ OpenAI | ☁️ cloud | `OPENAI_API_KEY`, optional `OPENAI_BASE_URL` |
114
117
  | 🔷 Google Gemini | ☁️ cloud | `GEMINI_API_KEY` |
115
118
  | 🔀 OpenRouter | ☁️ cloud | `OPENROUTER_API_KEY` |
116
119
  | ⚡ Groq | ☁️ cloud | `GROQ_API_KEY` |
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "crypto";
2
2
  import { toolDefs } from "../tools/index.js";
3
3
  import { applyChange, capOutput, fileTree, isInProject, proposeEdit, proposeWrite, readForModel, runCommand, toRelative, } from "../commands/index.js";
4
- import { describe } from "../providers/index.js";
4
+ import { describe, keyHint } from "../providers/index.js";
5
5
  import { newCallId } from "../providers/ids.js";
6
6
  import { globFiles, grep } from "../search/index.js";
7
7
  /** Model requests per user message before stopping; real tasks (explore, edit, test, fix) take a few dozen. */
@@ -272,7 +272,8 @@ export class Agent {
272
272
  this.hooks.onEntry({ kind: "info", text: "Cancelled." });
273
273
  }
274
274
  else {
275
- this.hooks.onEntry({ kind: "error", text: `${describe(this.choice)}: ${error.message}` });
275
+ const hint = keyHint(this.choice.provider, String(error.message));
276
+ this.hooks.onEntry({ kind: "error", text: `${describe(this.choice)}: ${error.message}${hint ? `\n${hint}` : ""}` });
276
277
  }
277
278
  this.dropUnansweredCalls();
278
279
  }
@@ -9,13 +9,14 @@ export const DEFAULT_SETTINGS = {
9
9
  model: "",
10
10
  engine: "nou",
11
11
  effort: "high",
12
- followMode: true,
12
+ followMode: false,
13
13
  autoApproveEdits: false,
14
14
  autoApproveCommands: false,
15
15
  gitContext: true,
16
16
  autoSave: "delay",
17
17
  mouse: true,
18
18
  reuseToolConfig: true,
19
+ paneWidths: null,
19
20
  plugins: {},
20
21
  trustedPlugins: {},
21
22
  };
@@ -1,4 +1,5 @@
1
1
  import { loadSettings, saveSettings } from "../config/index.js";
2
+ import { originOf } from "../config/origins.js";
2
3
  import { anthropicProvider } from "./anthropic.js";
3
4
  import { ollamaProvider } from "./ollama.js";
4
5
  import { OPENAI_COMPATIBLE, openAICompatibleProvider } from "./openai.js";
@@ -24,6 +25,21 @@ export function resolveModel(spec) {
24
25
  return { provider: providers[0], model: trimmed };
25
26
  }
26
27
  export const describe = (choice) => `${choice.provider.id}:${choice.model}`;
28
+ /** Whether a provider error means the API key is wrong (rather than, say, the server being down). */
29
+ export const rejectedKey = (error) => /\b(401|403)\b|api key|unauthori[sz]ed|authentication/i.test(error);
30
+ /**
31
+ * For an error that says the key was rejected: which key, where it was set, and how to fix it.
32
+ * The usual cause is a project's .env holding a key for the app rather than for Nou.
33
+ */
34
+ export function keyHint(provider, error) {
35
+ const key = provider.connect?.find((f) => f.secret)?.env;
36
+ if (!key || !rejectedKey(error))
37
+ return null;
38
+ const origin = originOf(key);
39
+ const others = providers.filter((p) => p !== provider && !p.local && !p.unavailable()).map((p) => p.id);
40
+ return `The ${key}${origin ? ` from ${origin}` : ""} was rejected. Enter another with /connect ${provider.id}`
41
+ + (others.length ? `, or switch with /model ${others.join(" · /model ")}.` : ", or connect another provider (e.g. /connect litellm).");
42
+ }
27
43
  /** NOU_MODEL wins, then the model saved in settings, then local Ollama. */
28
44
  export function initialModel() {
29
45
  if (process.env.NOU_MODEL)
@@ -2,14 +2,16 @@ import OpenAI from "openai";
2
2
  import { newCallId } from "./ids.js";
3
3
  /** Everything that speaks the OpenAI chat completions API. */
4
4
  export const OPENAI_COMPATIBLE = [
5
- { id: "openai", label: "OpenAI", baseURL: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY", defaultModel: "gpt-5", local: false },
5
+ { id: "openai", label: "OpenAI", baseURL: "https://api.openai.com/v1", urlEnv: "OPENAI_BASE_URL", keyEnv: "OPENAI_API_KEY", defaultModel: "gpt-5", local: false },
6
6
  { id: "gemini", label: "Google Gemini", baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/", keyEnv: "GEMINI_API_KEY", defaultModel: "gemini-2.5-pro", local: false },
7
7
  { id: "openrouter", label: "OpenRouter", baseURL: "https://openrouter.ai/api/v1", keyEnv: "OPENROUTER_API_KEY", defaultModel: "anthropic/claude-opus-5-5", local: false },
8
8
  { id: "groq", label: "Groq", baseURL: "https://api.groq.com/openai/v1", keyEnv: "GROQ_API_KEY", defaultModel: "llama-3.3-70b-versatile", local: false },
9
9
  { id: "deepseek", label: "DeepSeek", baseURL: "https://api.deepseek.com/v1", keyEnv: "DEEPSEEK_API_KEY", defaultModel: "deepseek-chat", local: false },
10
10
  { id: "fireworks", label: "Fireworks AI", baseURL: "https://api.fireworks.ai/inference/v1", keyEnv: "FIREWORKS_API_KEY", defaultModel: "accounts/fireworks/models/kimi-k2-instruct", local: false, keyHint: "fireworks.ai/account/api-keys" },
11
11
  // A LiteLLM proxy, or any hosted OpenAI-compatible gateway: cloud-sized context, unlike "custom".
12
- { id: "litellm", label: "LiteLLM / gateway", baseURL: "", urlEnv: "LITELLM_BASE_URL", keyEnv: "LITELLM_API_KEY", defaultModel: "gpt-5", local: false, keyHint: "your proxy's virtual key or master key" },
12
+ { id: "litellm", label: "LiteLLM / gateway", baseURL: "", urlEnv: "LITELLM_BASE_URL", keyEnv: "LITELLM_API_KEY", defaultModel: "gpt-5", local: false, keyHint: "your proxy's virtual key or master key",
13
+ // The names LiteLLM's own SDK uses for a proxy (its litellm_proxy provider).
14
+ aliases: { url: ["LITELLM_PROXY_API_BASE"], key: ["LITELLM_PROXY_API_KEY"] } },
13
15
  { id: "lmstudio", label: "LM Studio", baseURL: "http://localhost:1234/v1", urlEnv: "LMSTUDIO_BASE_URL", defaultModel: "local-model", local: true },
14
16
  { id: "llamacpp", label: "llama.cpp server", baseURL: "http://localhost:8080/v1", urlEnv: "LLAMACPP_BASE_URL", defaultModel: "local-model", local: true },
15
17
  { id: "custom", label: "Custom OpenAI-compatible", baseURL: "", urlEnv: "NOU_BASE_URL", keyEnv: "NOU_API_KEY", defaultModel: "default", local: true },
@@ -41,8 +43,9 @@ function toOpenAI(system, messages) {
41
43
  }
42
44
  export function openAICompatibleProvider(preset) {
43
45
  // Read when used, not when loaded: keys found in other tools' config (see setup/) are added at startup.
44
- const baseURL = () => (preset.urlEnv && process.env[preset.urlEnv]) || preset.baseURL;
45
- const apiKey = () => (preset.keyEnv && process.env[preset.keyEnv]) || (preset.local ? "not-needed" : "");
46
+ const first = (names) => names.map((n) => n && process.env[n]).find(Boolean);
47
+ const baseURL = () => first([preset.urlEnv, ...(preset.aliases?.url ?? [])]) || preset.baseURL;
48
+ const apiKey = () => first([preset.keyEnv, ...(preset.aliases?.key ?? [])]) || (preset.local ? "not-needed" : "");
46
49
  // Rebuilt when the URL or key changes (they can be entered while Nou runs).
47
50
  let client = null;
48
51
  const getClient = () => {
@@ -66,8 +66,17 @@ function parse(file, text) {
66
66
  load(name);
67
67
  if (!lang || typeof lang === "string" || !parser)
68
68
  return null;
69
- parser.setLanguage(lang);
70
- return parser.parse(text);
69
+ try {
70
+ parser.setLanguage(lang);
71
+ return parser.parse(text);
72
+ }
73
+ catch {
74
+ // Some shipped grammars call into the runtime with functions it doesn't have (e.g. YAML's scanner).
75
+ // Don't use that grammar again; callers fall back to highlight.js.
76
+ languages.set(name, "failed");
77
+ parser = new Parser();
78
+ return null;
79
+ }
71
80
  }
72
81
  export function hasGrammar(file) {
73
82
  return grammarFor(file) !== undefined;
@@ -24,10 +24,14 @@ import { FileTree } from "./FileTree.js";
24
24
  import { CodeView } from "./CodeView.js";
25
25
  import { Editor } from "./Editor.js";
26
26
  import { Chat } from "./Chat.js";
27
+ import { copyToClipboard } from "./clipboard.js";
27
28
  import { Header } from "./Header.js";
28
29
  import { QuickOpen, listAllFiles } from "./QuickOpen.js";
29
30
  import { Settings } from "./Settings.js";
30
31
  const FOCUS_ORDER = ["tree", "code", "chat"];
32
+ const MIN_TREE = 14;
33
+ const MIN_CODE = 20;
34
+ const MIN_CHAT = 24;
31
35
  const NO_LINES = new Set();
32
36
  const HELP = `Commands:
33
37
  /settings models, preferences and plugins
@@ -37,6 +41,8 @@ const HELP = `Commands:
37
41
  /connect <provider> enter an API key (and URL), e.g. /connect fireworks /connect litellm
38
42
  /pull <model> download an Ollama model
39
43
  /engine [name] which agent answers: nou, claude-code, codex or pi
44
+ /auto [ask|auto-edit|auto] how much Nou does without asking (also: Shift+Tab)
45
+ /copy [code|file] copy the last reply, its last code block, or the open file
40
46
  /plugins your plugins and the starter ones you can add
41
47
  /plugins add <name> add a starter plugin (copied to ~/.nou/plugins to edit)
42
48
  /plugins new <name> start your own plugin in .nou/plugins/
@@ -50,6 +56,8 @@ const HELP = `Commands:
50
56
  /version which version of Nou this is
51
57
  Keys:
52
58
  Ctrl+P find a file Tab switch pane Esc stop / back
59
+ Shift+Tab ask / auto-edit / auto < > resize pane = reset
60
+ m mouse on/off (off: select text to copy; Shift+drag also works in most terminals)
53
61
  e edit open file v diff (git / Nou / session) f follow mode
54
62
  p problems u undo Nou's edit s settings
55
63
  c (in files) show only changed files Ctrl+C twice quit
@@ -124,6 +132,11 @@ export function App({ initialModel, permContext }) {
124
132
  const [treeClick, setTreeClick] = useState(null);
125
133
  // Pane geometry from the last render, so mouse events can be mapped to the pane under the pointer.
126
134
  const layout = useRef({ treeWidth: 0, codeWidth: 0, paneHeight: 0 });
135
+ /** Pane widths while a border is dragged (saved on release); the saved ones otherwise. */
136
+ const [paneWidths, setPaneWidths] = useState(settings.paneWidths);
137
+ const paneWidthsRef = useRef(paneWidths);
138
+ paneWidthsRef.current = paneWidths;
139
+ const dragging = useRef(null);
127
140
  const pullAbort = useRef(null);
128
141
  const diagnosticsRef = useRef(null);
129
142
  diagnosticsRef.current ??= new DiagnosticsManager();
@@ -132,6 +145,8 @@ export function App({ initialModel, permContext }) {
132
145
  timelineRef.current ??= new Timeline(() => setHistoryVersion((v) => v + 1));
133
146
  const timeline = timelineRef.current;
134
147
  const followRef = useRef(follow);
148
+ /** Whether the editor is open, for the agent hooks (which outlive renders). */
149
+ const draftActiveRef = useRef(false);
135
150
  followRef.current = follow;
136
151
  const settingsRef = useRef(settings);
137
152
  settingsRef.current = settings;
@@ -169,8 +184,8 @@ export function App({ initialModel, permContext }) {
169
184
  }
170
185
  const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
171
186
  setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
172
- // Diffs awaiting approval are always shown, follow mode or not.
173
- if (status === "pending" || followRef.current) {
187
+ // Diffs awaiting approval are shown unless you're in the middle of editing a file (the chat says what's waiting).
188
+ if ((status === "pending" && !draftActiveRef.current) || followRef.current) {
174
189
  setOpenFile(change.path);
175
190
  setContent(status === "applied" ? change.after : readText(change.path));
176
191
  setDiffMode("off");
@@ -280,18 +295,19 @@ export function App({ initialModel, permContext }) {
280
295
  if (!isInstalled(target)) {
281
296
  const controller = new AbortController();
282
297
  pullAbort.current = controller;
283
- info(`Installing ${target.label} (${target.package}) into ~/.nou/engines. Esc to stop.`);
284
- setBusy(`Installing ${target.label}...`);
298
+ info(`${target.label} isn't installed yet, so Nou is installing it now: npm install ${target.package} into ~/.nou/engines. This happens once and can take a minute. Esc to stop.`);
299
+ setBusy(`Installing the ${target.label} engine (npm)...`);
285
300
  try {
286
- await installEngine(target, (line) => setBusy(`Installing ${target.label}: ${line.slice(0, 80)}`), controller.signal);
301
+ await installEngine(target, (line) => setBusy(`Installing the ${target.label} engine (npm): ${line.slice(0, 70)}`), controller.signal);
287
302
  }
288
303
  catch (error) {
289
- return info(controller.signal.aborted ? `Stopped installing ${target.label}.` : `Couldn't install ${target.label}: ${error.message}`, "error");
304
+ return info(controller.signal.aborted ? `Stopped installing ${target.label}. Nothing changed; you're still on ${engineInfo(agentRef.current.id).label}.` : `Couldn't install ${target.label}: ${error.message}`, "error");
290
305
  }
291
306
  finally {
292
307
  pullAbort.current = null;
293
308
  setBusy(null);
294
309
  }
310
+ info(`Installed ${target.label} (${target.package}).`);
295
311
  }
296
312
  agentRef.current.dispose?.();
297
313
  const engine = createEngine(id, choice, engineConfig.current.system ?? buildSystemPrompt(permContext), hooksRef.current);
@@ -365,16 +381,40 @@ export function App({ initialModel, permContext }) {
365
381
  approval?.resolve(ok);
366
382
  setApproval(null);
367
383
  };
384
+ /** Shows the edit waiting for approval in the code view. */
385
+ const openPending = () => {
386
+ const file = approval?.request.kind === "change" ? approval.request.change.path : null;
387
+ if (!file || !settleDraft(file))
388
+ return;
389
+ setEditing(false);
390
+ open(file);
391
+ setReveal(file);
392
+ setFocus("code");
393
+ };
394
+ /** Ask → Auto-edit (edits apply without asking) → Auto (commands too) → Ask. Undo and diffs work the same in each. */
395
+ const autoMode = settings.autoApproveCommands ? "auto" : settings.autoApproveEdits ? "auto-edit" : "ask";
396
+ const cycleAutoMode = (to) => {
397
+ const next = to ?? (autoMode === "ask" ? "auto-edit" : autoMode === "auto-edit" ? "auto" : "ask");
398
+ updateSettings({ autoApproveEdits: next !== "ask", autoApproveCommands: next === "auto" });
399
+ // Whatever is waiting now falls under the new mode too.
400
+ const kind = approval?.request.kind;
401
+ if (kind && (next === "auto" || (next === "auto-edit" && kind === "change")))
402
+ answer(true);
403
+ info(next === "ask" ? "Ask mode: Nou asks before every edit and command."
404
+ : next === "auto-edit" ? "Auto-edit mode: edits apply without asking; Nou still asks before commands. Review with the diff view, u to undo."
405
+ : "Auto mode: edits and commands run without asking. Review with the diff view, u to undo. Shift+Tab to change.");
406
+ };
368
407
  const record = openFile ? changes.get(openFile) : undefined;
369
408
  const pendingChange = approval?.request.kind === "change" ? approval.request.change : null;
370
409
  const gitChange = useMemo(() => (diffMode === "git" && openFile ? diffAgainstHead(openFile, content) : null), [diffMode, openFile, content]);
371
410
  const sessionId = sessionView?.id ?? agentRef.current.session;
372
411
  const sessionFiles = useMemo(() => timeline.sessionChanges(sessionId), [sessionId, historyVersion]);
373
412
  const sessionChange = openFile ? sessionFiles.find((c) => c.path === openFile) ?? null : null;
374
- const diff = pendingChange ?? (diffMode === "git" ? gitChange
413
+ const diff = (pendingChange && pendingChange.path === openFile ? pendingChange : null) ?? (diffMode === "git" ? gitChange
375
414
  : diffMode === "nou" && record ? record.change
376
415
  : diffMode === "session" ? sessionChange : null);
377
- const showEditor = editing && !!openFile && !pendingChange;
416
+ const showEditor = editing && !!openFile && !(pendingChange && pendingChange.path === openFile);
417
+ draftActiveRef.current = showEditor;
378
418
  // Check Nou's proposed edit before you approve it: does it add problems?
379
419
  useEffect(() => {
380
420
  setPendingNote(null);
@@ -415,14 +455,37 @@ export function App({ initialModel, permContext }) {
415
455
  setExitArmed(true);
416
456
  setTimeout(() => setExitArmed(false), 2000);
417
457
  };
458
+ /** Moves a pane border to column `x` and keeps every pane usable. */
459
+ const moveBorder = (border, x) => {
460
+ const { treeWidth, codeWidth } = layout.current;
461
+ const chatWidth = columns - treeWidth - codeWidth;
462
+ const tree = border === "tree" ? Math.min(Math.max(x + 1, MIN_TREE), columns - chatWidth - MIN_CODE) : treeWidth;
463
+ const chat = border === "chat" ? Math.min(Math.max(columns - x, MIN_CHAT), columns - treeWidth - MIN_CODE) : chatWidth;
464
+ const next = { tree: tree / columns, chat: chat / columns };
465
+ setPaneWidths(next);
466
+ return next;
467
+ };
418
468
  // Mouse: the wheel scrolls whatever pane is under the pointer; a click focuses that pane
419
- // (and in the file tree, opens the clicked file).
469
+ // (and in the file tree, opens the clicked file). Dragging a border between panes resizes them.
420
470
  useMouse((event) => {
421
471
  if (view !== "main" || quickOpen)
422
472
  return;
423
473
  const { treeWidth, codeWidth, paneHeight } = layout.current;
474
+ if (event.kind === "drag")
475
+ return void (dragging.current && moveBorder(dragging.current, event.x));
476
+ if (event.kind === "release") {
477
+ if (dragging.current)
478
+ updateSettings({ paneWidths: paneWidthsRef.current });
479
+ dragging.current = null;
480
+ return;
481
+ }
424
482
  if (event.y < 1 || event.y > paneHeight)
425
483
  return; // header or status bar
484
+ // Each border is two columns: the left pane's edge and the right pane's.
485
+ if (event.kind === "click" && Math.abs(event.x - (treeWidth - 0.5)) <= 1)
486
+ return void (dragging.current = "tree");
487
+ if (event.kind === "click" && Math.abs(event.x - (treeWidth + codeWidth - 0.5)) <= 1)
488
+ return void (dragging.current = "chat");
426
489
  const pane = event.x < treeWidth ? "tree" : event.x < treeWidth + codeWidth ? "code" : "chat";
427
490
  if (event.kind === "wheel") {
428
491
  setWheel((w) => ({ pane, dir: event.dir, nonce: (w?.nonce ?? 0) + 1 }));
@@ -611,16 +674,17 @@ export function App({ initialModel, permContext }) {
611
674
  setDiffMode((m) => order[(order.indexOf(m) + 1) % order.length]);
612
675
  };
613
676
  useInput((input, key) => {
614
- if (approval) {
615
- if (input === "y" || input === "Y")
616
- answer(true);
617
- else if (input === "n" || input === "N")
618
- answer(false);
619
- else if (key.escape) {
677
+ // A waiting approval doesn't freeze anything: y/n answer it from the tree or code view (the chat
678
+ // handles them itself when its input is empty), Esc stops the turn, and every other key works as usual.
679
+ if (approval && !(showEditor && focus === "code")) {
680
+ if (key.escape) {
620
681
  agentRef.current?.cancel();
621
- answer(false);
682
+ return answer(false);
622
683
  }
623
- return;
684
+ if (focus !== "chat" && /^[yYnN]$/.test(input))
685
+ return answer(/[yY]/.test(input));
686
+ if (focus !== "chat" && input === "o" && pendingChange)
687
+ return openPending();
624
688
  }
625
689
  if (key.ctrl && input === "p") {
626
690
  setQuickOpen(true);
@@ -628,9 +692,10 @@ export function App({ initialModel, permContext }) {
628
692
  }
629
693
  if (showEditor && focus === "code")
630
694
  return; // the editor owns the keyboard
695
+ if (key.tab && key.shift)
696
+ return cycleAutoMode();
631
697
  if (key.tab) {
632
- const step = key.shift ? -1 : 1;
633
- setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + step + FOCUS_ORDER.length) % FOCUS_ORDER.length]);
698
+ setFocus((f) => FOCUS_ORDER[(FOCUS_ORDER.indexOf(f) + 1) % FOCUS_ORDER.length]);
634
699
  return;
635
700
  }
636
701
  if (key.escape) {
@@ -650,6 +715,10 @@ export function App({ initialModel, permContext }) {
650
715
  return; // letters belong to the text box
651
716
  if (input === "f")
652
717
  setFollow((f) => !f);
718
+ else if (input === "m") {
719
+ updateSettings({ mouse: !settings.mouse });
720
+ info(settings.mouse ? "Mouse off: drag to select text and copy it as usual in your terminal. m turns it back on." : "Mouse on: wheel scrolls, click focuses, drag borders to resize.");
721
+ }
653
722
  else if (input === "p") {
654
723
  setCodePanel((p) => (p === "problems" ? "code" : "problems"));
655
724
  setEditing(false);
@@ -657,6 +726,16 @@ export function App({ initialModel, permContext }) {
657
726
  }
658
727
  else if (input === "u")
659
728
  undoLast();
729
+ else if (input === "<" || input === ">") {
730
+ // Moves the focused pane's inner border: the tree's right edge, or the code view's edge with the chat.
731
+ const { treeWidth, codeWidth } = layout.current;
732
+ const step = input === ">" ? 2 : -2;
733
+ updateSettings({ paneWidths: moveBorder(focus === "tree" ? "tree" : "chat", (focus === "tree" ? treeWidth - 1 : treeWidth + codeWidth) + step) });
734
+ }
735
+ else if (input === "=") {
736
+ setPaneWidths(null);
737
+ updateSettings({ paneWidths: null });
738
+ }
660
739
  else if (input === "v")
661
740
  cycleDiff();
662
741
  else if ((input === "]" || input === "[") && diffMode === "session")
@@ -774,13 +853,31 @@ ${snippet}
774
853
  }
775
854
  return chooseModel(resolveModel(arg));
776
855
  }
856
+ case "copy": {
857
+ const reply = [...entries].reverse().find((e) => e.kind === "assistant")?.text;
858
+ const blocks = reply ? [...reply.matchAll(/```[^\n]*\n([\s\S]*?)```/g)].map((m) => m[1]) : [];
859
+ const what = arg === "file" ? (openFile ? readText(openFile) : null) : arg === "code" ? blocks[blocks.length - 1] ?? null : reply ?? null;
860
+ if (arg && arg !== "file" && arg !== "code")
861
+ return info("Usage: /copy (the last reply) · /copy code (its last code block) · /copy file (the open file)", "error");
862
+ if (!what)
863
+ return info(arg === "file" ? "No file is open." : arg === "code" ? "The last reply has no code block." : "Nothing to copy yet.", "error");
864
+ const ok = await copyToClipboard(what);
865
+ return info(ok ? `Copied ${arg === "file" ? openFile : arg === "code" ? "the code block" : "the last reply"} (${what.split("\n").length} lines).` : "Couldn't reach the clipboard. Press m to turn off the mouse and select the text yourself.", ok ? "info" : "error");
866
+ }
867
+ case "auto": {
868
+ if (!arg)
869
+ return cycleAutoMode();
870
+ if (arg === "ask" || arg === "auto-edit" || arg === "auto")
871
+ return cycleAutoMode(arg);
872
+ return info("Usage: /auto [ask|auto-edit|auto]", "error");
873
+ }
777
874
  case "engine": {
778
875
  if (arg && isEngineId(arg))
779
876
  return switchEngine(arg);
780
877
  if (arg)
781
878
  return info(`Unknown engine ${arg}. Choose one of: ${ENGINES.map((e) => e.id).join(", ")}`, "error");
782
879
  const current = agentRef.current.id;
783
- const lines = ENGINES.map((e) => ` ${e.id === current ? "●" : "○"} ${e.id.padEnd(12)} ${e.description}${isInstalled(e) ? "" : " (installs on first use)"}`);
880
+ const lines = ENGINES.map((e) => ` ${e.id === current ? "●" : "○"} ${e.id.padEnd(12)} ${e.description}${isInstalled(e) ? "" : ` (not installed: switching runs npm install ${e.package})`}`);
784
881
  return info(`Engines (/engine <name> to switch, or /settings → Engine):\n${lines.join("\n")}`);
785
882
  }
786
883
  case "pull": {
@@ -892,9 +989,9 @@ ${snippet}
892
989
  } }));
893
990
  }
894
991
  const height = Math.max(10, rows - 2);
895
- const treeWidth = Math.max(24, Math.floor(columns * 0.2));
896
- const chatWidth = Math.max(34, Math.floor(columns * 0.3));
897
- const codeWidth = Math.max(20, columns - treeWidth - chatWidth);
992
+ const treeWidth = paneWidths ? Math.max(MIN_TREE, Math.round(columns * paneWidths.tree)) : Math.max(24, Math.floor(columns * 0.2));
993
+ const chatWidth = paneWidths ? Math.max(MIN_CHAT, Math.min(Math.round(columns * paneWidths.chat), columns - treeWidth - MIN_CODE)) : Math.max(34, Math.floor(columns * 0.3));
994
+ const codeWidth = Math.max(MIN_CODE, columns - treeWidth - chatWidth);
898
995
  layout.current = { treeWidth, codeWidth, paneHeight: height };
899
996
  const wheelFor = (pane) => (wheel?.pane === pane ? wheel : null);
900
997
  const edited = new Set([...changes].filter(([, c]) => c.status === "applied").map(([p]) => p));
@@ -908,19 +1005,32 @@ ${snippet}
908
1005
  : diff ? { label: "DIFF", color: C.accent2 }
909
1006
  : busy ? { label: "WORKING", color: C.accent2 }
910
1007
  : { label: focus.toUpperCase(), color: C.accent };
911
- const hints = approval
912
- ? "y accept · n reject · Esc stop"
1008
+ // Shortcuts for the status bar, most useful first; the ones that don't fit are left out (all of them are in /help).
1009
+ const hints = approval && !(showEditor && focus === "code")
1010
+ ? [["y", "accept"], ["n", "reject"], ...(pendingChange && diff !== pendingChange ? [["o", "show the edit"]] : []), ["Esc", "stop"], ["Shift+Tab", "auto mode"]]
913
1011
  : showEditor && focus === "code"
914
- ? `Ctrl+S save${settings.autoSave === "off" ? "" : " (auto-save on)"} · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G definition · Esc close`
1012
+ ? [["Esc", "done"], ["Ctrl+S", settings.autoSave === "off" ? "save" : "save (auto-save on)"], ["Ctrl+Z", "undo"], ["Ctrl+Y", "redo"], ["Ctrl+K", "info"], ["Ctrl+G", "definition"]]
915
1013
  : focus === "chat"
916
- ? busy ? "Esc stop · Shift+↑↓ scroll chat · Tab switch pane" : "Enter send · Shift+↑↓ / PgUp scroll · / commands · Ctrl+P find file · Tab switch pane"
1014
+ ? busy
1015
+ ? [["Esc", "stop"], ["Shift+↑↓", "scroll"], ["Tab", "next pane"]]
1016
+ : [["Enter", "send"], ["/", "commands"], ["Tab", "next pane"], ["Ctrl+P", "find file"], ["Shift+↑↓", "scroll"]]
917
1017
  : focus === "tree"
918
- ? "↑↓ move · Enter open · c changes · e edit · p problems · u undo · Ctrl+P find · s settings"
919
- : diff ? `n/N next/prev change${diffMode === "session" ? " · [ ] prev/next file" : ""} · v cycle diff · Esc close diff` : "↑↓ scroll · e edit · v diff · p problems · u undo · s settings";
920
- return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`, local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: height, focused: focus === "tree" && !approval, openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, problemCounts: diagnostics.fileCounts(), wheel: wheelFor("tree"), click: treeClick, onOpen: (filePath) => {
1018
+ ? [["Enter", "open"], ["e", "edit"], ["c", "changes"], ["Ctrl+P", "find"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]]
1019
+ : diff
1020
+ ? [["Esc", "close diff"], ["n/N", "next/prev change"], ...(diffMode === "session" ? [["[ ]", "prev/next file"]] : []), ["v", "cycle diff"]]
1021
+ : [["e", "edit"], ["v", "diff"], ["u", "undo"], ["p", "problems"], ["< >", "resize"], ["m", "select text"], ["s", "settings"]];
1022
+ const statusUsed = mode.label.length + 2 + autoMode.length + 2 + 10 + ` ✖ ${totals.errors} ▲ ${totals.warnings} `.length + (checking.length ? 4 + checking.join(", ").length : 0) + 2;
1023
+ const shownHints = [];
1024
+ for (let used = statusUsed, i = 0; i < hints.length; i++) {
1025
+ used += hints[i][0].length + hints[i][1].length + 4;
1026
+ if (used > columns)
1027
+ break;
1028
+ shownHints.push(hints[i]);
1029
+ }
1030
+ return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`, local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: height, focused: focus === "tree", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, problemCounts: diagnostics.fileCounts(), wheel: wheelFor("tree"), click: treeClick, onOpen: (filePath) => {
921
1031
  open(filePath);
922
1032
  setFocus("code");
923
- }, onHover: () => { } }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code" && !approval, problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: (file, line) => openAt(file, line), onClose: () => setCodePanel("code") })) : showEditor ? (_jsx(Editor, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), onHover: (text, line, col) => (lspEnabled ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)), onDefinition: (text, line, col) => {
1033
+ }, onHover: () => { } }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: (file, line) => openAt(file, line), onClose: () => setCodePanel("code") })) : showEditor ? (_jsx(Editor, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), onHover: (text, line, col) => (lspEnabled ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)), onDefinition: (text, line, col) => {
924
1034
  if (!lspEnabled)
925
1035
  return info("Go to definition needs a language server (see /settings → Plugins).", "error");
926
1036
  void lsp.definition(openFile, text, line, col).then((target) => {
@@ -939,9 +1049,9 @@ ${snippet}
939
1049
  draft.current = state.dirty
940
1050
  ? { path: openFile, text: state.lines.join("\n"), baseline: draft.current?.path === openFile ? draft.current.baseline : content }
941
1051
  : null;
942
- } }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code" && !approval, filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), onViewport: (top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })) })), _jsx(Chat, { width: chatWidth, height: height, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheel: wheelFor("chat"), onSubmit: send })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: follow ? " ◉ follow " : " ○ follow " }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
1052
+ } }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange && diff === pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange && diff === pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), onViewport: (top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })) })), _jsx(Chat, { width: chatWidth, height: height, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheel: wheelFor("chat"), onSubmit: send, onAnswer: answer })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: autoMode === "ask" ? C.dim : C.orange, bold: autoMode !== "ask", children: ` ${autoMode === "ask" ? "ask" : autoMode} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: follow ? " ◉ follow " : " ○ follow " }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
943
1053
  ? _jsxs(Text, { color: C.yellow, bold: true, children: [" ", "Press again to quit", draft.current && settings.autoSave === "off" ? ` (unsaved changes in ${draft.current.path} will be lost)` : ""] })
944
- : _jsxs(Text, { color: C.dim, children: [" ", hints] })] }) }), quickOpen && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, onPick: (file) => {
1054
+ : shownHints.map(([key, action], i) => (_jsxs(Text, { children: [i === 0 ? " " : _jsx(Text, { color: C.dim, children: " \u00B7 " }), _jsx(Text, { color: C.text, children: key }), _jsxs(Text, { color: C.dim, children: [" ", action] })] }, key)))] }) }), quickOpen && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, onPick: (file) => {
945
1055
  setQuickOpen(false);
946
1056
  open(file);
947
1057
  setReveal(file);
@@ -1,12 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useState } from "react";
3
3
  import { Box, Text, useAnimation, useInput } from "ink";
4
- import { wrap } from "./text.js";
4
+ import { renderMarkdown, wrap } from "./text.js";
5
5
  import { ChatInput, inputHeight } from "./ChatInput.js";
6
6
  import { C, SPINNER } from "./theme.js";
7
7
  import { Pane } from "./Pane.js";
8
- export const COMMANDS = ["/model", "/models", "/settings", "/undo", "/rewind", "/history", "/problems", "/version", "/clear", "/help"];
9
- export function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, onSubmit }) {
8
+ export const COMMANDS = ["/model", "/models", "/settings", "/auto", "/engine", "/copy", "/undo", "/rewind", "/history", "/problems", "/version", "/clear", "/help"];
9
+ export function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, onSubmit, onAnswer }) {
10
10
  const [input, setInput] = useState("");
11
11
  const [scrollBack, setScrollBack] = useState(0);
12
12
  const { frame } = useAnimation({ interval: 80, isActive: !!busy });
@@ -23,9 +23,9 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
23
23
  }
24
24
  else if (entry.kind === "assistant") {
25
25
  out.push({ text: "◆ nou", color: C.accent2, bold: true, bar: C.accent2 });
26
- for (const line of wrap(entry.text, inner - 3)) {
27
- const isCode = /^\s{2,}|^```|^\s*[{}();]/.test(line);
28
- out.push({ text: line, ...(isCode ? { color: C.yellow } : {}), bar: C.accent2 });
26
+ for (const line of renderMarkdown(entry.text, inner - 3)) {
27
+ const color = line.kind === "heading" ? C.accent2 : line.kind === "quote" ? C.dim : undefined;
28
+ out.push({ text: "", spans: line.spans, ...(color ? { color } : {}), bar: C.accent2 });
29
29
  }
30
30
  out.push({ text: "" });
31
31
  }
@@ -54,13 +54,13 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
54
54
  footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
55
55
  }
56
56
  else if (approval?.kind === "change") {
57
- footer.push({ text: `✎ Review ${approval.change.path} →`, color: C.yellow, bold: true });
57
+ footer.push({ text: `✎ Nou wants to ${approval.change.isNew ? "create" : "edit"} ${approval.change.path}`, color: C.yellow, bold: true });
58
58
  if (approvalNote) {
59
59
  footer.push({ text: "⚠ This edit adds problems:", color: C.red, bold: true });
60
60
  for (const line of wrap(approvalNote, inner - 2).slice(0, 6))
61
61
  footer.push({ text: ` ${line}`, color: C.red });
62
62
  }
63
- footer.push({ text: " [y] accept [n] reject [Esc] stop", color: C.yellow });
63
+ footer.push({ text: " [y] accept [n] reject [o] show it [Esc] stop", color: C.yellow });
64
64
  }
65
65
  else if (busy) {
66
66
  footer.push({ text: `${SPINNER[frame % SPINNER.length]} ${busy}`, color: C.accent2 });
@@ -78,6 +78,13 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
78
78
  if (wheel)
79
79
  setScrollBack((b) => Math.max(0, Math.min(maxBack, b - wheel.dir * 3)));
80
80
  }, [wheel?.nonce]);
81
+ // While something waits for approval, y/n into an empty input answer it; any other key starts a message.
82
+ useInput((typed) => {
83
+ if (/^[yYnN]$/.test(typed))
84
+ onAnswer(/[yY]/.test(typed));
85
+ else if (typed && typed >= " ")
86
+ setInput(typed); // printable, not a control key
87
+ }, { isActive: focused && !!approval && !input });
81
88
  useInput((_input, key) => {
82
89
  if (key.pageUp || (key.upArrow && key.shift))
83
90
  setScrollBack((b) => Math.min(maxBack, b + Math.max(1, rows - 2)));
@@ -92,12 +99,14 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
92
99
  setInput("");
93
100
  onSubmit(command);
94
101
  };
95
- const render = (line, key) => (_jsxs(Text, { wrap: "truncate-end", children: [line.bar ? _jsx(Text, { color: line.bar, children: "\u258C " }) : null, _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
102
+ const render = (line, key) => (_jsxs(Text, { wrap: "truncate-end", children: [line.bar ? _jsx(Text, { color: line.bar, children: "\u258C " }) : null, line.spans?.length
103
+ ? line.spans.map((span, i) => (_jsx(Text, { bold: !!span.bold || !!line.bold, italic: !!span.italic, dimColor: !!span.dim, ...(span.code ? { color: C.yellow } : line.color ? { color: line.color } : { color: C.text }), children: span.text }, i)))
104
+ : _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
96
105
  const empty = entries.length === 0 && !streaming;
97
106
  const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back
98
107
  ? _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines \u00B7 Shift+\u2193 / PgDn / wheel to go back"] })
99
108
  : maxBack > 0 ? _jsx(Text, { color: C.dim, children: " Shift+\u2191 / PgUp / wheel to scroll" }) : null] }));
100
109
  return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsx(Box, { flexDirection: "column", height: rows, children: empty ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Try:" }), _jsx(Text, { color: C.text, children: " \u201Cexplain this file\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cwhat changed since my last commit?\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cadd error handling here\u201D" }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "I can see the file you have open." })] })) : visible.map((line, i) => render(line, i)) }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), _jsx(Text, { wrap: "truncate-end", color: C.dim, children: suggestions.length
101
110
  ? _jsxs(Text, { color: C.accent, children: [suggestions.join(" "), " ", _jsx(Text, { color: C.dim, children: suggestions.length === 1 ? "Enter ↵" : "" })] })
102
- : contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " }), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner)) }), _jsx(ChatInput, { value: input, onChange: setInput, onSubmit: submit, focus: focused && !approval, width: inner, history: history, placeholder: busy ? "working… Esc to stop" : "Ask anything · / for commands · ↑ history" })] }));
111
+ : contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " }), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner)) }), _jsx(ChatInput, { value: input, onChange: setInput, onSubmit: submit, focus: focused && !(approval && !input), width: inner, history: history, placeholder: busy ? "working… Esc to stop" : "Ask anything · / for commands · ↑ history" })] }));
103
112
  }
@@ -53,7 +53,7 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
53
53
  setTop(0);
54
54
  else if (input === "G")
55
55
  setTop(maxTop);
56
- else if (diff && (input === "n" || input === "N")) {
56
+ else if (diff && !pending && (input === "n" || input === "N")) {
57
57
  // Jump between change hunks.
58
58
  const starts = diffRows.flatMap((r, i) => (r.changed && !diffRows[i - 1]?.changed ? [i] : []));
59
59
  const target = input === "n" ? starts.find((s) => s - 3 > start) : [...starts].reverse().find((s) => s - 3 < start);
@@ -234,5 +234,5 @@ export function Editor({ width, height, focused, filePath, initial, baseline, st
234
234
  const shown = expand(line).slice(v.left, v.left + textWidth);
235
235
  const at = cursorX - v.left;
236
236
  return (_jsxs(Text, { wrap: "truncate-end", children: [num, _jsx(Text, { color: C.text, children: shown.slice(0, at) }), _jsx(Text, { inverse: true, color: C.green, children: shown[at] ?? " " }), _jsx(Text, { color: C.text, children: shown.slice(at + 1) })] }, n));
237
- }) }), hoverLines.length > 0 && (_jsx(Box, { position: "absolute", top: Math.max(1, rows - hoverLines.length - 1), left: gutter + 1, flexDirection: "column", borderStyle: "round", borderColor: C.accent2, width: Math.min(width - gutter - 3, 2 + Math.max(...hoverLines.map((l) => l.length))), children: hoverLines.map((l, i) => _jsx(Text, { color: C.text, wrap: "truncate-end", children: l.padEnd(width) }, i)) })), _jsxs(Text, { wrap: "truncate-end", color: statusColor, children: [` ${status}`, _jsx(Text, { color: C.dim, children: confirmExit ? "" : ` Ctrl+S save${autoSave === "off" ? "" : " (auto)"} · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G definition · Esc close` })] })] }));
237
+ }) }), hoverLines.length > 0 && (_jsx(Box, { position: "absolute", top: Math.max(1, rows - hoverLines.length - 1), left: gutter + 1, flexDirection: "column", borderStyle: "round", borderColor: C.accent2, width: Math.min(width - gutter - 3, 2 + Math.max(...hoverLines.map((l) => l.length))), children: hoverLines.map((l, i) => _jsx(Text, { color: C.text, wrap: "truncate-end", children: l.padEnd(width) }, i)) })), _jsx(Text, { wrap: "truncate-end", color: statusColor, children: ` ${status}` })] }));
238
238
  }
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useState } from "react";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
3
  import { readdirSync } from "fs";
4
4
  import path from "path";
5
5
  import { Box, Text, useInput } from "ink";
@@ -50,10 +50,13 @@ export function FileTree({ width, height, focused, openFile, reveal, version, ch
50
50
  }
51
51
  return dirs;
52
52
  }, [git]);
53
- // Expand the folders leading to whatever the agent is touching and move to it.
53
+ // Expand the folders leading to whatever the agent is touching and move to it, once per reveal:
54
+ // later changes to the list (expanding folders, git refreshes) must not pull the cursor back.
55
+ const pendingReveal = useRef(null);
54
56
  useEffect(() => {
55
57
  if (!reveal)
56
58
  return;
59
+ pendingReveal.current = reveal;
57
60
  const parts = reveal.split("/");
58
61
  setExpanded((prev) => {
59
62
  const next = new Set(prev);
@@ -63,12 +66,14 @@ export function FileTree({ width, height, focused, openFile, reveal, version, ch
63
66
  });
64
67
  }, [reveal, version]);
65
68
  useEffect(() => {
66
- if (!reveal)
69
+ if (!pendingReveal.current)
70
+ return;
71
+ const index = nodes.findIndex((n) => n.path === pendingReveal.current);
72
+ if (index < 0)
67
73
  return;
68
- const index = nodes.findIndex((n) => n.path === reveal);
69
- if (index >= 0)
70
- setCursor(index);
71
- }, [nodes, reveal]);
74
+ setCursor(index);
75
+ pendingReveal.current = null;
76
+ }, [nodes]);
72
77
  useEffect(() => {
73
78
  setCursor((c) => Math.min(c, Math.max(0, nodes.length - 1)));
74
79
  }, [nodes.length]);
@@ -7,7 +7,7 @@ import { saveCredentials } from "../config/credentials.js";
7
7
  import { originOf, setOutsideNou } from "../config/origins.js";
8
8
  import { PLUGIN_DIRS, isEnabled, trustState } from "../plugins/index.js";
9
9
  import { currentVersion } from "../update/index.js";
10
- import { describe, providers } from "../providers/index.js";
10
+ import { describe, providers, rejectedKey } from "../providers/index.js";
11
11
  import { ENGINES, isInstalled } from "../engines/index.js";
12
12
  import { C, SPINNER } from "./theme.js";
13
13
  const TABS = ["Model", "Engine", "Preferences", "Plugins"];
@@ -201,7 +201,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
201
201
  return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [ENGINES.map((engine, i) => {
202
202
  const selected = i === row;
203
203
  const active = engine.id === settings.engine;
204
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), _jsx(Text, { color: active ? C.green : C.dim, bold: true, children: active ? "● " : "○ " }), _jsx(Text, { color: C.text, bold: selected, children: engine.label.padEnd(14) }), _jsxs(Text, { color: C.dim, children: [active ? "in use · " : "", isInstalled(engine) ? "" : "installs on first use · ", "model: ", engine.model(choice)] })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", engine.description] }), engine.approvals === "after" && _jsx(Text, { color: C.orange, wrap: "truncate-end", children: " Nou can't ask before its edits; it shows them afterwards so you can undo them." })] }, engine.id));
204
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), _jsx(Text, { color: active ? C.green : C.dim, bold: true, children: active ? "● " : "○ " }), _jsx(Text, { color: C.text, bold: selected, children: engine.label.padEnd(14) }), _jsxs(Text, { color: C.dim, children: [active ? "in use · " : "", "model: ", engine.model(choice)] })] }), !isInstalled(engine) && _jsxs(Text, { color: C.orange, wrap: "truncate-end", children: [" Not installed. Enter installs it with npm (", engine.package, ") into ~/.nou/engines."] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", engine.description] }), engine.approvals === "after" && _jsx(Text, { color: C.orange, wrap: "truncate-end", children: " Nou can't ask before its edits; it shows them afterwards so you can undo them." })] }, engine.id));
205
205
  }), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "The engine is the agent that answers in the chat. Diffs, approvals, undo and problems work with each one." })] }));
206
206
  }
207
207
  if (tab === "Preferences") {
@@ -240,5 +240,3 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
240
240
  function initialValue(f) {
241
241
  return f.secret ? "" : process.env[f.env] ?? "";
242
242
  }
243
- /** Whether a provider error means the API key is wrong (rather than, say, the server being down). */
244
- const rejectedKey = (error) => /\b(401|403)\b|api key|unauthori[sz]ed|authentication/i.test(error);
@@ -0,0 +1,29 @@
1
+ import { spawn } from "child_process";
2
+ /** Native clipboard commands to try, in order, for this platform. */
3
+ function commands() {
4
+ if (process.platform === "win32")
5
+ return [["powershell", ["-NoProfile", "-Command", "Set-Clipboard -Value ([Console]::In.ReadToEnd())"]]];
6
+ if (process.platform === "darwin")
7
+ return [["pbcopy", []]];
8
+ return [["wl-copy", []], ["xclip", ["-selection", "clipboard"]], ["xsel", ["--clipboard", "--input"]]];
9
+ }
10
+ function run(cmd, args, text) {
11
+ return new Promise((resolve) => {
12
+ const child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
13
+ child.on("error", () => resolve(false));
14
+ child.on("close", (code) => resolve(code === 0));
15
+ child.stdin.end(text);
16
+ });
17
+ }
18
+ /**
19
+ * Copies text to the system clipboard. Also sends it as an OSC 52 escape, which terminals that
20
+ * support it (Windows Terminal, iTerm2, kitty, WezTerm...) put on the clipboard even over SSH.
21
+ */
22
+ export async function copyToClipboard(text) {
23
+ if (process.stdout.isTTY)
24
+ process.stdout.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
25
+ for (const [cmd, args] of commands())
26
+ if (await run(cmd, args, text))
27
+ return true;
28
+ return process.stdout.isTTY === true;
29
+ }
@@ -14,8 +14,10 @@ export function parseMouse(chunk) {
14
14
  const button = code & 0b11000011; // drop the shift/alt/ctrl bits
15
15
  if (button === 64 || button === 65)
16
16
  events.push({ kind: "wheel", dir: button === 64 ? -1 : 1, x, y });
17
- else if (button === 0 && m[4] === "M")
18
- events.push({ kind: "click", x, y });
17
+ else if (button === 0 && code & 32)
18
+ events.push({ kind: "drag", x, y }); // 32: moved with the left button held
19
+ else if (button === 0)
20
+ events.push({ kind: m[4] === "M" ? "click" : "release", x, y });
19
21
  }
20
22
  return events;
21
23
  }
@@ -110,3 +110,158 @@ export function lineCounts(before, after) {
110
110
  }
111
111
  return { added, removed };
112
112
  }
113
+ const INLINE = /(`+)([^`]+?)\1|\*\*(.+?)\*\*|__(.+?)__|(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])|(?<!\w)_(?!\s)(.+?)(?<!\s)_(?!\w)|\[([^\]]+)\]\(([^)\s]+)\)/g;
114
+ /** Splits a line of markdown into styled spans: `code`, **bold**, *italic*, [links](url). */
115
+ export function inlineSpans(text, base = { text: "" }) {
116
+ const out = [];
117
+ let pos = 0;
118
+ for (const m of text.matchAll(INLINE)) {
119
+ if (m.index > pos)
120
+ out.push({ ...base, text: text.slice(pos, m.index) });
121
+ if (m[2] !== undefined)
122
+ out.push({ ...base, text: m[2], code: true });
123
+ else if (m[3] !== undefined || m[4] !== undefined)
124
+ out.push(...inlineSpans(m[3] ?? m[4], { ...base, bold: true }));
125
+ else if (m[5] !== undefined || m[6] !== undefined)
126
+ out.push(...inlineSpans(m[5] ?? m[6], { ...base, italic: true }));
127
+ else {
128
+ out.push(...inlineSpans(m[7], base));
129
+ if (m[8] !== m[7])
130
+ out.push({ ...base, text: ` (${m[8]})`, dim: true });
131
+ }
132
+ pos = m.index + m[0].length;
133
+ }
134
+ if (pos < text.length)
135
+ out.push({ ...base, text: text.slice(pos) });
136
+ return out.filter((s) => s.text);
137
+ }
138
+ const sameStyle = (a, b) => !!a.bold === !!b.bold && !!a.italic === !!b.italic && !!a.code === !!b.code && !!a.dim === !!b.dim;
139
+ /** Word-wraps styled spans; continuation lines start with `indent` spaces. */
140
+ function wrapSpans(spans, width, indent = 0) {
141
+ const max = Math.max(1, width);
142
+ const lines = [[]];
143
+ let len = 0;
144
+ const push = (span) => {
145
+ const line = lines[lines.length - 1];
146
+ const last = line[line.length - 1];
147
+ if (last && sameStyle(last, span))
148
+ last.text += span.text;
149
+ else
150
+ line.push({ ...span });
151
+ len += span.text.length;
152
+ };
153
+ const newLine = () => {
154
+ const line = lines[lines.length - 1];
155
+ const last = line[line.length - 1];
156
+ if (last)
157
+ last.text = last.text.trimEnd();
158
+ lines.push(indent ? [{ text: " ".repeat(indent) }] : []);
159
+ len = indent;
160
+ };
161
+ for (const span of spans) {
162
+ for (const word of span.text.split(/(\s+)/)) {
163
+ if (!word)
164
+ continue;
165
+ if (len + word.length <= max) {
166
+ push({ ...span, text: word });
167
+ continue;
168
+ }
169
+ if (/^\s+$/.test(word)) {
170
+ newLine();
171
+ continue;
172
+ }
173
+ if (len > indent)
174
+ newLine();
175
+ let rest = word;
176
+ while (len + rest.length > max) {
177
+ const room = Math.max(1, max - len);
178
+ push({ ...span, text: rest.slice(0, room) });
179
+ rest = rest.slice(room);
180
+ newLine();
181
+ }
182
+ push({ ...span, text: rest });
183
+ }
184
+ }
185
+ return lines;
186
+ }
187
+ const cells = (row) => row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
188
+ /** Renders markdown for the terminal: headings, lists, quotes, code blocks, tables and inline styles, wrapped to `width`. */
189
+ export function renderMarkdown(text, width) {
190
+ const out = [];
191
+ const src = splitLines(text);
192
+ let fence = null;
193
+ for (let i = 0; i < src.length; i++) {
194
+ const raw = src[i];
195
+ const opener = raw.match(/^\s*(`{3,}|~{3,})\s*(\S*)/);
196
+ if (fence !== null) {
197
+ if (opener && raw.trim().startsWith(fence)) {
198
+ fence = null;
199
+ continue;
200
+ }
201
+ for (const line of wrap(raw, width))
202
+ out.push({ kind: "code", spans: [{ text: line, code: true }] });
203
+ continue;
204
+ }
205
+ if (opener) {
206
+ fence = opener[1];
207
+ if (opener[2])
208
+ out.push({ kind: "code", spans: [{ text: opener[2], dim: true }] });
209
+ continue;
210
+ }
211
+ // Tables: a header row, a |---| row, then body rows.
212
+ if (/^\s*\|/.test(raw) && /^\s*\|?\s*:?-{2,}/.test(src[i + 1] ?? "")) {
213
+ const rows = [cells(raw)];
214
+ let j = i + 2;
215
+ for (; j < src.length && /^\s*\|/.test(src[j]); j++)
216
+ rows.push(cells(src[j]));
217
+ i = j - 1;
218
+ const plain = rows.map((r) => r.map((c) => inlineSpans(c).map((s) => s.text).join("")));
219
+ const columns = Math.max(...plain.map((r) => r.length));
220
+ const widths = Array.from({ length: columns }, (_, c) => Math.max(...plain.map((r) => (r[c] ?? "").length)));
221
+ const fits = widths.reduce((a, b) => a + b, 0) + 3 * (columns - 1) <= width;
222
+ rows.forEach((row, r) => {
223
+ const spans = [];
224
+ row.forEach((cell, c) => {
225
+ if (c > 0)
226
+ spans.push({ text: " │ ", dim: true });
227
+ spans.push(...inlineSpans(cell, r === 0 ? { text: "", bold: true } : { text: "" }));
228
+ if (fits && c < columns - 1)
229
+ spans.push({ text: " ".repeat(widths[c] - plain[r][c].length) });
230
+ });
231
+ for (const line of wrapSpans(spans, width, 2))
232
+ out.push({ kind: "text", spans: line });
233
+ if (r === 0)
234
+ out.push({ kind: "rule", spans: [{ text: fits ? widths.map((w) => "─".repeat(w)).join("─┼─") : "─".repeat(Math.min(width, 40)), dim: true }] });
235
+ });
236
+ continue;
237
+ }
238
+ const heading = raw.match(/^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/);
239
+ if (heading) {
240
+ for (const line of wrapSpans(inlineSpans(heading[2], { text: "", bold: true }), width))
241
+ out.push({ kind: "heading", spans: line });
242
+ continue;
243
+ }
244
+ if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(raw)) {
245
+ out.push({ kind: "rule", spans: [{ text: "─".repeat(Math.min(width, 40)), dim: true }] });
246
+ continue;
247
+ }
248
+ const quote = raw.match(/^\s*>\s?(.*)/);
249
+ if (quote) {
250
+ for (const line of wrapSpans(inlineSpans(quote[1], { text: "", italic: true }), width - 2))
251
+ out.push({ kind: "quote", spans: [{ text: "│ ", dim: true }, ...line] });
252
+ continue;
253
+ }
254
+ const item = raw.match(/^(\s*)([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?(.*)/);
255
+ if (item) {
256
+ const depth = Math.floor(item[1].length / 2) * 2;
257
+ const task = item[3] ? (/x/i.test(item[3]) ? "☑ " : "☐ ") : "";
258
+ const bullet = `${" ".repeat(depth)}${/\d/.test(item[2]) ? item[2] : "•"} ${task}`;
259
+ for (const line of wrapSpans([{ text: bullet }, ...inlineSpans(item[4])], width, bullet.length))
260
+ out.push({ kind: "text", spans: line });
261
+ continue;
262
+ }
263
+ for (const line of wrapSpans(inlineSpans(raw), width))
264
+ out.push({ kind: "text", spans: line });
265
+ }
266
+ return out;
267
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nou-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Nou: an IDE-style AI coding assistant for your terminal. Local (Ollama, LM Studio) and cloud (Claude, OpenAI, Gemini...) models, live diffs, git-aware, built-in editor.",
5
5
  "type": "module",
6
6
  "bin": {