nou-cli 0.5.3 → 0.5.6

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.
@@ -0,0 +1,100 @@
1
+ import path from "path";
2
+ export const SHOW_STEPS = "show_steps";
3
+ /** The tool the engine hands its steps back with. `onSteps` gets them; the engine is told they're shown. */
4
+ export function showStepsTool(onSteps) {
5
+ return {
6
+ definition: {
7
+ name: SHOW_STEPS,
8
+ description: "Only when the user asked you to show them how ([Show me how] in their message): hand back the change as steps for them to type themselves, instead of editing. Each step adds whole lines of code before a line of a file, with why it's needed. Call it once, with every step in order.",
9
+ parameters: {
10
+ type: "object",
11
+ properties: {
12
+ steps: {
13
+ type: "array",
14
+ items: {
15
+ type: "object",
16
+ properties: {
17
+ file: { type: "string", description: "Project-relative path of the file to add to." },
18
+ line: { type: "number", description: "1-based line the code goes in before (the line after the last one appends)." },
19
+ code: { type: "string", description: "The lines to type, indented as they'll be in the file." },
20
+ why: { type: "string", description: "One or two plain sentences: what this does and why it's needed." },
21
+ },
22
+ required: ["file", "line", "code", "why"],
23
+ },
24
+ },
25
+ },
26
+ required: ["steps"],
27
+ },
28
+ },
29
+ readOnly: true,
30
+ source: "nou",
31
+ run: (args) => {
32
+ const steps = tidySteps(args.steps);
33
+ if (!steps.length)
34
+ return "No usable steps: each needs a file, a line and the code to type. Call show_steps again.";
35
+ onSteps(steps);
36
+ return `Shown to the user as ${steps.length} step${steps.length > 1 ? "s" : ""} to type. Don't edit the files yourself; finish with a one-line summary.`;
37
+ },
38
+ };
39
+ }
40
+ /** Steps as the model gave them, kept when usable: a file, a line, code to type. */
41
+ function tidySteps(raw) {
42
+ return (Array.isArray(raw) ? raw : [])
43
+ .filter((s) => typeof s?.file === "string" && typeof s?.code === "string" && s.code.trim())
44
+ .map((s) => ({ file: path.relative(process.cwd(), path.resolve(s.file)).split(path.sep).join("/"), line: Math.max(1, Math.floor(Number(s.line) || 1)), code: s.code.replace(/\r\n/g, "\n").replace(/\n+$/, ""), why: String(s.why ?? "") }));
45
+ }
46
+ /** The fenced block other engines (which can't call Nou's tools) put their steps in. */
47
+ const BLOCK = /```nou-steps\s*\n([\s\S]*?)\n```/;
48
+ /**
49
+ * The message that asks for steps. Nou's own engine hands them back with the show_steps tool; Claude
50
+ * Code, Codex and pi can't call Nou's tools, so they end their reply with a ```nou-steps block instead.
51
+ */
52
+ export function showMePrompt(task, withTool) {
53
+ const how = withTool
54
+ ? `call ${SHOW_STEPS} once with the steps in order`
55
+ : "end your reply with the steps in order as a JSON array in a fenced block marked nou-steps, like:\n```nou-steps\n[{\"file\": \"src/cli.ts\", \"line\": 12, \"code\": \"const verbose = args.includes(\\\"--verbose\\\");\", \"why\": \"Reads the new flag.\"}]\n```";
56
+ return `[Show me how] ${task}\n\nDon't edit any files: I want to type it myself. Read what you need to find where it goes, then ${how} (each step: file, the line to add the code before, the exact lines of code to type, and why). Keep each step small, a few lines, so it's easy to follow.`;
57
+ }
58
+ /** Steps from a reply's ```nou-steps block, and the reply without it; null when there's none. */
59
+ export function stepsFromReply(text) {
60
+ const m = BLOCK.exec(text);
61
+ if (!m)
62
+ return null;
63
+ try {
64
+ const steps = tidySteps(JSON.parse(m[1]));
65
+ return steps.length ? { steps, rest: text.replace(m[0], "").trim() } : null;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ /**
72
+ * How far the text typed so far (`typed`, from where the step starts to the cursor) gets through
73
+ * `expected`: the index reached in `expected`, or -1 when it doesn't match. Leading indentation on each
74
+ * line is ignored either way, since the editor indents new lines itself.
75
+ */
76
+ export function guideProgress(typed, expected) {
77
+ let i = 0;
78
+ let j = 0;
79
+ const skipIndent = (s, k) => {
80
+ while (k < s.length && (s[k] === " " || s[k] === "\t"))
81
+ k++;
82
+ return k;
83
+ };
84
+ i = skipIndent(typed, i);
85
+ j = skipIndent(expected, j);
86
+ while (i < typed.length) {
87
+ if (j >= expected.length || typed[i] !== expected[j])
88
+ return -1;
89
+ const newline = typed[i] === "\n";
90
+ i++;
91
+ j++;
92
+ if (newline) {
93
+ i = skipIndent(typed, i);
94
+ // The rest of the expected indentation counts as typed only once something follows it.
95
+ if (i < typed.length)
96
+ j = skipIndent(expected, j);
97
+ }
98
+ }
99
+ return j;
100
+ }
@@ -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");