nou-cli 0.5.3 → 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.
@@ -3,13 +3,23 @@ import { memo, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Box, Text, useAnimation, useInput } from "ink";
4
4
  import { statSync } from "fs";
5
5
  import path from "path";
6
- import { closeOpenMarkdown, linkAt, linkFileRefs, renderMarkdown, wrap } from "./text.js";
6
+ import { closeOpenMarkdown, linkAt, linkFileRefs, linkUrls, renderMarkdown, wrap } from "./text.js";
7
7
  import { ChatInput, inputHeight } from "./ChatInput.js";
8
8
  import { copyToClipboard } from "./clipboard.js";
9
- import { C, SPINNER } from "./theme.js";
9
+ import { C, SPINNER, ANIMATION_MS } from "./theme.js";
10
10
  import { Pane } from "./Pane.js";
11
11
  import { matchCommands } from "./commands.js";
12
12
  import { Scrollbar, topFor } from "./scroll.js";
13
+ /**
14
+ * What the agent did between replies (files read, commands run...) folds into one line, like Claude's
15
+ * "▸ 5 steps": click it (or Ctrl+O for all of them) to see each step. `open` holds the runs shown open.
16
+ */
17
+ function stepLines(steps, open, live, inner) {
18
+ const last = steps[steps.length - 1].text.replace(/\s+/g, " ");
19
+ const summary = `${open ? "▾" : "▸"} ${steps.length} steps${open ? "" : ` · ${live ? "now: " : "last: "}${last}`}`;
20
+ const head = { text: ` ${summary.length > inner - 4 ? `${summary.slice(0, inner - 5)}…` : summary}`, color: open ? C.accent2 : C.dim, lead: " ⚙", leadColor: C.dim, toggle: steps[0] };
21
+ return open ? [head, ...steps.flatMap((e) => cachedLines(e, inner).map((l) => (l.lead === " ⚙" ? { ...l, lead: " " } : l)))] : [head];
22
+ }
13
23
  const BAR = "▌ ";
14
24
  // Whether a path in a message is a file in the project, remembered so each one is checked once.
15
25
  const knownFiles = new Map();
@@ -54,7 +64,7 @@ function entryLines(entry, inner, links = true) {
54
64
  ? { icon: "⚙", color: C.dim }
55
65
  : entry.kind === "error" ? { icon: "✖", color: C.red } : { icon: "ℹ", color: C.accent };
56
66
  wrap(entry.text, inner - 4).forEach((line, i) => {
57
- const spans = links && entry.kind !== "user" ? linkFileRefs([{ text: ` ${line}` }], isProjectFile) : [];
67
+ const spans = links && entry.kind !== "user" ? linkFileRefs(linkUrls([{ text: ` ${line}` }]), isProjectFile) : [];
58
68
  out.push({ text: ` ${line}`, color: style.color, lead: i === 0 ? ` ${style.icon}` : " ", leadColor: style.color, ...(spans.some((sp) => sp.link) ? { spans } : {}) });
59
69
  });
60
70
  }
@@ -71,17 +81,67 @@ function cachedLines(entry, inner) {
71
81
  laidOut.set(entry, { inner, lines });
72
82
  return lines;
73
83
  }
84
+ /**
85
+ * Where each tab sits in the chat's title row (columns from the pane's inner left edge), and the "+"
86
+ * that opens a new one: shared by the drawing and the clicks.
87
+ */
88
+ export function chatTabZones(tabs) {
89
+ // One tab: just "Chat +", to leave the title row its room.
90
+ if (tabs.length === 1)
91
+ return { zones: [{ id: tabs[0].id, from: 3, to: 7 }], plus: 8, width: 9 };
92
+ let x = 3; // " ◆ "
93
+ const zones = tabs.map((t) => {
94
+ const w = tabText(t).length;
95
+ const zone = { id: t.id, from: x, to: x + w };
96
+ x += w + 1;
97
+ return zone;
98
+ });
99
+ return { zones, plus: x + 1, width: x + 3 };
100
+ }
101
+ const tabText = (t) => ` ${t.state === "busy" ? "● " : t.state === "waiting" ? "! " : ""}${t.label} `;
74
102
  const lineText = (line) => (line.spans ? line.spans.map((s) => s.text).join("") : line.text ?? "");
75
- export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, queued = [] }) {
76
- const [input, setInput] = useState("");
103
+ export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [] }) {
104
+ const [input, setInputText] = useState("");
105
+ // Whether the chat has the keyboard right now: a key handler from a render before focus moved away can
106
+ // still be attached for a moment (on a busy machine), and its keys belong to the pane that has focus.
107
+ const focusedNow = useRef(focused);
108
+ focusedNow.current = focused;
109
+ const [menuRow, setMenuRow] = useState(0);
110
+ /** A new input also puts the command menu back on its first row, in the same render. */
111
+ const setInput = (text) => {
112
+ setInputText(text);
113
+ setMenuRow(0);
114
+ };
77
115
  useEffect(() => {
78
116
  if (prefill)
79
117
  setInput(prefill.text);
80
118
  }, [prefill?.nonce]);
81
119
  const [scrollBack, setScrollBack] = useState(0);
82
- const { frame } = useAnimation({ interval: 80, isActive: !!busy });
120
+ const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy });
83
121
  const inner = width - 3; // minus the borders and the scrollbar
84
- const history = useMemo(() => entries.flatMap((e) => cachedLines(e, inner)), [entries, inner]);
122
+ const [openSteps, setOpenSteps] = useState(new Set());
123
+ const [allOpen, setAllOpen] = useState(false);
124
+ const history = useMemo(() => {
125
+ const out = [];
126
+ for (let i = 0; i < entries.length;) {
127
+ if (entries[i].kind !== "tool") {
128
+ out.push(...cachedLines(entries[i], inner));
129
+ i++;
130
+ continue;
131
+ }
132
+ // A run of steps: one alone is shown as is, two or more fold into a line.
133
+ let j = i;
134
+ while (j < entries.length && entries[j].kind === "tool")
135
+ j++;
136
+ const steps = entries.slice(i, j);
137
+ if (steps.length === 1)
138
+ out.push(...cachedLines(steps[0], inner));
139
+ else
140
+ out.push(...stepLines(steps, allOpen || openSteps.has(steps[0]), !!busy && j === entries.length, inner));
141
+ i = j;
142
+ }
143
+ return out;
144
+ }, [entries, inner, openSteps, allOpen, !!busy]);
85
145
  const lines = useMemo(() => (streaming ? [...history, ...entryLines({ kind: "assistant", text: closeOpenMarkdown(streaming) }, inner, false)] : history), [history, streaming, inner]);
86
146
  // At the bottom, new output scrolls into view. Scrolled up to read, the view stays where it is
87
147
  // (counted from the bottom, so it moves up by however many lines arrived) until you scroll back down.
@@ -135,8 +195,6 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
135
195
  // Typing "/" opens a menu of commands with what each does; ↑↓ pick one, Tab completes, Enter runs it.
136
196
  const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
137
197
  const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
138
- const [menuRow, setMenuRow] = useState(0);
139
- useEffect(() => setMenuRow(0), [input]);
140
198
  const showMenu = matches.length > 0;
141
199
  useEffect(() => onMenu?.(showMenu), [showMenu]);
142
200
  const menuRows = Math.min(matches.length, Math.max(1, Math.min(7, height - 12)));
@@ -164,6 +222,20 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
164
222
  if (!click)
165
223
  return;
166
224
  const line = visible[click.row];
225
+ const run = line?.toggle;
226
+ if (run) {
227
+ setOpenSteps((open) => {
228
+ const next = new Set(open);
229
+ if (next.has(run) || allOpen)
230
+ next.delete(run);
231
+ else
232
+ next.add(run);
233
+ return next;
234
+ });
235
+ if (allOpen)
236
+ setAllOpen(false);
237
+ return;
238
+ }
167
239
  const ref = line?.spans ? linkAt(line.spans, click.col) : null;
168
240
  if (ref)
169
241
  onOpenRef?.(ref);
@@ -225,13 +297,19 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
225
297
  else if (typed && typed >= " ")
226
298
  setInput(typed); // printable, not a control key
227
299
  }, { isActive: focused && !!approval && !input });
228
- useInput((_input, key) => {
300
+ useInput((input, key) => {
229
301
  if (key.pageUp || (key.upArrow && key.shift))
230
302
  setScrollBack((b) => Math.min(maxBack, Math.min(b, maxBack) + Math.max(1, rows - 2)));
231
303
  else if (key.pageDown || (key.downArrow && key.shift))
232
304
  setScrollBack((b) => Math.max(0, Math.min(b, maxBack) - Math.max(1, rows - 2)));
233
305
  else if (key.ctrl && key.home)
234
306
  setScrollBack(maxBack);
307
+ else if (key.ctrl && input === "o") {
308
+ // Open every run of steps, or fold them all (the ones opened by a click too).
309
+ setAllOpen(!allOpen);
310
+ if (allOpen)
311
+ setOpenSteps(new Set());
312
+ }
235
313
  else if (key.ctrl && key.end)
236
314
  setScrollBack(0);
237
315
  }, { isActive: focused });
@@ -262,19 +340,22 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
262
340
  ? line.spans.map((span, i) => (_jsx(Text, { bold: !!span.bold || !!line.bold, italic: !!span.italic, dimColor: !!span.dim && !span.link, underline: !!span.link, ...(span.link ? { color: C.cyan } : span.code ? { color: C.yellow } : line.color ? { color: line.color } : { color: C.text }), children: span.text }, i)))
263
341
  : _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
264
342
  const empty = entries.length === 0 && !streaming;
265
- const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", flash ? _jsxs(Text, { color: C.green, bold: true, children: [" ", flash] }) : back
343
+ const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " ", tabs && tabs.items.length > 1 ? (_jsxs(_Fragment, { children: [tabs.items.map((t) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.id === tabs.active ? { backgroundColor: C.panel, color: C.text, bold: true } : { color: t.state === "waiting" ? C.yellow : C.dim }), children: tabText(t) }), " "] }, t.id))), _jsx(Text, { color: C.accent2, bold: true, children: " + " })] })) : _jsxs(_Fragment, { children: ["Chat", tabs ? _jsx(Text, { color: C.accent2, bold: true, children: " +" }) : null] }), flash ? _jsxs(Text, { color: C.green, bold: true, children: [" ", flash] }) : back
266
344
  ? unread
267
345
  ? _jsxs(Text, { color: C.green, bold: true, children: [" \u2193 ", unread, " new lines below", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
268
346
  : _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
269
347
  : maxBack > 0 ? _jsx(Text, { color: C.dim, children: " PgUp / wheel to scroll" }) : null] }));
270
- return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, 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." })] })) : (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { children: visible.map((line, i) => _jsx(Text, { color: line.leadColor ?? C.dim, children: `${line.lead ?? " "}${i < visible.length - 1 ? "\n" : ""}` }, i)) }) }), _jsx(Box, { flexDirection: "column", width: inner - 2, flexShrink: 0, children: visible.map((line, i) => {
271
- const range = selectedIn(firstShown + i);
272
- if (!range)
273
- return render(line, firstShown + i, false);
274
- const t = lineText(line) || " ";
275
- return (_jsxs(Text, { wrap: "truncate-end", color: C.text, children: [t.slice(0, range[0]), _jsx(Text, { inverse: true, children: t.slice(range[0], range[1]) || " " }), t.slice(range[1])] }, firstShown + i));
276
- }) })] })) }), _jsx(Scrollbar, { total: lines.length, top: firstShown, rows: rows, focused: focused })] }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), showMenu ? (_jsx(Box, { flexDirection: "column", children: matches.slice(menuStart, menuStart + menuRows).map((c, i) => {
348
+ // The messages, rebuilt only when they or the scroll position change: typing, the context chip and the
349
+ // spinner re-render the chat without touching its (often hundreds of) lines.
350
+ const messages = useMemo(() => (_jsx(_Fragment, { 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." })] })) : (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { children: visible.map((line, i) => _jsx(Text, { color: line.leadColor ?? C.dim, children: `${line.lead ?? " "}${i < visible.length - 1 ? "\n" : ""}` }, i)) }) }), _jsx(Box, { flexDirection: "column", width: inner - 2, flexShrink: 0, children: visible.map((line, i) => {
351
+ const range = selectedIn(firstShown + i);
352
+ if (!range)
353
+ return render(line, firstShown + i, false);
354
+ const t = lineText(line) || " ";
355
+ return (_jsxs(Text, { wrap: "truncate-end", color: C.text, children: [t.slice(0, range[0]), _jsx(Text, { inverse: true, children: t.slice(range[0], range[1]) || " " }), t.slice(range[1])] }, firstShown + i));
356
+ }) })] })) })), [lines, firstShown, visible.length, selection, inner, empty]);
357
+ return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: messages }), _jsx(Scrollbar, { total: lines.length, top: firstShown, rows: rows, focused: focused })] }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), showMenu ? (_jsx(Box, { flexDirection: "column", children: matches.slice(menuStart, menuStart + menuRows).map((c, i) => {
277
358
  const selected = menuStart + i === menuRow;
278
359
  return (_jsxs(Text, { wrap: "truncate-end", ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸" : " " }), _jsxs(Text, { color: selected ? C.accent : C.text, bold: selected, children: ["/", c.name] }), c.args && inner >= 56 ? _jsxs(Text, { color: C.dim, children: [" ", c.args] }) : null /* narrow: the description matters more */, _jsxs(Text, { color: C.dim, children: [" ", c.description] })] }, c.name));
279
- }) })) : (_jsx(Text, { wrap: "truncate-end", color: C.dim, children: 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 + 1)) }), _jsx(ChatInput, { value: input, onChange: setInput, onSubmit: submit, focus: focused && !(approval && !input), width: inner + 1, history: sent, arrowsTaken: showMenu, placeholder: busy ? (inner >= 44 ? "Enter queues · Alt+Enter sends now · Esc stops" : "Enter queues · Esc stops") : showMenu ? "↑↓ choose · Tab complete · Enter run" : inner >= 44 ? "Ask anything · / for commands · ↑ history" : "Ask anything · / commands" })] }));
360
+ }) })) : (_jsx(Text, { wrap: "truncate-end", color: C.dim, children: 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 + 1)) }), _jsx(ChatInput, { value: input, onChange: (text) => focusedNow.current && setInput(text), onSubmit: submit, focus: focused && !(approval && !input), width: inner + 1, history: sent, arrowsTaken: showMenu, placeholder: busy ? (inner >= 44 ? "Enter queues · Alt+Enter sends now · Esc stops" : "Enter queues · Esc stops") : showMenu ? "↑↓ choose · Tab complete · Enter run" : inner >= 44 ? "Ask anything · / for commands · ↑ history" : "Ask anything · / commands" })] }));
280
361
  });
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { memo, useEffect, useMemo, useRef, useState } from "react";
3
3
  import path from "path";
4
4
  import { Box, Text, useInput, usePaste } from "ink";
@@ -8,6 +8,7 @@ import { SEVERITY_STYLE } from "./ProblemsView.js";
8
8
  import { Pane } from "./Pane.js";
9
9
  import { Scrollbar, topFor } from "./scroll.js";
10
10
  import { copyToClipboard, readClipboard } from "./clipboard.js";
11
+ import { guideProgress } from "../guide/index.js";
11
12
  const TAB = " ";
12
13
  const expand = (s) => s.replace(/\t/g, TAB);
13
14
  /** Screen column of buffer column `col` on `line` (tabs take two cells). */
@@ -87,13 +88,24 @@ const LINE_COMMENTS = {
87
88
  ".toml": "#", ".r": "#", ".pl": "#", ".ex": "#", ".exs": "#", ".dockerfile": "#", ".sql": "--", ".lua": "--", ".hs": "--",
88
89
  };
89
90
  const commentFor = (file) => LINE_COMMENTS[path.extname(file).toLowerCase()] ?? (path.basename(file).toLowerCase() === "dockerfile" ? "#" : null);
91
+ /** A line without the cursor or a selection: kept as is while you type on other lines. */
92
+ const PlainRow = memo(function PlainRow({ num, numColor, text }) {
93
+ return _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: numColor, children: num }), text || " "] });
94
+ });
90
95
  /** A text editor: arrows and the mouse to move, Shift to select, Ctrl+C/X/V clipboard, Ctrl+S save, Ctrl+Z undo, Esc leave. */
91
- export const Editor = memo(function Editor({ width, height, focused, filePath, initial, baseline, startLine, startCol, problems, autoSave, syntaxVersion, wheel, scrollTo, mouse, onSave, onExit, onState, onHover, onDefinition, tabs }) {
96
+ export const Editor = memo(function Editor({ width, height, focused, filePath, initial, baseline, startLine, startCol, problems, autoSave, syntaxVersion, wheel, scrollTo, mouse, onSave, onExit, onState, onHover, onDefinition, tabs, guide, onGuideDone }) {
92
97
  const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
93
98
  const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
94
99
  const comment = useMemo(() => commentFor(filePath), [filePath]);
95
100
  const [buf, setBuf] = useState(() => {
96
101
  const lines = initial.replace(/\r\n/g, "\n").split("\n");
102
+ if (guide) {
103
+ // A fresh line for the guided code, indented like its first line.
104
+ const row = Math.max(0, Math.min(lines.length, guide.row));
105
+ const indent = guide.code.match(/^[ \t]*/)[0];
106
+ lines.splice(row, 0, indent);
107
+ return { lines, row, col: indent.length, anchor: null };
108
+ }
97
109
  const row = Math.max(0, Math.min(lines.length - 1, (startLine ?? 1) - 1));
98
110
  const col = startCol ? Math.min(lines[row].length, startCol - 1) : lines[row].match(/^\s*/)[0].length;
99
111
  return { lines, row, col, anchor: null };
@@ -517,6 +529,12 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
517
529
  }
518
530
  return insert(`\n${indent}${opens ? indentUnit : ""}`);
519
531
  }
532
+ if (key.tab && !key.shift && !hasSelection && guide && ghost) {
533
+ // Guided: Tab types the rest of the line for you (and the next line's indentation).
534
+ const end = ghost.indexOf("\n");
535
+ const chunk = end < 0 ? ghost : ghost.slice(0, end + 1) + ghost.slice(end + 1).match(/^[ \t]*/)[0];
536
+ return insert(chunk);
537
+ }
520
538
  if (key.tab) {
521
539
  // With a selection, or Shift+Tab: indent or outdent whole lines.
522
540
  if (key.shift)
@@ -589,16 +607,32 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
589
607
  return map;
590
608
  }, [problems]);
591
609
  const sel = selection(buf);
610
+ // Guided typing: how much of the step's code is typed (from its line's start to the cursor).
611
+ const guideRow = guide ? Math.max(0, Math.min(buf.lines.length - 1, guide.row)) : -1;
612
+ const typed = guide && (buf.row > guideRow || (buf.row === guideRow)) ? [...buf.lines.slice(guideRow, buf.row), buf.lines[buf.row].slice(0, buf.col)].join("\n") : null;
613
+ const progress = guide && typed !== null ? guideProgress(typed, guide.code) : -1;
614
+ const ghost = guide && progress >= 0 ? guide.code.slice(progress) : "";
615
+ const guideDone = useRef(false);
616
+ useEffect(() => {
617
+ if (guide && progress === guide.code.length && !guideDone.current) {
618
+ guideDone.current = true;
619
+ onGuideDone?.();
620
+ }
621
+ }, [progress]);
592
622
  const cursorProblem = byLine.get(buf.row + 1)?.[0];
593
623
  const selectedCount = sel ? selectedText(buf).length : 0;
594
- const status = confirmExit
624
+ const guideStatus = !guide ? null
625
+ : progress < 0 ? "Not quite: that doesn't match the step's code. Backspace and try again (the dim text shows what goes here)."
626
+ : ghost ? `Type the dim text${ghost.includes("\n") ? ` (${ghost.split("\n").length} lines to go)` : ""} · Tab fills in the rest of the line · Esc stop`
627
+ : "✓ Step done";
628
+ const status = guideStatus && !confirmExit && !prompt ? guideStatus : confirmExit
595
629
  ? "Unsaved changes! Ctrl+S save & close · Esc discard · any key keep editing"
596
630
  : prompt
597
631
  ? prompt.kind === "find" ? `Find: ${prompt.text}▏ Enter/↓ next · Shift+Enter/↑ previous · Esc close` : `Go to line: ${prompt.text}▏ Enter go · Esc cancel`
598
632
  : flash ?? (cursorProblem
599
633
  ? `${SEVERITY_STYLE[cursorProblem.severity].icon} ${cursorProblem.message} (${cursorProblem.source})`
600
634
  : `Ln ${buf.row + 1}, Col ${buf.col + 1}${sel ? ` (${selectedCount} selected · Ctrl+C copy · Ctrl+X cut)` : ""}${dirty ? " ● modified" : ""} Ctrl+F find · Ctrl+/ comment · Esc done`);
601
- const statusColor = confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
635
+ const statusColor = guideStatus && !confirmExit && !prompt ? (progress < 0 ? C.red : C.accent2) : confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
602
636
  const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
603
637
  const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
604
638
  return (_jsxs(Pane, { title: _jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, dirty ? _jsx(Text, { color: C.yellow, children: " \u25CF" }) : ""] }), " ", _jsx(Text, { backgroundColor: C.green, color: "black", children: " \u270E EDITING " }), !cursorVisible ? _jsxs(Text, { color: C.dim, children: [" cursor on line ", buf.row + 1, " \u00B7 \u2191\u2193 to go back"] }) : null] }), width: width, height: height, focused: focused, accent: C.green, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: width - 3, flexShrink: 0, children: visible.map((line, i) => {
@@ -609,7 +643,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
609
643
  const num = (_jsxs(Text, { color: mark?.color ?? (isCursorLine ? C.green : C.dim), children: [String(n + 1).padStart(gutter - 2), mark ? ` ${mark.icon}` : isCursorLine ? " ▸" : " "] }));
610
644
  const inSelection = sel && n >= sel.start.row && n <= sel.end.row;
611
645
  if (!isCursorLine && !inSelection) {
612
- return _jsxs(Text, { wrap: "truncate-end", children: [num, colored[i] || " "] }, n);
646
+ return _jsx(PlainRow, { num: `${String(n + 1).padStart(gutter - 2)}${mark ? ` ${mark.icon}` : " "}`, numColor: mark?.color ?? C.dim, text: colored[i] ?? "" }, n);
613
647
  }
614
648
  // Lines with the cursor or a selection show plain text, so both can be drawn exactly.
615
649
  const shown = expand(line).slice(v.left, v.left + textWidth);
@@ -626,8 +660,11 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
626
660
  else
627
661
  cells.push(cell);
628
662
  }
663
+ // The code still to type, dim, from the cursor to the end of its line (only past the line's text).
664
+ const ghostLine = isCursorLine && ghost && buf.col >= line.length ? ghost.split("\n")[0] : "";
665
+ const ghostMore = isCursorLine && ghost && buf.col >= line.length ? ghost.split("\n").length - 1 : 0;
629
666
  return (_jsxs(Text, { wrap: "truncate-end", children: [num, cells.map((c, j) => c.cursor
630
- ? _jsx(Text, { inverse: true, color: C.green, children: c.text }, j)
631
- : _jsx(Text, { color: C.text, ...(c.selected ? { backgroundColor: "#364a82" } : {}), children: c.text }, j))] }, n));
667
+ ? _jsx(Text, { inverse: true, color: C.green, children: ghostLine ? ghostLine[0] : c.text }, j)
668
+ : _jsx(Text, { color: C.text, ...(c.selected ? { backgroundColor: "#364a82" } : {}), children: c.text }, j)), ghostLine.length > 1 ? _jsx(Text, { color: C.dim, italic: true, children: expand(ghostLine.slice(1)) }) : null, ghostMore ? _jsxs(Text, { color: C.accent2, children: [ghostLine ? " " : "", "\u23CE", ghostMore > 1 ? ` +${ghostMore - 1} line${ghostMore > 2 ? "s" : ""}` : ""] }) : null] }, n));
632
669
  }) }), _jsx(Scrollbar, { total: buf.lines.length, top: v.top, rows: rows, focused: focused })] }), 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}` })] }));
633
670
  });
@@ -4,7 +4,7 @@ import { readdirSync } from "fs";
4
4
  import path from "path";
5
5
  import { Box, Text, useAnimation, useInput } from "ink";
6
6
  import { IGNORED } from "../commands/index.js";
7
- import { C, GIT_COLORS, fileIcon, gradient } from "./theme.js";
7
+ import { C, GIT_COLORS, fileIcon, gradient, ANIMATION_MS } from "./theme.js";
8
8
  import { Pane } from "./Pane.js";
9
9
  import { Scrollbar, topFor } from "./scroll.js";
10
10
  function listDir(dir) {
@@ -167,7 +167,7 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
167
167
  else
168
168
  onOpen(node.path);
169
169
  }, [click?.nonce]);
170
- const { frame } = useAnimation({ interval: 120, isActive: !!working?.size });
170
+ const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!working?.size });
171
171
  const inner = width - 3; // minus the borders and the scrollbar
172
172
  const title = changesOnly
173
173
  ? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.yellow, children: "\u00B1" }), " Changes ", _jsxs(Text, { color: C.dim, children: ["(", nodes.length, ") c: all files"] })] })
@@ -81,7 +81,7 @@ export const GamePanel = memo(function GamePanel({ width, height, focused, movin
81
81
  }, { isActive: focused });
82
82
  const color = focused || moving ? C.accent2 : C.dim;
83
83
  const inner = width - 2;
84
- const title = moving ? " ✦ Drop it on a pane: files, code or chat" : playing ? ` ✦ ${playing.game.title} ⠿ drag to move` : " ✦ Games ⠿ drag to move";
84
+ const title = moving ? " ✦ Drop it on a pane: files, code or chat" : `${playing ? ` ✦ ${playing.game.title}` : " ✦ Games"} ⠿ drag to move · top edge resizes`;
85
85
  // The title row: the name and the drag handle, and × (close) at the right.
86
86
  const head = title.length > inner - 3 ? `${title.slice(0, inner - 4)}…` : title.padEnd(inner - 3);
87
87
  const hint = !focused ? "click to play" : playing ? (view?.over ? "Enter again · q games · Esc leave" : `${paused ? "PAUSED · " : ""}Space pause · q games · Esc leave`) : "↑↓ choose · Enter play · Esc leave";
@@ -2,11 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { memo } from "react";
3
3
  import path from "path";
4
4
  import { Box, Text, useAnimation } from "ink";
5
- import { C, SPINNER, gradient } from "./theme.js";
5
+ import { C, SPINNER, gradient, ANIMATION_MS } from "./theme.js";
6
6
  import { currentVersion } from "../update/index.js";
7
7
  const VERSION = `v${currentVersion()}`;
8
8
  export const Header = memo(function Header({ width, branch, changes, model, local, busy, update }) {
9
- const { frame } = useAnimation({ interval: 120, isActive: !!busy });
9
+ const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy });
10
10
  const project = path.basename(process.cwd());
11
11
  return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, flexGrow: 1, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsxs(Text, { color: C.dim, children: [" ", VERSION] }), _jsx(Text, { color: C.dim, children: " \u2502 " }), _jsx(Text, { color: C.text, children: project }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] })), busy && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " ", busy] })] }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate-end", children: [update && width >= 110 && _jsxs(Text, { color: C.green, bold: true, children: ["\u2B06 v", update, " available \u00B7 run `nou update` "] }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: "black", bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] }) })] }));
12
12
  });
@@ -1,7 +1,7 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx, 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 { C, SPINNER, fileIcon } from "./theme.js";
4
+ import { C, SPINNER, fileIcon, ANIMATION_MS } from "./theme.js";
5
5
  import { Pane } from "./Pane.js";
6
6
  export const SEVERITY_STYLE = {
7
7
  error: { icon: "✖", color: C.red },
@@ -11,7 +11,7 @@ export const SEVERITY_STYLE = {
11
11
  export function ProblemsView({ width, height, focused, problems, checking, wheel, onJump, onClose }) {
12
12
  const [cursor, setCursor] = useState(0);
13
13
  const [onlyErrors, setOnlyErrors] = useState(false);
14
- const { frame } = useAnimation({ interval: 80, isActive: checking.length > 0 });
14
+ const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: checking.length > 0 });
15
15
  const rows = useMemo(() => {
16
16
  const list = onlyErrors ? problems.filter((d) => d.severity === "error") : problems;
17
17
  const out = [];
@@ -11,7 +11,7 @@ import { PLUGIN_DIRS, isEnabled, trustState } from "../plugins/index.js";
11
11
  import { currentVersion } from "../update/index.js";
12
12
  import { describe, providers, rejectedKey } from "../providers/index.js";
13
13
  import { ENGINES, isInstalled } from "../engines/index.js";
14
- import { C, SPINNER } from "./theme.js";
14
+ import { C, SPINNER, ANIMATION_MS } from "./theme.js";
15
15
  /** A few lines of code to show what a code theme looks like. */
16
16
  const THEME_PREVIEW = `// Greets someone, politely
17
17
  export function greet(name: string, times = 2): string {
@@ -40,7 +40,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
40
40
  const [models, setModels] = useState({});
41
41
  const [connecting, setConnecting] = useState(connectTo?.connect ? { step: 0, values: {} } : null);
42
42
  const [field, setField] = useState(() => (connectTo?.connect ? initialValue(connectTo.connect[0]) : ""));
43
- const { frame } = useAnimation({ interval: 80 });
43
+ const { frame } = useAnimation({ interval: ANIMATION_MS });
44
44
  const provider = providers[row] ?? providers[0];
45
45
  const listed = models[provider.id];
46
46
  // Load the model list for the highlighted provider.
@@ -6,8 +6,9 @@ const COLORS = { out: C.text, command: C.accent, info: C.dim, error: C.red };
6
6
  /**
7
7
  * The terminal panel, under the code like VS Code's: type a command and Enter runs it. While one runs,
8
8
  * what you type goes to it; Ctrl+C stops it. ↑↓ go through what you ran, Ctrl+L clears, Esc leaves.
9
+ * Drag its top border (or Alt+↑/↓) to make it taller or shorter.
9
10
  */
10
- export const Terminal = memo(function Terminal({ shell, version, width, height, focused, wheel, onLeave }) {
11
+ export const Terminal = memo(function Terminal({ shell, version, width, height, focused, wheel, onLeave, onResize }) {
11
12
  const [input, setInput] = useState("");
12
13
  const [history, setHistory] = useState([]);
13
14
  const [back, setBack] = useState(-1);
@@ -57,6 +58,8 @@ export const Terminal = memo(function Terminal({ shell, version, width, height,
57
58
  setBack(-1);
58
59
  return;
59
60
  }
61
+ if (key.meta && (key.upArrow || key.downArrow))
62
+ return onResize(key.upArrow ? 2 : -2);
60
63
  if (key.upArrow || key.downArrow) {
61
64
  if (!history.length)
62
65
  return;
@@ -79,6 +82,6 @@ export const Terminal = memo(function Terminal({ shell, version, width, height,
79
82
  setInput((v) => v + typed.replace(/[\r\n]+/g, " "));
80
83
  }, { isActive: focused });
81
84
  const color = focused ? C.accent : C.dim;
82
- const title = ` ▸ Terminal${shell.running ? " ● running · Ctrl+C stops" : ""}${at ? ` ↑ ${at} lines` : ""}`;
85
+ const title = ` ▸ Terminal${shell.running ? " ● running · Ctrl+C stops" : ""}${at ? ` ↑ ${at} lines` : ""}${focused && !shell.running && !at ? " ⇕ drag this edge or Alt+↑↓ to resize" : ""}`;
83
86
  return (_jsxs(Box, { width: width, height: height, flexDirection: "column", borderStyle: focused ? "round" : "single", borderColor: color, overflow: "hidden", children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: color, bold: true, children: title.slice(0, inner - 3).padEnd(inner - 3) }), _jsx(Text, { color: C.text, bold: true, children: " \u00D7 " })] }), _jsx(Box, { flexDirection: "column", height: outRows, flexShrink: 0, children: shown.map((l, i) => _jsx(Text, { color: l.color, wrap: "truncate-end", children: l.text }, i)) }), _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: shell.running ? C.yellow : C.green, children: promptText }), _jsx(Text, { color: C.text, children: input }), focused ? _jsx(Text, { inverse: true, children: " " }) : null] })] }));
84
87
  });
@@ -59,3 +59,22 @@ export async function readClipboard() {
59
59
  }
60
60
  return null;
61
61
  }
62
+ /** Opens a web address in the default browser (the terminal's own link handling can't: Nou has the mouse). */
63
+ export function openInBrowser(url) {
64
+ if (!/^https?:\/\//.test(url))
65
+ return false;
66
+ if (process.env.NOU_TEST_OPEN)
67
+ return (globalThis.__opened = url), true; // tests: note it, open nothing
68
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]]
69
+ : process.platform === "win32" ? ["cmd", ["/c", "start", "", url.replace(/&/g, "^&")]]
70
+ : ["xdg-open", [url]];
71
+ try {
72
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
73
+ child.on("error", () => { });
74
+ child.unref();
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
@@ -1,3 +1,15 @@
1
+ /**
2
+ * Commands that only change what's on screen (or handle a busy Nou themselves): they run right away
3
+ * while Nou works instead of waiting in the queue behind it.
4
+ */
5
+ const INSTANT = new Set(["new", "tab", "close", "terminal", "games", "open", "settings", "auto", "diff", "sessions", "history", "problems", "copy", "help", "version", "cancel"]);
6
+ /** Whether a typed command runs right away while Nou works (see INSTANT); `/showme` counts only to look or step. */
7
+ export function runsWhileBusy(text) {
8
+ const [name, ...rest] = text.slice(1).trim().split(/\s+/);
9
+ if (name === "showme" || name === "teach")
10
+ return !rest.length || /^(next|skip|stop|end)$/.test(rest.join(" "));
11
+ return text.startsWith("/") && INSTANT.has(name ?? "");
12
+ }
1
13
  export const SLASH_COMMANDS = [
2
14
  { name: "model", args: "[provider:model]", description: "show or switch the model" },
3
15
  { name: "models", args: "[provider]", description: "list the models a provider offers" },
@@ -16,6 +28,11 @@ export const SLASH_COMMANDS = [
16
28
  { name: "copy", args: "[code|file]", description: "copy the last reply, its last code block, or the open file" },
17
29
  { name: "compact", description: "summarize the conversation to free up context" },
18
30
  { name: "clear", description: "start a fresh conversation" },
31
+ { name: "showme", args: "<what you want>", description: "Nou shows you how, step by step, and you type it (with the code shown ahead of your cursor)" },
32
+ { name: "teach", args: "<what you want>", description: "same as /showme: Nou teaches you to make the change yourself, step by step" },
33
+ { name: "new", description: "a new conversation tab: another agent, working alongside this one" },
34
+ { name: "tab", args: "[n]", description: "list the conversation tabs, or switch to tab n" },
35
+ { name: "close", description: "close this conversation tab (it stays saved for /resume)" },
19
36
  { name: "terminal", description: "a terminal under the code, like VS Code's (or press `)" },
20
37
  { name: "resume", args: "[number|last]", description: "pick up an earlier conversation in this folder (any engine)" },
21
38
  { name: "pull", args: "<model>", description: "download an Ollama model" },
@@ -140,9 +140,11 @@ export function inlineSpans(text, base = { text: "" }) {
140
140
  else if (m[5] !== undefined || m[6] !== undefined)
141
141
  out.push(...inlineSpans(m[5] ?? m[6], { ...base, italic: true }));
142
142
  else {
143
- out.push(...inlineSpans(m[7], base));
143
+ // [text](https://...) links to the page; [text](file) is left for the file links.
144
+ const web = /^https?:\/\//.test(m[8]) ? { ...base, link: { path: "", url: m[8] } } : base;
145
+ out.push(...inlineSpans(m[7], web));
144
146
  if (m[8] !== m[7])
145
- out.push({ ...base, text: ` (${m[8]})`, dim: true });
147
+ out.push({ ...web, text: ` (${m[8]})`, dim: true });
146
148
  }
147
149
  pos = m.index + m[0].length;
148
150
  }
@@ -203,7 +205,8 @@ const cells = (row) => row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|
203
205
  /** Renders markdown for the terminal: headings, lists, quotes, code blocks, tables and inline styles, wrapped to `width`. */
204
206
  export function renderMarkdown(text, width, isFile) {
205
207
  // File references become links before wrapping, so a long path that wraps is still one link.
206
- const spansOf = (line, base) => (isFile ? linkFileRefs(inlineSpans(line, base), isFile) : inlineSpans(line, base));
208
+ // Web addresses first, so a file name inside a URL stays part of the URL.
209
+ const spansOf = (line, base) => (isFile ? linkFileRefs(linkUrls(inlineSpans(line, base)), isFile) : linkUrls(inlineSpans(line, base)));
207
210
  const out = [];
208
211
  const src = splitLines(text);
209
212
  let fence = null;
@@ -291,6 +294,10 @@ const FILE_REF = /(?<![\w/\\.@~-])((?:[A-Za-z]:[\\/]|\.{0,2}[\\/])?(?:[\w@.~+-]+
291
294
  export function linkFileRefs(spans, isFile) {
292
295
  const out = [];
293
296
  for (const span of spans) {
297
+ if (span.link) {
298
+ out.push(span);
299
+ continue;
300
+ }
294
301
  let pos = 0;
295
302
  for (const m of span.text.matchAll(FILE_REF)) {
296
303
  const file = m[1].replace(/^\.[\\/]/, "");
@@ -311,6 +318,27 @@ export function linkFileRefs(spans, isFile) {
311
318
  }
312
319
  return out;
313
320
  }
321
+ const URL_RE = /\bhttps?:\/\/[^\s<>()"'`]+[^\s<>()"'`.,;:!?]/g;
322
+ /** Splits out web addresses (http:// and https://) as link spans, keeping each span's style. */
323
+ export function linkUrls(spans) {
324
+ const out = [];
325
+ for (const span of spans) {
326
+ if (span.link || span.code || !span.text.includes("http")) {
327
+ out.push(span);
328
+ continue;
329
+ }
330
+ let pos = 0;
331
+ for (const m of span.text.matchAll(URL_RE)) {
332
+ if (m.index > pos)
333
+ out.push({ ...span, text: span.text.slice(pos, m.index) });
334
+ out.push({ ...span, text: m[0], link: { path: "", url: m[0] } });
335
+ pos = m.index + m[0].length;
336
+ }
337
+ if (pos < span.text.length)
338
+ out.push(pos ? { ...span, text: span.text.slice(pos) } : span);
339
+ }
340
+ return out;
341
+ }
314
342
  /** The link at column `col` of a line of spans, if any. */
315
343
  export function linkAt(spans, col) {
316
344
  let x = 0;
@@ -12,6 +12,8 @@ export const C = {
12
12
  dim: "#565f89",
13
13
  panel: "#1f2335",
14
14
  };
15
+ /** Every spinner and shimmer ticks on this one interval, so they redraw together in a single frame. */
16
+ export const ANIMATION_MS = 100;
15
17
  export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
16
18
  const GRADIENT = [C.accent, "#8c9cf7", C.accent2, "#c39af2", "#e08ad6", C.red, C.orange, C.yellow, C.green, C.cyan];
17
19
  /** Colors for each character of a label, shifted by `frame` so it shimmers when animated. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nou-cli",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
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": {