nou-cli 0.2.1 → 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.
Files changed (42) hide show
  1. package/.env.example +13 -0
  2. package/README.md +47 -10
  3. package/dist/index.js +22 -2
  4. package/dist/src/agent/harness.js +54 -0
  5. package/dist/src/agent/index.js +162 -17
  6. package/dist/src/commands/index.js +54 -5
  7. package/dist/src/config/credentials.js +48 -0
  8. package/dist/src/config/index.js +4 -1
  9. package/dist/src/config/origins.js +14 -0
  10. package/dist/src/context/index.js +1 -1
  11. package/dist/src/engines/claude.js +147 -0
  12. package/dist/src/engines/codex.js +131 -0
  13. package/dist/src/engines/external.js +152 -0
  14. package/dist/src/engines/index.js +127 -0
  15. package/dist/src/engines/pi.js +161 -0
  16. package/dist/src/engines/types.js +1 -0
  17. package/dist/src/env.js +5 -1
  18. package/dist/src/git/index.js +14 -4
  19. package/dist/src/history/index.js +53 -1
  20. package/dist/src/plugins/index.js +109 -21
  21. package/dist/src/providers/anthropic.js +16 -1
  22. package/dist/src/providers/index.js +16 -0
  23. package/dist/src/providers/ollama.js +58 -1
  24. package/dist/src/providers/openai.js +26 -6
  25. package/dist/src/search/index.js +183 -0
  26. package/dist/src/setup/index.js +240 -0
  27. package/dist/src/syntax/index.js +11 -2
  28. package/dist/src/tools/index.js +72 -2
  29. package/dist/src/ui/App.js +452 -93
  30. package/dist/src/ui/Chat.js +19 -10
  31. package/dist/src/ui/CodeView.js +1 -1
  32. package/dist/src/ui/Editor.js +1 -1
  33. package/dist/src/ui/FileTree.js +12 -7
  34. package/dist/src/ui/Settings.js +91 -15
  35. package/dist/src/ui/clipboard.js +29 -0
  36. package/dist/src/ui/mouse.js +4 -2
  37. package/dist/src/ui/text.js +167 -0
  38. package/package.json +4 -2
  39. package/plugins/fetch-url.mjs +82 -0
  40. package/plugins/project-rules.mjs +43 -0
  41. package/plugins/run-tests.mjs +89 -0
  42. package/plugins/todos.mjs +81 -0
@@ -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]);
@@ -3,43 +3,82 @@ import { useEffect, useState } from "react";
3
3
  import { Box, Text, useAnimation, useInput } from "ink";
4
4
  import TextInput from "ink-text-input";
5
5
  import { AUTO_SAVE, EFFORTS } from "../config/index.js";
6
+ import { saveCredentials } from "../config/credentials.js";
7
+ import { originOf, setOutsideNou } from "../config/origins.js";
6
8
  import { PLUGIN_DIRS, isEnabled, trustState } from "../plugins/index.js";
7
9
  import { currentVersion } from "../update/index.js";
8
- import { describe, providers } from "../providers/index.js";
10
+ import { describe, providers, rejectedKey } from "../providers/index.js";
11
+ import { ENGINES, isInstalled } from "../engines/index.js";
9
12
  import { C, SPINNER } from "./theme.js";
10
- const TABS = ["Model", "Preferences", "Plugins"];
13
+ const TABS = ["Model", "Engine", "Preferences", "Plugins"];
11
14
  const PREFS = [
12
15
  { key: "effort", label: "Reasoning effort", help: "How hard Claude thinks (low → max). Higher is smarter but slower and pricier." },
13
16
  { key: "autoSave", label: "Auto-save", help: "delay: a second after you stop typing · focus: when you leave the editor · off: only Ctrl+S." },
14
17
  { key: "mouse", label: "Mouse", help: "Wheel scrolls the pane under the pointer; click to focus or open files. Hold Shift to select text." },
15
18
  { key: "followMode", label: "Follow mode on start", help: "The code view jumps to files Nou reads and edits." },
19
+ { key: "reuseToolConfig", label: "Use Claude Code / Codex keys", help: "Use the API keys from Claude Code's or Codex's settings when you haven't set one. Applies on restart." },
16
20
  { key: "gitContext", label: "Git context", help: "Give the model your branch, changes and commits, plus git tools." },
17
21
  { key: "autoApproveEdits", label: "Auto-approve edits", help: "Apply file edits without asking (diffs are still shown).", warn: true },
18
22
  { key: "autoApproveCommands", label: "Auto-approve commands", help: "Run shell commands without asking. Only turn on if you trust the model.", warn: true },
19
23
  ];
20
- export function Settings({ width, height, settings, choice, plugins, onModel, onChange, onTogglePlugin, onRescanPlugins, onClose }) {
24
+ export function Settings({ width, height, settings, choice, plugins, connectTo, onModel, onEngine, onChange, onTogglePlugin, onRescanPlugins, onClose }) {
21
25
  const [tab, setTab] = useState("Model");
22
- const [row, setRow] = useState(0);
26
+ const [row, setRow] = useState(() => Math.max(0, providers.indexOf(connectTo ?? choice.provider)));
23
27
  const [column, setColumn] = useState("providers");
24
28
  const [modelRow, setModelRow] = useState(0);
25
29
  const [typing, setTyping] = useState(false);
26
30
  const [custom, setCustom] = useState("");
27
31
  const [models, setModels] = useState({});
32
+ const [connecting, setConnecting] = useState(connectTo?.connect ? { step: 0, values: {} } : null);
33
+ const [field, setField] = useState(() => (connectTo?.connect ? initialValue(connectTo.connect[0]) : ""));
28
34
  const { frame } = useAnimation({ interval: 80 });
29
35
  const provider = providers[row] ?? providers[0];
30
36
  const listed = models[provider.id];
31
37
  // Load the model list for the highlighted provider.
32
38
  useEffect(() => {
33
- if (tab !== "Model" || provider.unavailable() || models[provider.id])
39
+ if (tab !== "Model" || connecting || provider.unavailable() || models[provider.id])
34
40
  return;
35
41
  setModels((m) => ({ ...m, [provider.id]: "loading" }));
36
42
  provider.listModels()
37
43
  .then((list) => setModels((m) => ({ ...m, [provider.id]: list })))
38
44
  .catch((error) => setModels((m) => ({ ...m, [provider.id]: { error: String(error.message ?? error) } })));
39
- }, [tab, provider.id]);
45
+ }, [tab, provider.id, connecting]);
46
+ const startConnect = () => {
47
+ if (!provider.connect)
48
+ return;
49
+ setConnecting({ step: 0, values: {} });
50
+ setField(initialValue(provider.connect[0]));
51
+ };
52
+ /** Takes the current field's answer; after the last one, saves everything and shows the provider's models. */
53
+ const submitField = (value) => {
54
+ const fields = provider.connect;
55
+ const current = fields[connecting.step];
56
+ // Required, and nothing set yet: ask again.
57
+ if (!value.trim() && !current.optional && !process.env[current.env])
58
+ return;
59
+ const values = { ...connecting.values, [current.env]: value };
60
+ const next = connecting.step + 1;
61
+ if (next < fields.length) {
62
+ setConnecting({ step: next, values });
63
+ setField(initialValue(fields[next]));
64
+ return;
65
+ }
66
+ saveCredentials(values);
67
+ setConnecting(null);
68
+ setModels((m) => Object.fromEntries(Object.entries(m).filter(([id]) => id !== provider.id)));
69
+ if (!provider.unavailable()) {
70
+ setColumn("models");
71
+ setModelRow(0);
72
+ }
73
+ };
40
74
  const modelItems = Array.isArray(listed) ? listed : [];
41
- const rowsFor = (t) => (t === "Model" ? providers.length : t === "Preferences" ? PREFS.length : plugins.length);
75
+ const rowsFor = (t) => (t === "Model" ? providers.length : t === "Engine" ? ENGINES.length : t === "Preferences" ? PREFS.length : plugins.length);
42
76
  useInput((input, key) => {
77
+ if (connecting) {
78
+ if (key.escape)
79
+ setConnecting(null);
80
+ return;
81
+ }
43
82
  if (typing) {
44
83
  if (key.escape)
45
84
  setTyping(false);
@@ -48,7 +87,7 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
48
87
  if (key.tab) {
49
88
  const next = TABS[(TABS.indexOf(tab) + (key.shift ? TABS.length - 1 : 1)) % TABS.length];
50
89
  setTab(next);
51
- setRow(next === "Model" ? Math.max(0, providers.indexOf(choice.provider)) : 0);
90
+ setRow(next === "Model" ? Math.max(0, providers.indexOf(choice.provider)) : next === "Engine" ? Math.max(0, ENGINES.findIndex((e) => e.id === settings.engine)) : 0);
52
91
  setColumn("providers");
53
92
  return;
54
93
  }
@@ -67,6 +106,8 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
67
106
  setModelRow((r) => Math.min(count - 1, r + 1));
68
107
  else if (key.leftArrow)
69
108
  setColumn("providers");
109
+ else if (input === "k")
110
+ startConnect();
70
111
  else if (key.return) {
71
112
  if (modelRow === 0) {
72
113
  setCustom("");
@@ -86,6 +127,14 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
86
127
  setColumn("models");
87
128
  setModelRow(0);
88
129
  }
130
+ else
131
+ startConnect();
132
+ }
133
+ else if (tab === "Model" && input === "k") {
134
+ startConnect();
135
+ }
136
+ else if (tab === "Engine" && (key.return || input === " ")) {
137
+ onEngine(ENGINES[row].id);
89
138
  }
90
139
  else if (tab === "Preferences" && (key.return || input === " " || key.leftArrow || key.rightArrow)) {
91
140
  const pref = PREFS[row];
@@ -112,16 +161,32 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
112
161
  const body = () => {
113
162
  if (tab === "Model") {
114
163
  const leftWidth = Math.min(46, Math.floor(width * 0.45));
164
+ // Room for the label next to the marker, "local"/"cloud" and the lock (plus the border).
165
+ const labelWidth = Math.max(10, leftWidth - 14);
115
166
  return (_jsxs(Box, { flexDirection: "row", height: boxHeight, children: [_jsxs(Box, { flexDirection: "column", width: leftWidth, borderStyle: "round", borderColor: column === "providers" ? C.accent : C.dim, children: [_jsx(Text, { color: C.dim, bold: true, children: " PROVIDER" }), providers.map((p, i) => {
116
167
  const problem = p.unavailable();
117
168
  const selected = i === row;
118
169
  const current = p.id === choice.provider.id;
119
- return (_jsxs(Text, { wrap: "truncate-end", ...(selected && column === "providers" ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: current ? C.green : C.dim, children: current ? " ● " : " " }), _jsx(Text, { color: problem ? C.dim : C.text, bold: selected, children: p.label.padEnd(26).slice(0, 26) }), _jsx(Text, { color: p.local ? C.green : C.accent2, children: p.local ? " local" : " cloud" }), problem ? _jsx(Text, { color: C.dim, children: " \uD83D\uDD12" }) : null] }, p.id));
120
- })] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "round", borderColor: column === "models" ? C.accent : C.dim, paddingX: 1, children: [_jsxs(Text, { color: C.dim, bold: true, children: ["MODELS \u00B7 ", provider.label] }), provider.unavailable() ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.yellow, children: ["Not set up: ", provider.unavailable()] }), _jsx(Text, { color: C.dim, children: "Add it to a .env file in your project (see .env.example) or your environment, then restart Nou." })] })) : (_jsxs(_Fragment, { children: [typing ? (_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, children: "\u270E " }), _jsx(TextInput, { value: custom, onChange: setCustom, onSubmit: (v) => {
170
+ return (_jsxs(Text, { wrap: "truncate-end", ...(selected && column === "providers" ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: current ? C.green : C.dim, children: current ? " ● " : " " }), _jsx(Text, { color: problem ? C.dim : C.text, bold: selected, children: p.label.padEnd(labelWidth).slice(0, labelWidth) }), _jsx(Text, { color: p.local ? C.green : C.accent2, children: p.local ? " local" : " cloud" }), problem ? _jsx(Text, { color: C.dim, children: " \uD83D\uDD12" }) : null] }, p.id));
171
+ })] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "round", borderColor: column === "models" ? C.accent : C.dim, paddingX: 1, children: [_jsxs(Text, { color: C.dim, bold: true, children: ["MODELS \u00B7 ", provider.label] }), connecting && provider.connect ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.accent2, bold: true, children: ["Connect ", provider.label] }), provider.connect.map((f, i) => {
172
+ const entered = connecting.values[f.env];
173
+ const shown = (v) => (f.secret ? "•".repeat(Math.min(v.length, 12)) : v);
174
+ if (i < connecting.step) {
175
+ return _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["\u2713 ", f.label, ": ", entered ? shown(entered) : "unchanged"] }, f.env);
176
+ }
177
+ if (i > connecting.step)
178
+ return _jsxs(Text, { color: C.dim, children: [" ", f.label, f.optional ? " (optional)" : ""] }, f.env);
179
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsxs(Text, { color: C.accent2, children: ["\u270E ", f.label, f.optional ? " (optional)" : "", ": "] }), _jsx(TextInput, { value: field, onChange: setField, onSubmit: submitField, ...(f.secret ? { mask: "•" } : {}), ...(f.placeholder ? { placeholder: f.placeholder } : {}) })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", f.env, process.env[f.env] ? (f.secret ? ` is set (from ${originOf(f.env)}); Enter keeps it` : "") : f.optional ? "; Enter skips" : ""] }), setOutsideNou(f.env) && (_jsxs(Text, { color: C.yellow, wrap: "wrap", children: [" A new one works now, but ", originOf(f.env), " sets ", f.env, " too and wins when Nou restarts: change or remove it there."] }))] }, f.env));
180
+ }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "Saved to ~/.nou/credentials.json (only you can read it). Your environment and .env still win." })] })) : provider.unavailable() ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.yellow, children: ["Not set up: ", provider.unavailable()] }), provider.connect
181
+ ? _jsx(Text, { color: C.accent2, children: "Press Enter to connect it: paste your key here, no restart needed." })
182
+ : null, _jsx(Text, { color: C.dim, children: "Or add it to a .env file in your project (see .env.example) or your environment." })] })) : (_jsxs(_Fragment, { children: [typing ? (_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, children: "\u270E " }), _jsx(TextInput, { value: custom, onChange: setCustom, onSubmit: (v) => {
121
183
  setTyping(false);
122
184
  if (v.trim())
123
185
  onModel({ provider, model: v.trim() });
124
- }, placeholder: `e.g. ${provider.defaultModel}` })] })) : (_jsx(Text, { ...(column === "models" && modelRow === 0 ? { backgroundColor: C.panel } : {}), color: C.accent2, children: "\u270E Type a model name\u2026" })), listed === "loading" && _jsxs(Text, { color: C.accent2, children: [SPINNER[frame % SPINNER.length], " loading models\u2026"] }), listed && typeof listed === "object" && !Array.isArray(listed) && _jsxs(Text, { color: C.red, children: ["Couldn't list models: ", listed.error] }), (() => {
186
+ }, placeholder: `e.g. ${provider.defaultModel}` })] })) : (_jsx(Text, { ...(column === "models" && modelRow === 0 ? { backgroundColor: C.panel } : {}), color: C.accent2, children: "\u270E Type a model name\u2026" })), listed === "loading" && _jsxs(Text, { color: C.accent2, children: [SPINNER[frame % SPINNER.length], " loading models\u2026"] }), listed && typeof listed === "object" && !Array.isArray(listed) && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.red, children: ["Couldn't list models: ", listed.error] }), rejectedKey(listed.error) && provider.connect?.some((f) => f.secret) && (() => {
187
+ const key = provider.connect.find((f) => f.secret).env;
188
+ return _jsxs(Text, { color: C.yellow, wrap: "wrap", children: ["The ", key, " ", originOf(key) ? `from ${originOf(key)} ` : "", "was rejected. Press k to enter a different key."] });
189
+ })()] })), (() => {
125
190
  const visibleCount = Math.max(1, boxHeight - 6);
126
191
  const offset = Math.max(0, Math.min(modelRow - 1 - Math.floor(visibleCount / 2), modelItems.length - visibleCount));
127
192
  return modelItems.slice(offset, offset + visibleCount).map((m, i) => {
@@ -132,6 +197,13 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
132
197
  });
133
198
  })()] }))] })] }));
134
199
  }
200
+ if (tab === "Engine") {
201
+ return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [ENGINES.map((engine, i) => {
202
+ const selected = i === row;
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 · " : "", "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
+ }), _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
+ }
135
207
  if (tab === "Preferences") {
136
208
  return (_jsx(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: PREFS.map((pref, i) => {
137
209
  const selected = i === row;
@@ -152,15 +224,19 @@ export function Settings({ width, height, settings, choice, plugins, onModel, on
152
224
  (p.plugin.diagnostics ?? []).length && "checker",
153
225
  ].filter(Boolean)
154
226
  : [];
155
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), toggle(enabled), _jsxs(Text, { color: C.text, bold: true, children: [" ", p.plugin?.name ?? p.id] }), _jsxs(Text, { color: C.dim, children: [" ", p.plugin?.version ? `v${p.plugin.version} · ` : "", p.scope, parts.length ? ` · ${parts.join(", ")}` : ""] })] }), state === "changed"
227
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸ " : " " }), p.scope === "starter" ? _jsx(Text, { color: C.accent2, bold: true, children: "+ ADD" }) : toggle(enabled), _jsxs(Text, { color: C.text, bold: true, children: [" ", p.plugin?.name ?? p.id] }), _jsxs(Text, { color: C.dim, children: [" ", p.plugin?.version ? `v${p.plugin.version} · ` : "", p.scope === "starter" ? "starter, optional" : p.scope, parts.length ? ` · ${parts.join(", ")}` : ""] })] }), state === "changed"
156
228
  ? _jsx(Text, { color: C.yellow, wrap: "truncate-end", children: " \u26A0 Changed since you enabled it. Review the code, then press Enter to enable again." })
157
229
  : p.error
158
230
  ? _jsxs(Text, { color: C.red, wrap: "truncate-end", children: [" \u2716 ", p.error] })
159
231
  : _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", p.plugin?.description ?? (enabled ? "" : "Enable to load it.")] })] }, p.id));
160
- }), _jsx(Text, { children: " " }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Add your own: drop a .js/.mjs file into ", PLUGIN_DIRS.map((d) => d.dir).join(" or "), ", then press r."] }), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "Example: examples/plugins/word-count.mjs in the Nou repo." })] }));
232
+ }), _jsx(Text, { children: " " }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Starters: Enter adds one to ", PLUGIN_DIRS.find((d) => d.scope === "user").dir, ", where you can edit it."] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: ["Write your own: /plugins new <name>, or drop a .js/.mjs file into ", PLUGIN_DIRS.map((d) => d.dir).join(" or "), ", then press r."] })] }));
161
233
  };
162
234
  const hints = tab === "Model"
163
- ? column === "models" ? "↑↓ choose · Enter use model · ← providers" : "↑↓ provider · Enter/→ pick a model"
164
- : tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change option" : "↑↓ move · Enter toggle · r rescan";
235
+ ? connecting ? "Enter next · Esc cancel" : column === "models" ? `↑↓ choose · Enter use model · ← providers${provider.connect ? " · k change key" : ""}` : `↑↓ provider · Enter/→ ${provider.unavailable() ? "connect" : "pick a model"}${provider.connect ? " · k change key" : ""}`
236
+ : tab === "Engine" ? "↑↓ move · Enter use this engine" : tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change option" : "↑↓ move · Enter toggle · r rescan";
165
237
  return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, bold: true, children: "\u2699 Settings " }), TABS.map((t) => (_jsx(Text, { ...(t === tab ? { backgroundColor: C.accent2, color: "black" } : { color: C.dim }), bold: t === tab, children: ` ${t} ` }, t))), _jsx(Text, { color: C.dim, children: " current: " }), _jsx(Text, { color: C.green, children: describe(choice) }), _jsxs(Text, { color: C.dim, children: [" nou v", currentVersion()] })] }), _jsx(Text, { children: " " }), body(), _jsxs(Text, { color: C.dim, children: [" Tab next section \u00B7 ", hints, " \u00B7 Esc close ", _jsx(Text, { color: C.dim, children: "saved to ~/.nou/config.json" })] })] }));
166
238
  }
239
+ /** What a connect field starts with: the current URL to edit (keys stay hidden, so they start empty). */
240
+ function initialValue(f) {
241
+ return f.secret ? "" : process.env[f.env] ?? "";
242
+ }
@@ -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
  }
@@ -98,3 +98,170 @@ export function changedLines(before, after) {
98
98
  }
99
99
  return lines;
100
100
  }
101
+ /** Lines added and removed, for summaries like "+12 -3". */
102
+ export function lineCounts(before, after) {
103
+ let added = 0;
104
+ let removed = 0;
105
+ for (const part of diffLines(before.replace(/\r\n/g, "\n"), after.replace(/\r\n/g, "\n"))) {
106
+ if (part.added)
107
+ added += part.count ?? 0;
108
+ else if (part.removed)
109
+ removed += part.count ?? 0;
110
+ }
111
+ return { added, removed };
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.2.1",
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": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "dist",
11
+ "plugins",
11
12
  ".env.example",
12
13
  "README.md"
13
14
  ],
@@ -19,7 +20,8 @@
19
20
  "build": "tsc -p tsconfig.build.json && node scripts/copy-grammars.mjs",
20
21
  "start": "node dist/index.js",
21
22
  "typecheck": "tsc --noEmit -p .",
22
- "lint": "eslint index.ts src",
23
+ "lint": "eslint index.ts src evals plugins",
24
+ "eval": "tsx evals/run.ts",
23
25
  "prepublishOnly": "npm run build",
24
26
  "screenshot": "node -e \"process.env.FORCE_COLOR='3';require('child_process').execSync('npx tsx scripts/screenshot.tsx',{stdio:'inherit'})\"",
25
27
  "test": "tsx tests/unit.ts && tsx tests/e2e.tsx"