nou-cli 0.5.15 → 0.5.17

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/README.md CHANGED
@@ -135,7 +135,7 @@ Chat commands: `/settings` · `/model [provider:model]` · `/models [provider]`
135
135
 
136
136
  A small typo in a command still works when it only changes the screen (`/termnial` opens the terminal); for others Nou asks "Did you mean...?" instead of guessing.
137
137
 
138
- New lines in a message: <kbd>Shift</kbd>/<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Enter</kbd> in terminals that report them (kitty, Ghostty, WezTerm, iTerm2 and others with the kitty keyboard protocol, which Nou checks for before it starts; also xterm's modifyOtherKeys form), or type `\` and press <kbd>Enter</kbd> in any terminal.
138
+ New lines in a message: <kbd>Shift</kbd>/<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Enter</kbd> in terminals that report them (kitty, Ghostty, WezTerm, iTerm2 and others with the kitty keyboard protocol, which Nou checks for before it starts; also xterm's modifyOtherKeys form), or type `\` and press <kbd>Enter</kbd> in any terminal. On Windows, <kbd>Ctrl</kbd>+<kbd>Enter</kbd> works in every Windows Terminal and the console; <kbd>Shift</kbd>+<kbd>Enter</kbd> needs Windows Terminal 1.25 or later (older versions send it as a plain Enter).
139
139
 
140
140
  Conversations are saved per folder after each reply. `/resume` opens a picker (↑↓ and Enter, type to filter, or type its number) and `/resume 2` (or `nou --continue` for the last one, `nou --resume` for the list) picks one back up with the engine it was on: Nou's own engine restores its messages, Claude Code its session and Codex its thread; pi is handed the conversation so far. Sessions you started in Claude Code's or Codex's own CLI in this folder are listed too (marked "CLI"), and resuming one carries it on in that engine inside Nou.
141
141
 
package/dist/src/cli.js CHANGED
@@ -16,9 +16,10 @@ import { installFastOutput } from "./ui/fast-output.js";
16
16
  import { RENDER_OPTIONS } from "./ui/render-options.js";
17
17
  import { setScreenClear } from "./ui/redraw.js";
18
18
  import { detectTerminal } from "./ui/background.js";
19
+ import { setTerminalTitle } from "./ui/title.js";
19
20
  import { setPalette } from "./ui/theme.js";
20
21
  import { runHost, workspaceFolder } from "./workspaces/host.js";
21
- import { pendingWorkspaces } from "./workspaces/client.js";
22
+ import { isHosted, pendingWorkspaces } from "./workspaces/client.js";
22
23
  import { spawn } from "child_process";
23
24
  import { appendFileSync, mkdirSync } from "fs";
24
25
  import path from "path";
@@ -97,6 +98,7 @@ async function main() {
97
98
  const uiTheme = loadSettings().uiTheme;
98
99
  // Every workspace uses the colors found for this terminal (they can't ask it themselves from the background).
99
100
  // The host asks the terminal once and tells every workspace (in the background they can't ask it).
101
+ setTerminalTitle();
100
102
  const term = await detectTerminal();
101
103
  process.env.NOU_PALETTE = uiTheme === "dark" || uiTheme === "light" ? uiTheme : term.palette ?? "dark";
102
104
  if (term.kitty !== null)
@@ -108,6 +110,9 @@ async function main() {
108
110
  console.error("Nou needs an interactive terminal (try Windows Terminal, PowerShell, or Terminal/iTerm on macOS).");
109
111
  process.exit(1);
110
112
  }
113
+ // The tab says Nou CLI, not the shell it runs in (a workspace's host already set it).
114
+ if (!isHosted())
115
+ setTerminalTitle();
111
116
  p.intro(chalk.cyan(`${artWork} \n Hello I am Nou! 🌞 ${chalk.dim(`v${currentVersion()}`)}`));
112
117
  // A folder's files can run things (its MCP servers, plugins, the commands Nou is asked to run), so a
113
118
  // folder is trusted once, like Claude Code and VS Code ask.
@@ -48,6 +48,7 @@ export class ClaudeCodeEngine extends ExternalEngine {
48
48
  super(choice, hooks, "Claude Code");
49
49
  }
50
50
  resetConversation() {
51
+ this.closeLive();
51
52
  this.claudeSession = undefined;
52
53
  }
53
54
  snapshot() {
@@ -57,6 +58,7 @@ export class ClaudeCodeEngine extends ExternalEngine {
57
58
  restore(state) {
58
59
  if (typeof state?.claudeSession !== "string")
59
60
  return false;
61
+ this.closeLive();
60
62
  this.claudeSession = state.claudeSession;
61
63
  return true;
62
64
  }
@@ -64,15 +66,45 @@ export class ClaudeCodeEngine extends ExternalEngine {
64
66
  setModel(choice) {
65
67
  this.choice = choice;
66
68
  }
67
- async run(prompt, signal) {
68
- const sdk = await loadPackage("@anthropic-ai/claude-agent-sdk");
69
- const abortController = new AbortController();
70
- signal.addEventListener("abort", () => abortController.abort());
69
+ dispose() {
70
+ this.closeLive();
71
+ }
72
+ /**
73
+ * One Claude Code process per conversation, kept running between messages: each message is fed into it
74
+ * and a turn is over at its result. A process per message didn't work: one that left something running
75
+ * (a dev server, a background command) never closed its stream, so the turn never ended, and the next
76
+ * message's process found the session still in use and ended without a word.
77
+ */
78
+ live = null;
79
+ /** The turn being waited on: settled at its result, or when the process ends. */
80
+ waiting = null;
81
+ streamed = "";
82
+ closeLive() {
83
+ const live = this.live;
84
+ this.live = null;
85
+ live?.close();
86
+ }
87
+ /** What the process was started with; a change (model, effort, project context) means a new one. */
88
+ liveKey() {
89
+ return JSON.stringify([this.choice.provider.id === "anthropic" ? this.choice.model : null, this.effort ?? null, this.extras]);
90
+ }
91
+ openLive(sdk, key) {
92
+ const queue = [];
93
+ let wake = null;
94
+ let closed = false;
95
+ const input = async function* () {
96
+ while (!closed) {
97
+ if (queue.length)
98
+ yield queue.shift();
99
+ else
100
+ await new Promise((resolve) => (wake = resolve));
101
+ }
102
+ };
71
103
  const allow = { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" } };
72
104
  const deny = (reason) => ({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } });
73
- const preToolUse = async (input) => {
74
- const tool = input.tool_name;
75
- const args = (input.tool_input ?? {});
105
+ const preToolUse = async (hookInput) => {
106
+ const tool = hookInput.tool_name;
107
+ const args = (hookInput.tool_input ?? {});
76
108
  if (EDIT_TOOLS.has(tool) && args.file_path) {
77
109
  const ok = await this.tracker.approve(args.file_path, proposedContent(tool, args), tool);
78
110
  return ok ? allow : deny("The user rejected this change. Ask them what they would like instead.");
@@ -86,9 +118,9 @@ export class ClaudeCodeEngine extends ExternalEngine {
86
118
  // Reads and searches follow Claude Code's own rules; anything else that needs asking reaches canUseTool.
87
119
  return {};
88
120
  };
89
- const postToolUse = async (input) => {
90
- const tool = input.tool_name;
91
- const args = (input.tool_input ?? {});
121
+ const postToolUse = async (hookInput) => {
122
+ const tool = hookInput.tool_name;
123
+ const args = (hookInput.tool_input ?? {});
92
124
  if (EDIT_TOOLS.has(tool) && args.file_path) {
93
125
  const problems = await this.tracker.applied(args.file_path);
94
126
  return problems
@@ -109,10 +141,9 @@ export class ClaudeCodeEngine extends ExternalEngine {
109
141
  return {};
110
142
  };
111
143
  const query = sdk.query({
112
- prompt,
144
+ prompt: input(),
113
145
  options: {
114
146
  cwd: process.cwd(),
115
- abortController,
116
147
  ...(this.claudeSession ? { resume: this.claudeSession } : {}),
117
148
  ...(this.choice.provider.id === "anthropic" ? { model: this.choice.model } : {}),
118
149
  ...(this.effort && EFFORTS.has(this.effort) ? { effort: this.effort } : {}),
@@ -122,42 +153,124 @@ export class ClaudeCodeEngine extends ExternalEngine {
122
153
  settingSources: ["project"],
123
154
  systemPrompt: { type: "preset", preset: "claude_code", append: this.extras.join("\n\n") },
124
155
  hooks: { PreToolUse: [{ hooks: [preToolUse] }], PostToolUse: [{ hooks: [postToolUse] }] },
125
- canUseTool: async (tool, input) => {
126
- const ok = await this.hooks.approve({ kind: "tool", name: tool, args: input });
156
+ canUseTool: async (tool, toolInput) => {
157
+ const ok = await this.hooks.approve({ kind: "tool", name: tool, args: toolInput });
127
158
  if (!ok)
128
- this.hooks.onEntry({ kind: "tool", text: `Denied: ${describeTool(tool, input)}` });
129
- return ok ? { behavior: "allow", updatedInput: input } : { behavior: "deny", message: "The user denied this." };
159
+ this.hooks.onEntry({ kind: "tool", text: `Denied: ${describeTool(tool, toolInput)}` });
160
+ return ok ? { behavior: "allow", updatedInput: toolInput } : { behavior: "deny", message: "The user denied this." };
130
161
  },
131
162
  },
132
163
  });
133
- let streamed = "";
134
- for await (const message of query) {
135
- if (message.type === "system" && message.subtype === "init") {
136
- this.claudeSession = message.session_id;
137
- }
138
- else if (message.type === "stream_event" && !message.parent_tool_use_id) {
139
- const event = message.event;
140
- if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
141
- streamed += event.delta.text;
142
- this.hooks.onStream(streamed);
164
+ const live = {
165
+ key,
166
+ query,
167
+ push: (text) => {
168
+ queue.push({ type: "user", message: { role: "user", content: text }, parent_tool_use_id: null, session_id: this.claudeSession ?? "" });
169
+ wake?.();
170
+ wake = null;
171
+ },
172
+ close: () => {
173
+ process.off("exit", live.close);
174
+ closed = true;
175
+ wake?.();
176
+ try {
177
+ query.close?.();
143
178
  }
179
+ catch {
180
+ // already gone
181
+ }
182
+ },
183
+ };
184
+ process.once("exit", live.close); // Claude Code doesn't outlive Nou
185
+ // Reads everything Claude Code says, for as long as the process runs. Between turns that's what a
186
+ // background task it started brings back (it may carry on by itself); it goes to the chat too.
187
+ void (async () => {
188
+ let failure;
189
+ try {
190
+ for await (const message of query)
191
+ this.handle(message, live);
144
192
  }
145
- else if (message.type === "assistant" && !message.parent_tool_use_id) {
146
- const text = message.message.content.filter((b) => b.type === "text").map((b) => b.text).join("").trim();
147
- streamed = "";
148
- this.hooks.onStream("");
149
- if (text)
150
- this.hooks.onEntry({ kind: "assistant", text });
151
- this.hooks.onBusy("Claude Code is working...");
193
+ catch (error) {
194
+ failure = error instanceof Error ? error : new Error(String(error));
152
195
  }
153
- else if (message.type === "result") {
154
- this.claudeSession = message.session_id ?? this.claudeSession;
155
- if (message.subtype !== "success" || message.is_error) {
156
- const detail = message.errors?.join("; ") || message.result || message.subtype;
157
- if (!signal.aborted)
158
- this.hooks.onEntry({ kind: "error", text: `Claude Code: ${detail}` });
159
- }
196
+ finally {
197
+ if (this.live === live)
198
+ this.live = null;
199
+ if (this.waiting?.live === live)
200
+ this.waiting.done(failure ?? (closed ? undefined : new Error("Claude Code stopped before it answered.")));
160
201
  }
202
+ })();
203
+ return live;
204
+ }
205
+ handle(message, live) {
206
+ if (message.type === "system" && message.subtype === "init") {
207
+ this.claudeSession = message.session_id;
208
+ }
209
+ else if (message.type === "stream_event" && !message.parent_tool_use_id) {
210
+ const event = message.event;
211
+ if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
212
+ this.streamed += event.delta.text;
213
+ this.hooks.onStream(this.streamed);
214
+ }
215
+ }
216
+ else if (message.type === "assistant" && !message.parent_tool_use_id) {
217
+ const text = message.message.content.filter((b) => b.type === "text").map((b) => b.text).join("").trim();
218
+ this.streamed = "";
219
+ this.hooks.onStream("");
220
+ if (text)
221
+ this.hooks.onEntry({ kind: "assistant", text });
222
+ if (this.waiting)
223
+ this.hooks.onBusy("Claude Code is working...");
224
+ }
225
+ else if (message.type === "result") {
226
+ this.claudeSession = message.session_id ?? this.claudeSession;
227
+ this.streamed = "";
228
+ this.hooks.onStream("");
229
+ if (message.subtype !== "success" || message.is_error) {
230
+ const detail = message.errors?.join("; ") || message.result || message.subtype;
231
+ // A turn you stopped ends with an error result; that isn't news.
232
+ if (!this.stopping)
233
+ this.hooks.onEntry({ kind: "error", text: `Claude Code: ${detail}` });
234
+ }
235
+ this.stopping = false;
236
+ if (this.waiting?.live === live)
237
+ this.waiting.done();
238
+ }
239
+ }
240
+ stopping = false;
241
+ async run(prompt, signal) {
242
+ const sdk = await loadPackage("@anthropic-ai/claude-agent-sdk");
243
+ const key = this.liveKey();
244
+ if (this.live && this.live.key !== key)
245
+ this.closeLive();
246
+ const live = (this.live ??= this.openLive(sdk, key));
247
+ this.streamed = "";
248
+ let settle;
249
+ const finished = new Promise((resolve, reject) => (settle = (error) => (error ? reject(error) : resolve())));
250
+ const waiting = { live, done: (error) => settle(error) };
251
+ this.waiting = waiting;
252
+ // Stopping: Claude Code interrupts the turn and ends it with a result; if it doesn't promptly, the
253
+ // process goes (the next message starts a new one, resuming the session).
254
+ const onAbort = () => {
255
+ this.stopping = true;
256
+ Promise.resolve(live.query.interrupt?.()).catch(() => { });
257
+ setTimeout(() => {
258
+ if (this.waiting !== waiting)
259
+ return;
260
+ if (this.live === live)
261
+ this.closeLive();
262
+ waiting.done();
263
+ }, 3000);
264
+ };
265
+ signal.addEventListener("abort", onAbort);
266
+ try {
267
+ live.push(prompt);
268
+ await finished;
269
+ }
270
+ finally {
271
+ signal.removeEventListener("abort", onAbort);
272
+ if (this.waiting === waiting)
273
+ this.waiting = null;
161
274
  }
162
275
  if (signal.aborted)
163
276
  throw new Error("aborted");
@@ -148,8 +148,25 @@ export function showPlacesTool(onStops) {
148
148
  },
149
149
  };
150
150
  }
151
- /** The fenced block other engines (which can't call Nou's tools) list their places in. */
152
- const SHOW_BLOCK = /```nou-show\s*\n([\s\S]*?)\n```/;
151
+ /**
152
+ * The fenced block other engines (which can't call Nou's tools) list their places in. Models also write it
153
+ * with ~~~, with CRLF, with the closing fence on the JSON's last line, or (unfenced) as a "nou-show" line
154
+ * followed by the JSON array at the end of the reply.
155
+ */
156
+ const SHOW_BLOCK = /(?:```|~~~)[ \t]*nou-show[ \t]*\r?\n([\s\S]*?)\r?\n?[ \t]*(?:```|~~~)|(?:^|\n)[ \t]*nou-show[ \t]*\r?\n[ \t]*(\[[\s\S]*\])\s*$/;
157
+ /**
158
+ * A reply as the chat shows it: Nou's blocks (nou-show, nou-steps) are for Nou, not you, so they're never
159
+ * drawn, including one still arriving mid-stream.
160
+ */
161
+ export function hideNouBlocks(text) {
162
+ if (!text.includes("nou-"))
163
+ return text;
164
+ return text
165
+ .replace(/(?:```|~~~)[ \t]*nou-(?:show|steps)[ \t]*\r?\n[\s\S]*?(?:```|~~~)/g, "")
166
+ .replace(/(?:```|~~~)[ \t]*nou-(?:show|steps)[\s\S]*$/, "")
167
+ .replace(/(?:^|\n)[ \t]*nou-(?:show|steps)[ \t]*\r?\n[ \t]*\[[\s\S]*$/, "")
168
+ .trimEnd();
169
+ }
153
170
  /**
154
171
  * For Claude Code, Codex and pi, which can't call show_places: how to point at places, added to what they
155
172
  * know about the project, so explaining "where does X happen" opens the files on any engine.
@@ -166,7 +183,7 @@ export function stopsFromReply(text) {
166
183
  if (!m)
167
184
  return null;
168
185
  try {
169
- const stops = tidyStops(JSON.parse(m[1]));
186
+ const stops = tidyStops(JSON.parse((m[1] ?? m[2])));
170
187
  return { stops, rest: text.replace(m[0], "").trim() };
171
188
  }
172
189
  catch {
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { hideNouBlocks } from "../guide/index.js";
2
3
  import { memo, useEffect, useMemo, useRef, useState } from "react";
3
4
  import { Box, Text, useAnimation, useInput } from "ink";
4
5
  import { statSync } from "fs";
@@ -54,7 +55,7 @@ function entryLines(entry, inner, links = true) {
54
55
  }
55
56
  else if (entry.kind === "assistant") {
56
57
  out.push({ text: "◆ nou", color: C.accent2, bold: true, lead: BAR, leadColor: C.accent2 });
57
- for (const line of renderMarkdown(entry.text, inner - 3, links ? isProjectFile : undefined)) {
58
+ for (const line of renderMarkdown(hideNouBlocks(entry.text), inner - 3, links ? isProjectFile : undefined)) {
58
59
  const color = line.kind === "heading" ? C.accent2 : line.kind === "quote" ? C.dim : undefined;
59
60
  out.push({ text: "", spans: line.spans, ...(color ? { color } : {}), lead: BAR, leadColor: C.accent2 });
60
61
  }
@@ -192,6 +192,6 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
192
192
  const room = Math.max(1, inner - marker.length - 1);
193
193
  const shown = label.length > room ? `${label.slice(0, room - 1)}…` : label.padEnd(room);
194
194
  const iconAt = shown.indexOf(icon);
195
- return (_jsxs(Text, { wrap: "truncate-end", ...(selected && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, children: shown.slice(0, iconAt) }), _jsx(Text, { color: color, children: shown.slice(iconAt, iconAt + icon.length) }), working?.has(node.path) ? (_jsx(Text, { bold: true, inverse: selected && focused, children: gradient(shown.slice(iconAt + icon.length), frame).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) })) : (_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, inverse: selected && focused, children: shown.slice(iconAt + icon.length) })), _jsxs(Text, { color: markerColor, children: [" ", marker] })] }, node.path));
195
+ return (_jsxs(Text, { wrap: "truncate-end", ...(isOpen ? { backgroundColor: C.openRow } : selected && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, children: shown.slice(0, iconAt) }), _jsx(Text, { color: color, children: shown.slice(iconAt, iconAt + icon.length) }), working?.has(node.path) ? (_jsx(Text, { bold: true, inverse: selected && focused, children: gradient(shown.slice(iconAt + icon.length), frame).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) })) : (_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, inverse: selected && focused, children: shown.slice(iconAt + icon.length) })), _jsxs(Text, { color: markerColor, children: [" ", marker] })] }, node.path));
196
196
  })] }), _jsx(Scrollbar, { total: nodes.length, top: start, rows: rows, focused: focused })] }) }));
197
197
  });
@@ -16,6 +16,8 @@ const DARK = {
16
16
  /** Lines around panes that aren't focused: quieter than text, since they aren't read. */
17
17
  border: "#565f89",
18
18
  panel: "#1f2335",
19
+ /** The band behind the file that's open, wherever it's listed: every name color stays 4.5:1 on it. */
20
+ openRow: "#2a3352",
19
21
  /** Text on a colored badge (CHAT, CLOUD, EDITING...). */
20
22
  onBadge: "#1a1b26",
21
23
  };
@@ -32,6 +34,7 @@ const LIGHT = {
32
34
  dim: "#5a607a",
33
35
  border: "#a8aecb",
34
36
  panel: "#e6e7ed",
37
+ openRow: "#edf1fc",
35
38
  onBadge: "#ffffff",
36
39
  };
37
40
  /** The colors in use: dark unless the terminal is light (see setPalette). Read at render time, so set it first. */
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The terminal's window or tab title. Windows Terminal, iTerm2, Ghostty and the rest show the running shell
3
+ * ("Windows PowerShell") unless the program sets its own with OSC 0, which Nou does: "Nou CLI".
4
+ * The title before is saved (CSI 22;0t) and put back (CSI 23;0t) on the way out, in terminals that keep a
5
+ * title stack (xterm, kitty, Ghostty, WezTerm, iTerm2); the others ignore those codes, and the shell retitles
6
+ * its tab soon after.
7
+ */
8
+ export const TITLE = "Nou CLI";
9
+ let set = false;
10
+ export function setTerminalTitle(title = TITLE) {
11
+ if (set || !process.stdout.isTTY)
12
+ return;
13
+ set = true;
14
+ process.stdout.write(`\x1b[22;0t\x1b]0;${title}\x07`);
15
+ process.on("exit", () => process.stdout.write("\x1b[23;0t"));
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nou-cli",
3
- "version": "0.5.15",
3
+ "version": "0.5.17",
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": {