nou-cli 0.5.2 → 0.5.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.
@@ -81,7 +81,7 @@ export function anthropicProvider() {
81
81
  apiKey: process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || "",
82
82
  api: "anthropic-messages",
83
83
  }),
84
- async chat({ model, system, messages, tools, onText, signal, effort: requested }) {
84
+ async chat({ model, system, messages, tools, onText, onThinking, signal, effort: requested }) {
85
85
  const fallbacks = FALLBACK_MODELS.test(model);
86
86
  const effort = EFFORT_MODELS.test(model) ? process.env.NOU_EFFORT ?? requested ?? "high" : undefined;
87
87
  const body = {
@@ -105,20 +105,9 @@ export function anthropicProvider() {
105
105
  let text = "";
106
106
  try {
107
107
  const stream = getClient().beta.messages.stream(body, signal ? { signal } : {});
108
- // Thinking streams as a quote above the reply (the chat shows quotes dimmed); only the reply is kept.
109
- let thinking = false;
110
- stream.on("thinking", (delta) => {
111
- const piece = `${thinking ? "" : "> "}${delta.replace(/\n/g, "\n> ")}`;
112
- thinking = true;
113
- text += piece;
114
- onText(piece);
115
- });
108
+ // Thinking goes to the progress line, not the reply: it isn't kept, so it mustn't reflow the chat.
109
+ stream.on("thinking", (delta) => onThinking?.(delta));
116
110
  stream.on("text", (delta) => {
117
- if (thinking) {
118
- thinking = false;
119
- text += "\n\n";
120
- onText("\n\n");
121
- }
122
111
  text += delta;
123
112
  onText(delta);
124
113
  });
@@ -101,28 +101,27 @@ const CALLS = /^(call_expression|call|method_invocation|new_expression)$/;
101
101
  /** A named string node (anonymous keyword nodes can also be called "string", e.g. the TS type). */
102
102
  const isString = (n) => n.isNamed && STRINGS.test(n.type);
103
103
  const hasSubstitution = (n) => n.children.some((c) => c !== null && /substitution|interpolation/.test(c.type));
104
- function colorFor(node) {
104
+ /**
105
+ * The color of a leaf. Its `parent` and `grand`parent and whether it's `inString` come from the walk over
106
+ * the tree: asking the node for them (node.parent) crosses into wasm, and every token used to, several times.
107
+ */
108
+ function colorFor(node, parent, grand, inString) {
105
109
  const type = node.type;
106
110
  if (type.includes("comment"))
107
111
  return "comment";
108
112
  // Inside a string (but not inside ${...} substitutions)?
109
- for (let p = node; p; p = p.parent) {
110
- if (/substitution|interpolation/.test(p.type))
111
- break;
112
- if (isString(p))
113
- return "string";
114
- }
113
+ if (inString || isString(node))
114
+ return "string";
115
115
  // Built-in type names (`number`, `string`) and Python annotations (`name: str`).
116
- if (node.parent?.type === "predefined_type" || (node.parent?.type === "type" && /identifier$/.test(type)))
116
+ if (parent?.type === "predefined_type" || (parent?.type === "type" && /identifier$/.test(type)))
117
117
  return "type";
118
118
  if (NUMBERS.test(type))
119
119
  return "number";
120
120
  if (CONSTANTS.test(type) || CONSTANTS.test(node.text))
121
121
  return node.isNamed || /^[a-zA-Z]+$/.test(node.text) ? "constant" : null;
122
- if (TYPES.test(type) && (node.isNamed || node.parent?.type === "predefined_type"))
122
+ if (TYPES.test(type) && (node.isNamed || parent?.type === "predefined_type"))
123
123
  return "type";
124
124
  if (/identifier$/.test(type)) {
125
- const parent = node.parent;
126
125
  if (parent && FUNCTION_PARENTS.test(parent.type) && parent.childForFieldName("name")?.equals(node))
127
126
  return "func";
128
127
  if (parent && CALLS.test(parent.type) && parent.childForFieldName("function")?.equals(node))
@@ -131,7 +130,7 @@ function colorFor(node) {
131
130
  const member = parent && /member_expression|attribute|field_expression|selector_expression/.test(parent.type)
132
131
  ? parent.childForFieldName("property") ?? parent.childForFieldName("attribute") ?? parent.childForFieldName("field")
133
132
  : null;
134
- if (member?.equals(node) && parent.parent && CALLS.test(parent.parent.type) && parent.parent.childForFieldName("function")?.equals(parent))
133
+ if (member?.equals(node) && grand && CALLS.test(grand.type) && grand.childForFieldName("function")?.equals(parent))
135
134
  return "func";
136
135
  }
137
136
  if (PROPERTIES.test(type))
@@ -146,7 +145,7 @@ function colorFor(node) {
146
145
  return null;
147
146
  }
148
147
  if (!node.isNamed) {
149
- if (node.parent?.type === "predefined_type")
148
+ if (parent?.type === "predefined_type")
150
149
  return "type";
151
150
  return /^[a-z_]+$/i.test(type) ? "keyword" : "punctuation";
152
151
  }
@@ -162,14 +161,14 @@ export function highlightTree(file, text) {
162
161
  let out = "";
163
162
  let pos = 0;
164
163
  const cursor = tree.walk();
165
- // Depth-first over leaves, coloring each span.
166
- const visit = () => {
164
+ // Depth-first over leaves, coloring each span; each level knows its parent, grandparent and whether it's in a string.
165
+ const visit = (parent, grand, inString) => {
167
166
  do {
168
167
  const node = cursor.currentNode;
169
168
  if (node.childCount === 0 || node.type.includes("comment") || (isString(node) && !hasSubstitution(node))) {
170
169
  if (node.startIndex >= pos) {
171
170
  out += themeColors().plain(text.slice(pos, node.startIndex));
172
- const color = colorFor(node);
171
+ const color = colorFor(node, parent, grand, inString);
173
172
  const span = text.slice(node.startIndex, node.endIndex);
174
173
  // Color each line separately so styles don't bleed across line breaks.
175
174
  out += span.split("\n").map((part) => (color ? themeColors()[color](part) : themeColors().plain(part))).join("\n");
@@ -177,13 +176,14 @@ export function highlightTree(file, text) {
177
176
  }
178
177
  }
179
178
  else if (cursor.gotoFirstChild()) {
180
- visit();
179
+ // A ${...} substitution inside a string starts code again.
180
+ visit(node, parent, /substitution|interpolation/.test(node.type) ? false : inString || isString(node));
181
181
  cursor.gotoParent();
182
182
  }
183
183
  } while (cursor.gotoNextSibling());
184
184
  };
185
185
  if (cursor.gotoFirstChild())
186
- visit();
186
+ visit(tree.rootNode, null, false);
187
187
  out += themeColors().plain(text.slice(pos));
188
188
  tree.delete();
189
189
  return out.split("\n");
@@ -0,0 +1,127 @@
1
+ import { spawn } from "child_process";
2
+ import { existsSync, statSync } from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { stripVTControlCharacters } from "util";
6
+ import { stopProcess } from "../commands/index.js";
7
+ /** Lines of output kept (the oldest go first). */
8
+ const KEEP = 3000;
9
+ /**
10
+ * The terminal panel's shell: runs what you type with your shell in the folder you've `cd`'d to, and
11
+ * keeps the output. Each command is its own process (no native pseudo-terminal), so full-screen programs
12
+ * like vim won't draw, but builds, tests, git and servers work, and what you type while one runs goes
13
+ * to it.
14
+ */
15
+ export class Shell {
16
+ onChange;
17
+ lines = [];
18
+ cwd = process.cwd();
19
+ child = null;
20
+ /** The line being written (output without its newline yet; a \r starts it over, like progress bars). */
21
+ partial = "";
22
+ constructor(onChange) {
23
+ this.onChange = onChange;
24
+ process.once("exit", () => this.interrupt()); // nothing it started is left running after Nou
25
+ }
26
+ get running() {
27
+ return !!this.child;
28
+ }
29
+ /** Runs a command line; `cd` and `clear` are handled here so they stick. */
30
+ run(command) {
31
+ const line = command.trim();
32
+ if (!line)
33
+ return;
34
+ if (this.child)
35
+ return this.input(command);
36
+ this.push({ text: `${this.prompt()}${line}`, kind: "command" });
37
+ if (line === "clear" || line === "cls") {
38
+ this.lines = [];
39
+ return this.onChange();
40
+ }
41
+ const cd = /^cd(?:\s+(.*))?$/.exec(line);
42
+ if (cd)
43
+ return this.changeDir(cd[1]?.trim().replace(/^["']|["']$/g, "") || os.homedir());
44
+ const posix = process.platform !== "win32";
45
+ const child = spawn(line, {
46
+ cwd: this.cwd,
47
+ shell: posix ? process.env.SHELL || true : true,
48
+ stdio: ["pipe", "pipe", "pipe"],
49
+ detached: posix, // its own process group, so Ctrl+C stops everything it started
50
+ env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
51
+ });
52
+ this.child = child;
53
+ const write = (chunk) => this.write(chunk.toString("utf-8"));
54
+ child.stdout?.on("data", write);
55
+ child.stderr?.on("data", write);
56
+ child.stdin?.on("error", () => { }); // it may exit before reading what you typed
57
+ child.on("error", (error) => this.push({ text: error.message, kind: "error" }));
58
+ child.on("close", (code, signal) => {
59
+ this.flush();
60
+ if (signal)
61
+ this.push({ text: "stopped", kind: "info" });
62
+ else if (code)
63
+ this.push({ text: `exit ${code}`, kind: "error" });
64
+ this.child = null;
65
+ this.onChange();
66
+ });
67
+ this.onChange();
68
+ }
69
+ /** Text for the running command's input (it gets a newline, like pressing Enter in a terminal). */
70
+ input(text) {
71
+ if (!this.child?.stdin?.writable)
72
+ return;
73
+ this.push({ text, kind: "command" });
74
+ this.child.stdin.write(`${text}\n`);
75
+ }
76
+ /** Ctrl+C: stops the running command. */
77
+ interrupt() {
78
+ if (this.child)
79
+ stopProcess(this.child);
80
+ }
81
+ clear() {
82
+ this.lines = [];
83
+ this.partial = "";
84
+ this.onChange();
85
+ }
86
+ dispose() {
87
+ this.interrupt();
88
+ }
89
+ prompt() {
90
+ const home = os.homedir();
91
+ const where = this.cwd === home ? "~" : this.cwd.startsWith(home + path.sep) ? `~${this.cwd.slice(home.length)}` : this.cwd;
92
+ return `${where.length > 30 ? `…${path.sep}${path.basename(where)}` : where} $ `;
93
+ }
94
+ changeDir(target) {
95
+ const next = path.resolve(this.cwd, target.startsWith("~") ? path.join(os.homedir(), target.slice(1)) : target);
96
+ if (!existsSync(next) || !statSync(next).isDirectory())
97
+ this.push({ text: `cd: no such folder: ${target}`, kind: "error" });
98
+ else
99
+ this.cwd = next;
100
+ this.onChange();
101
+ }
102
+ write(text) {
103
+ const parts = stripVTControlCharacters(text).replace(/\r\n/g, "\n").split("\n");
104
+ for (let i = 0; i < parts.length; i++) {
105
+ const part = parts[i];
106
+ // A carriage return goes back to the start of the line: what follows replaces it.
107
+ this.partial = part.includes("\r") ? part.slice(part.lastIndexOf("\r") + 1) || this.partial : this.partial + part;
108
+ if (i < parts.length - 1)
109
+ this.flush(true);
110
+ }
111
+ this.onChange();
112
+ }
113
+ flush(always = false) {
114
+ if (this.partial || always)
115
+ this.push({ text: this.partial.replace(/\t/g, " "), kind: "out" });
116
+ this.partial = "";
117
+ }
118
+ /** The output so far, with the line still being written. */
119
+ view() {
120
+ return this.partial ? [...this.lines, { text: this.partial, kind: "out" }] : this.lines;
121
+ }
122
+ push(line) {
123
+ this.lines.push(line);
124
+ if (this.lines.length > KEEP)
125
+ this.lines.splice(0, this.lines.length - KEEP);
126
+ }
127
+ }