nou-cli 0.5.11 → 0.5.13

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.
@@ -9,8 +9,8 @@ import { copyToClipboard } from "./clipboard.js";
9
9
  import { C, SPINNER, ANIMATION_MS } from "./theme.js";
10
10
  import { useTerminalFocused } from "./focus.js";
11
11
  import { Pane } from "./Pane.js";
12
- import { matchCommands } from "./commands.js";
13
- import { Scrollbar, topFor } from "./scroll.js";
12
+ import { SLASH_COMMANDS, matchCommands } from "./commands.js";
13
+ import { Scrollbar, topFor, usePaneWheel } from "./scroll.js";
14
14
  /**
15
15
  * What the agent did between replies (files read, commands run...) folds into one line, like Claude's
16
16
  * "▸ 5 steps": click it (or Ctrl+O for all of them) to see each step. `open` holds the runs shown open.
@@ -18,8 +18,8 @@ import { Scrollbar, topFor } from "./scroll.js";
18
18
  function stepLines(steps, open, live, inner) {
19
19
  const last = steps[steps.length - 1].text.replace(/\s+/g, " ");
20
20
  const summary = `${open ? "▾" : "▸"} ${steps.length} steps${open ? "" : ` · ${live ? "now: " : "last: "}${last}`}`;
21
- 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] };
22
- return open ? [head, ...steps.flatMap((e) => cachedLines(e, inner).map((l) => (l.lead === " ⚙" ? { ...l, lead: " " } : l)))] : [head];
21
+ 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] };
22
+ return open ? [head, ...steps.flatMap((e) => cachedLines(e, inner).map((l) => (l.lead === " ◇" ? { ...l, lead: " " } : l)))] : [head];
23
23
  }
24
24
  const BAR = "▌ ";
25
25
  // Whether a path in a message is a file in the project, remembered so each one is checked once.
@@ -62,8 +62,8 @@ function entryLines(entry, inner, links = true) {
62
62
  }
63
63
  else {
64
64
  const style = entry.kind === "tool"
65
- ? { icon: "⚙", color: C.dim }
66
- : entry.kind === "error" ? { icon: "✗", color: C.red } : { icon: "ℹ", color: C.accent };
65
+ ? { icon: "◇", color: C.dim }
66
+ : entry.kind === "error" ? { icon: "✗", color: C.red } : { icon: "ⓘ", color: C.accent };
67
67
  wrap(entry.text, inner - 4).forEach((line, i) => {
68
68
  const spans = links && entry.kind !== "user" ? linkFileRefs(linkUrls([{ text: ` ${line}` }]), isProjectFile) : [];
69
69
  out.push({ text: ` ${line}`, color: style.color, lead: i === 0 ? ` ${style.icon}` : " ", leadColor: style.color, ...(spans.some((sp) => sp.link) ? { spans } : {}) });
@@ -91,19 +91,22 @@ export function chatTabZones(tabs) {
91
91
  if (tabs.length === 1)
92
92
  return { zones: [{ id: tabs[0].id, from: 3, to: 7 }], plus: 8, width: 9 };
93
93
  let x = 3; // " ◆ "
94
- const zones = tabs.map((t) => {
94
+ // Each tab, then its × (which closes it).
95
+ const zones = tabs.flatMap((t) => {
95
96
  const w = tabText(t).length;
96
- const zone = { id: t.id, from: x, to: x + w };
97
- x += w + 1;
98
- return zone;
97
+ const both = [{ id: t.id, from: x, to: x + w }, { id: t.id, from: x + w, to: x + w + TAB_CLOSE.length, close: true }];
98
+ x += w + TAB_CLOSE.length + 1;
99
+ return both;
99
100
  });
100
101
  return { zones, plus: x + 1, width: x + 3 };
101
102
  }
103
+ const TAB_CLOSE = "× ";
102
104
  const tabText = (t) => ` ${t.state === "busy" ? "● " : t.state === "waiting" ? "! " : ""}${t.label} `;
103
105
  /** Characters of a word for a double-click: paths and web addresses come whole (src/a.ts, https://x.io/y). */
104
106
  const WORD_CHAR = /[\p{L}\p{N}_.\-/@:~#%?=&+]/u;
105
107
  const lineText = (line) => (line.spans ? line.spans.map((s) => s.text).join("") : line.text ?? "");
106
- 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 = [], sendNowFirst = false, onSendNow }) {
108
+ export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheelPane, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [], sendNowFirst = false, onSendNow }) {
109
+ const wheel = usePaneWheel(wheelPane);
107
110
  const [input, setInputText] = useState("");
108
111
  // Whether the chat has the keyboard right now: a key handler from a render before focus moved away can
109
112
  // still be attached for a moment (on a busy machine), and its keys belong to the pane that has focus.
@@ -171,21 +174,21 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
171
174
  }, [scrollBack]);
172
175
  const footer = [];
173
176
  if (approval?.kind === "command") {
174
- footer.push({ text: "⚡ Run this command?", color: C.yellow, bold: true });
177
+ footer.push({ text: "› Run this command?", color: C.yellow, bold: true });
175
178
  for (const line of wrap(`$ ${approval.command}`, inner - 2))
176
179
  footer.push({ text: ` ${line}`, color: C.text });
177
180
  footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
178
181
  }
179
182
  else if (approval?.kind === "tool") {
180
- footer.push({ text: `🔌 ${approval.name.startsWith("mcp__") ? `MCP tool ${approval.name.split("__")[1]} → ${approval.name.split("__").slice(2).join("__")}` : `Plugin tool: ${approval.name}`}`, color: C.yellow, bold: true });
183
+ footer.push({ text: `◇ ${approval.name.startsWith("mcp__") ? `MCP tool ${approval.name.split("__")[1]} → ${approval.name.split("__").slice(2).join("__")}` : `Plugin tool: ${approval.name}`}`, color: C.yellow, bold: true });
181
184
  for (const line of wrap(JSON.stringify(approval.args), inner - 2).slice(0, 4))
182
185
  footer.push({ text: ` ${line}`, color: C.text });
183
186
  footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
184
187
  }
185
188
  else if (approval?.kind === "change") {
186
- footer.push({ text: `✎ Nou wants to ${approval.change.isNew ? "create" : "edit"} ${approval.change.path}`, color: C.yellow, bold: true });
189
+ footer.push({ text: `± Nou wants to ${approval.change.isNew ? "create" : "edit"} ${approval.change.path}`, color: C.yellow, bold: true });
187
190
  if (approvalNote) {
188
- footer.push({ text: "⚠ This edit adds problems:", color: C.red, bold: true });
191
+ footer.push({ text: "▲ This edit adds problems:", color: C.red, bold: true });
189
192
  for (const line of wrap(approvalNote, inner - 2).slice(0, 6))
190
193
  footer.push({ text: ` ${line}`, color: C.red });
191
194
  }
@@ -196,13 +199,21 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
196
199
  }
197
200
  // Click what's queued (or /now) to stop Nou and send it right away.
198
201
  for (const text of queued)
199
- footer.push({ text: `⏳ next: ${text.replace(/\s+/g, " ")}`, color: C.dim, sendNow: true });
202
+ footer.push({ text: `⧗ next: ${text.replace(/\s+/g, " ")}`, color: C.dim, sendNow: true });
200
203
  if (queued.length && busy)
201
204
  footer.push({ text: " ↳ click to send now, or /now", color: C.accent2, sendNow: true });
202
205
  // Typing "/" opens a menu of commands with what each does; ↑↓ pick one, Tab completes, Enter runs it.
203
206
  const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
204
207
  const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
205
208
  const showMenu = matches.length > 0;
209
+ // A known command typed at the start of the input ("/zoom in") is drawn in the command color.
210
+ const commandLength = useMemo(() => {
211
+ const name = /^\/(\S+)/.exec(input)?.[1];
212
+ if (!name)
213
+ return 0;
214
+ const known = SLASH_COMMANDS.some((c) => c.name === name) || !!extraCommands?.some((c) => c.name === name);
215
+ return known ? name.length + 1 : 0;
216
+ }, [input, extraCommands]);
206
217
  useEffect(() => onMenu?.(showMenu), [showMenu]);
207
218
  const menuRows = Math.min(matches.length, Math.max(1, Math.min(7, height - 12)));
208
219
  const menuStart = Math.max(0, Math.min(menuRow - menuRows + 1, matches.length - menuRows));
@@ -392,7 +403,7 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
392
403
  ? 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)))
393
404
  : _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
394
405
  const empty = entries.length === 0 && !streaming;
395
- 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
406
+ 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) }), _jsx(Text, { color: C.dim, ...(t.id === tabs.active ? { backgroundColor: C.panel } : {}), children: TAB_CLOSE }), " "] }, 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
396
407
  ? unread
397
408
  ? _jsxs(Text, { color: C.green, bold: true, children: [" \u2193 ", unread, " new lines below", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
398
409
  : _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
@@ -408,6 +419,11 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
408
419
  }) })] })) })), [lines, firstShown, visible.length, selection, inner, empty]);
409
420
  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) => {
410
421
  const selected = menuStart + i === menuRow;
411
- 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));
412
- }) })) : (_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 ? (sendNowFirst ? "Enter sends now · Alt+Enter queues · Esc stops" : "Enter queues · Alt+Enter sends now · Esc stops") : sendNowFirst ? "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" })] }));
422
+ // What's typed so far shows in the command color; the rest of the name follows. A plugin's
423
+ // command says so, so Nou's own stand apart.
424
+ const typed = input.slice(1).toLowerCase();
425
+ const typedLen = typed && c.name.startsWith(typed) ? typed.length : 0;
426
+ const fromPlugin = !!extraCommands?.some((e) => e.name === c.name);
427
+ return (_jsxs(Text, { wrap: "truncate-end", ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸" : " " }), _jsx(Text, { color: C.accent2, bold: true, children: "/" }), _jsx(Text, { color: C.yellow, bold: true, children: c.name.slice(0, typedLen) }), _jsx(Text, { color: C.accent2, bold: selected, children: c.name.slice(typedLen) }), fromPlugin && _jsx(Text, { color: C.cyan, children: " plugin" }), 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));
428
+ }) })) : (_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, command: commandLength, placeholder: busy ? (inner >= 44 ? (sendNowFirst ? "Enter sends now · Alt+Enter queues · Esc stops" : "Enter queues · Alt+Enter sends now · Esc stops") : sendNowFirst ? "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" })] }));
413
429
  });
@@ -1,6 +1,6 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from "react";
3
- import { Box, Text, useInput, usePaste } from "ink";
3
+ import { Box, Text, useInput, usePaste, useStdin } from "ink";
4
4
  import { C } from "./theme.js";
5
5
  /** Word-wraps `text` into segments of at most `width` characters, honoring newlines. */
6
6
  export function layout(text, width) {
@@ -35,7 +35,7 @@ function cursorLine(segments, cursor) {
35
35
  }
36
36
  return segments.length - 1;
37
37
  }
38
- export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder, history, maxLines = 6, arrowsTaken = false }) {
38
+ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder, history, maxLines = 6, arrowsTaken = false, command = 0 }) {
39
39
  const [cursor, setCursor] = useState(value.length);
40
40
  const [recall, setRecall] = useState(null);
41
41
  const stash = useRef("");
@@ -57,6 +57,24 @@ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder
57
57
  setCursor(Math.max(0, Math.min(next.length, nextCursor)));
58
58
  };
59
59
  const insert = (text) => set(value.slice(0, pos) + text + value.slice(pos), pos + text.length);
60
+ // Shift/Ctrl/Cmd+Enter as xterm's modifyOtherKeys sends them (ESC [27;<mods>;13~, from terminals set up that
61
+ // way): Ink drops these, so they're caught here and add a line, like the kitty protocol's form does.
62
+ const { stdin } = useStdin();
63
+ const insertLine = useRef(() => { });
64
+ insertLine.current = () => insert("\n");
65
+ useEffect(() => {
66
+ if (!focus || !stdin)
67
+ return;
68
+ const onData = (data) => {
69
+ // eslint-disable-next-line no-control-regex -- the sequence starts with ESC
70
+ if (/\x1b\[27;([2-9]|1[0-6]);13~/.test(String(data)))
71
+ insertLine.current();
72
+ };
73
+ stdin.on("data", onData);
74
+ return () => {
75
+ stdin.off("data", onData);
76
+ };
77
+ }, [focus, stdin]);
60
78
  const recallHistory = (step) => {
61
79
  if (!history.length)
62
80
  return false;
@@ -131,11 +149,16 @@ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder
131
149
  return (_jsx(Box, { flexDirection: "column", children: shown.map((s, i) => {
132
150
  const text = value.slice(s.start, s.end).replace(/\n/g, "");
133
151
  const isCursor = focus && first + i === line;
152
+ // A known command at the start ("/zoom in") in the command color, the rest as typed.
153
+ const paint = (from, to) => {
154
+ const cmdEnd = Math.max(from, Math.min(to, command - s.start));
155
+ return (_jsxs(_Fragment, { children: [cmdEnd > from && _jsx(Text, { color: C.accent2, bold: true, children: text.slice(from, cmdEnd) }), _jsx(Text, { color: C.text, children: text.slice(cmdEnd, to) })] }));
156
+ };
134
157
  if (!isCursor) {
135
- return _jsxs(Text, { children: [prompt(i), _jsx(Text, { color: C.text, children: text || " " })] }, first + i);
158
+ return _jsxs(Text, { children: [prompt(i), text ? paint(0, text.length) : " "] }, first + i);
136
159
  }
137
160
  const at = pos - s.start;
138
- return (_jsxs(Text, { children: [prompt(i), _jsx(Text, { color: C.text, children: text.slice(0, at) }), _jsx(Text, { inverse: true, children: text[at] ?? " " }), _jsx(Text, { color: C.text, children: text.slice(at + 1) })] }, first + i));
161
+ return (_jsxs(Text, { children: [prompt(i), paint(0, at), _jsx(Text, { inverse: true, children: text[at] ?? " " }), paint(at + 1, text.length)] }, first + i));
139
162
  }) }));
140
163
  }
141
164
  /** How many rows the input will take, so the chat can size its history above it. */
@@ -7,13 +7,14 @@ import { foldRanges, highlightLines, markAnsi, sideBySide, sliceAnsi, splitLines
7
7
  import { C, fileIcon, gradient } from "./theme.js";
8
8
  import { isFind } from "./keys.js";
9
9
  import { Pane } from "./Pane.js";
10
- import { Scrollbar, topFor } from "./scroll.js";
10
+ import { Scrollbar, topFor, usePaneWheel } from "./scroll.js";
11
11
  /** The current match: a yellow background; the others are shown inverted. */
12
12
  const CURRENT_MATCH = ["\x1b[43m\x1b[30m", "\x1b[49m\x1b[39m"];
13
13
  /** Columns ←/→ and Shift+wheel move sideways. */
14
14
  const SIDE_STEP = 8;
15
15
  const EDIT_HINT = " double-click or e to edit · v diff";
16
- export const CodeView = memo(function CodeView({ showing, width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheel, scrollTo, onViewport, defineAt, onDefine, tabs, onFinding, findRequest, foldClick, foldAll }) {
16
+ export const CodeView = memo(function CodeView({ showing, width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheelPane, scrollTo, onViewport, defineAt, onDefine, tabs, onFinding, findRequest, foldClick, foldAll }) {
17
+ const wheel = usePaneWheel(wheelPane);
17
18
  const [top, setTop] = useState(0);
18
19
  // Columns scrolled sideways (colors are kept: see sliceAnsi).
19
20
  const [left, setLeft] = useState(0);
@@ -231,7 +232,7 @@ export const CodeView = memo(function CodeView({ showing, width, height, focused
231
232
  ["drag", "a pane's border to resize it"],
232
233
  ["s", "settings: models, keys, plugins"],
233
234
  ];
234
- return (_jsx(Pane, { title: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u2726" }), " Welcome"] }), width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, flexShrink: 0, children: [inner > 62 && artWork.split("\n").filter((l) => l.trim()).map((line, i) => (_jsx(Text, { children: gradient(line, i).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) }, i))), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: C.text, children: "Hello, I am Nou! \uD83C\uDF1E Your terminal coding companion." }), _jsx(Text, { color: C.dim, children: "Ask in the chat on the right. Files I read or edit open here as I work." }), _jsx(Text, { children: " " }), tips.map(([k, d]) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: C.accent, bold: true, children: k.padEnd(10) }), _jsx(Text, { color: C.dim, children: d })] }, k)))] }) }));
235
+ return (_jsx(Pane, { title: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u2726" }), " Welcome"] }), width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, flexShrink: 0, children: [inner > 62 && artWork.split("\n").filter((l) => l.trim()).map((line, i) => (_jsx(Text, { children: gradient(line, i).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) }, i))), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: C.text, children: "Hello, I am Nou! Your terminal coding companion." }), _jsx(Text, { color: C.dim, children: "Ask in the chat on the right. Files I read or edit open here as I work." }), _jsx(Text, { children: " " }), tips.map(([k, d]) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: C.accent, bold: true, children: k.padEnd(10) }), _jsx(Text, { color: C.dim, children: d })] }, k)))] }) }));
235
236
  }
236
237
  const gutter = String(lines.length).length;
237
238
  const { icon, color } = fileIcon(filePath);
@@ -7,7 +7,7 @@ import { C, fileIcon } from "./theme.js";
7
7
  import { FIND_KEY, isFind } from "./keys.js";
8
8
  import { SEVERITY_STYLE } from "./ProblemsView.js";
9
9
  import { Pane } from "./Pane.js";
10
- import { Scrollbar, topFor } from "./scroll.js";
10
+ import { Scrollbar, topFor, usePaneWheel } from "./scroll.js";
11
11
  import { copyToClipboard, readClipboard } from "./clipboard.js";
12
12
  import { guideProgress } from "../guide/index.js";
13
13
  const TAB = " ";
@@ -94,7 +94,8 @@ const PlainRow = memo(function PlainRow({ num, numColor, text }) {
94
94
  return _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: numColor, children: num }), text || " "] });
95
95
  });
96
96
  /** 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. */
97
- 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 }) {
97
+ export const Editor = memo(function Editor({ width, height, focused, filePath, initial, baseline, startLine, startCol, problems, autoSave, syntaxVersion, wheelPane, scrollTo, mouse, onSave, onExit, onState, onHover, onDefinition, tabs, guide, onGuideDone }) {
98
+ const wheel = usePaneWheel(wheelPane);
98
99
  const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
99
100
  const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
100
101
  const comment = useMemo(() => commentFor(filePath), [filePath]);
@@ -636,7 +637,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
636
637
  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;
637
638
  const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
638
639
  const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
639
- 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: C.onBadge, 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) => {
640
+ 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: C.onBadge, children: " \u00B1 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) => {
640
641
  const n = v.top + i;
641
642
  const isCursorLine = n === buf.row;
642
643
  const problem = byLine.get(n + 1)?.[0];
@@ -7,7 +7,7 @@ import { IGNORED } from "../commands/index.js";
7
7
  import { C, GIT_COLORS, fileIcon, gradient, ANIMATION_MS } from "./theme.js";
8
8
  import { useTerminalFocused } from "./focus.js";
9
9
  import { Pane } from "./Pane.js";
10
- import { Scrollbar, topFor } from "./scroll.js";
10
+ import { Scrollbar, topFor, usePaneWheel } from "./scroll.js";
11
11
  function listDir(dir) {
12
12
  try {
13
13
  return readdirSync(path.resolve(process.cwd(), dir), { withFileTypes: true })
@@ -32,7 +32,8 @@ function flatten(expanded) {
32
32
  walk(".", 0);
33
33
  return nodes;
34
34
  }
35
- export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, working, showing, git, problemCounts, wheel, scrollTo, click, onOpen, onHover }) {
35
+ export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, working, showing, git, problemCounts, wheelPane, scrollTo, click, onOpen, onHover }) {
36
+ const wheel = usePaneWheel(wheelPane);
36
37
  const [expanded, setExpanded] = useState(() => new Set(["src"]));
37
38
  const [cursor, setCursor] = useState(0);
38
39
  // The first row shown. The wheel moves it on its own; the keyboard moves the cursor and the view follows.
@@ -9,6 +9,8 @@ const VERSION = `v${currentVersion()}`;
9
9
  /** What comes before the project (or the workspace tabs): " ◆ NOU v1.2.3 │ ". */
10
10
  const LEAD = ` ◆ NOU ${VERSION} │ `;
11
11
  const tabLabel = (t, i) => ` ${i + 1} ${t.name}${t.busy ? " ●" : ""} `;
12
+ /** Each workspace tab ends with a × that closes it. */
13
+ const CLOSE = "× ";
12
14
  /**
13
15
  * Where each workspace tab, and the + that opens one, sits in the header row: for clicks. With one workspace
14
16
  * (not hosted) there's just the project's name and the +.
@@ -23,7 +25,8 @@ export function headerZones(tabs) {
23
25
  tabs.forEach((t, i) => {
24
26
  const w = tabLabel(t, i).length;
25
27
  zones.push({ from: x, to: x + w, tab: i });
26
- x += w + 1;
28
+ zones.push({ from: x + w, to: x + w + CLOSE.length, tab: i, close: true });
29
+ x += w + CLOSE.length + 1;
27
30
  });
28
31
  }
29
32
  zones.push({ from: x, to: x + 3, tab: "new" });
@@ -46,6 +49,6 @@ export const Header = memo(function Header({ width, branch, changes, model, loca
46
49
  const right = rightSide(width, model, local, update);
47
50
  const rightWidth = right.notice.length + right.badge.length + right.model.length + 1;
48
51
  return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { width: Math.max(0, width - rightWidth - 4), flexShrink: 0, 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 " }), tabs.length
49
- ? tabs.map((t, i) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: t.active ? C.accent : C.text, bold: t.active, children: tabLabel(t, i) }), " "] }, t.id)))
52
+ ? tabs.map((t, i) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: t.active ? C.accent : C.text, bold: t.active, children: tabLabel(t, i) }), _jsx(Text, { ...(t.active ? { backgroundColor: C.panel } : {}), color: C.dim, children: CLOSE }), " "] }, t.id)))
50
53
  : _jsxs(Text, { color: C.text, children: [project, " "] }), _jsx(Text, { color: C.accent2, children: " + " }), 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] })] }))] }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate-end", children: [right.notice && _jsx(Text, { color: C.green, bold: true, children: right.notice }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: C.onBadge, bold: true, children: right.badge }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: right.model }), " "] }) })] }));
51
54
  });
@@ -4,12 +4,14 @@ import { Box, Text, useAnimation, useInput } from "ink";
4
4
  import { C, SPINNER, fileIcon, ANIMATION_MS } from "./theme.js";
5
5
  import { useTerminalFocused } from "./focus.js";
6
6
  import { Pane } from "./Pane.js";
7
+ import { usePaneWheel } from "./scroll.js";
7
8
  export const SEVERITY_STYLE = {
8
9
  error: { icon: "✗", color: C.red },
9
10
  warning: { icon: "▲", color: C.yellow },
10
- info: { icon: "ℹ", color: C.accent },
11
+ info: { icon: "ⓘ", color: C.accent },
11
12
  };
12
- export function ProblemsView({ width, height, focused, problems, checking, wheel, onJump, onClose }) {
13
+ export function ProblemsView({ width, height, focused, problems, checking, wheelPane, onJump, onClose }) {
14
+ const wheel = usePaneWheel(wheelPane);
13
15
  const [cursor, setCursor] = useState(0);
14
16
  const [onlyErrors, setOnlyErrors] = useState(false);
15
17
  const windowFocused = useTerminalFocused();
@@ -58,7 +58,7 @@ export function score(file, query) {
58
58
  return { score: total - file.length * 0.05, hits };
59
59
  }
60
60
  /** Ctrl+P: find a file. Start with ">" (or press ?) for every action Nou has, with its shortcut. */
61
- export function QuickOpen({ width, top, files, recent = [], actions, initialQuery = "", placeholder = "Go to file… (> for commands)", onPick, onRun, onClose }) {
61
+ export function QuickOpen({ width, top, files, recent = [], actions, initialQuery = "", placeholder = "Go to file… (> for commands)", title, onPick, onRun, onClose }) {
62
62
  const [query, setQuery] = useState(initialQuery);
63
63
  const [cursor, setCursor] = useState(0);
64
64
  const boxWidth = Math.min(80, Math.max(40, width - 10));
@@ -101,8 +101,10 @@ export function QuickOpen({ width, top, files, recent = [], actions, initialQuer
101
101
  setCursor((c) => Math.min(count - 1, c + rowsShown));
102
102
  });
103
103
  const recentSet = useMemo(() => new Set(recent), [recent]);
104
+ /** Cut at the end (a title reads from the start). */
105
+ const fit = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s.padEnd(n));
104
106
  const pad = (s) => (s.length > inner ? `…${s.slice(-(inner - 1))}` : s.padEnd(inner));
105
- return (_jsxs(Box, { position: "absolute", top: top, left: Math.floor((width - boxWidth) / 2), width: boxWidth, flexDirection: "column", borderStyle: "round", borderColor: C.accent2, children: [_jsxs(Text, { backgroundColor: C.panel, children: [_jsx(Text, { color: C.accent2, bold: true, children: " \u2315 " }), _jsx(TextInput, { value: query, onChange: (v) => {
107
+ return (_jsxs(Box, { position: "absolute", top: top, left: Math.floor((width - boxWidth) / 2), width: boxWidth, flexDirection: "column", borderStyle: "round", borderColor: C.accent2, children: [_jsxs(Text, { backgroundColor: C.panel, children: [_jsx(Text, { color: C.accent2, bold: true, children: fit(` ${title ?? (commandMode ? "Every action · type to filter (? or /actions)" : "Find a file · type part of its name (Ctrl+P or /files)")}`, inner - 6) }), _jsx(Text, { color: C.dim, children: "Esc ×".padStart(6) })] }), _jsxs(Text, { backgroundColor: C.panel, children: [_jsx(Text, { color: C.accent2, bold: true, children: " \u2315 " }), _jsx(TextInput, { value: query, onChange: (v) => {
106
108
  setQuery(v);
107
109
  setCursor(0);
108
110
  }, onSubmit: () => {
@@ -212,7 +212,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
212
212
  const problem = p.unavailable();
213
213
  const selected = i === row;
214
214
  const current = p.id === choice.provider.id;
215
- 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));
215
+ 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: " \u25A3" }) : null] }, p.id));
216
216
  })] }), _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] }), reason ? _jsxs(Text, { color: C.red, wrap: "wrap", children: ["\u2717 ", reason] }) : null, connecting && provider.connect ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.accent2, bold: true, children: ["Connect ", provider.label] }), provider.connect.map((f, i) => {
217
217
  const entered = connecting.values[f.env];
218
218
  const shown = (v) => (f.secret ? "•".repeat(Math.min(v.length, 12)) : v);
@@ -221,14 +221,14 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
221
221
  }
222
222
  if (i > connecting.step)
223
223
  return _jsxs(Text, { color: C.dim, children: [" ", f.label, f.optional ? " (optional)" : ""] }, f.env);
224
- 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));
224
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsxs(Text, { color: C.accent2, children: ["\u00B1 ", 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));
225
225
  }), _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
226
226
  ? _jsx(Text, { color: C.accent2, children: "Press Enter to connect it: paste your key here, no restart needed." })
227
- : 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) => {
227
+ : 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: "\u00B1 " }), _jsx(TextInput, { value: custom, onChange: setCustom, onSubmit: (v) => {
228
228
  setTyping(false);
229
229
  if (v.trim())
230
230
  onModel({ provider, model: v.trim() });
231
- }, 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) && (() => {
231
+ }, placeholder: `e.g. ${provider.defaultModel}` })] })) : (_jsx(Text, { ...(column === "models" && modelRow === 0 ? { backgroundColor: C.panel } : {}), color: C.accent2, children: "\u00B1 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) && (() => {
232
232
  const key = provider.connect.find((f) => f.secret).env;
233
233
  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."] });
234
234
  })()] })), (() => {
@@ -269,7 +269,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
269
269
  return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [_jsx(Text, { color: C.dim, wrap: "truncate-end", children: "Little games for a break while Nou works. Added ones show in the Games panel: /games." }), _jsx(Text, { children: " " }), GAMES.map((g, i) => ({ g, i })).slice(gameWindow.first, gameWindow.last).map(({ g, i }) => {
270
270
  const selected = i === row;
271
271
  const added = settings.games.includes(g.id);
272
- 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 ? "▸ " : " " }), added ? _jsx(Text, { color: C.green, bold: true, children: "\u2713 ADDED" }) : _jsx(Text, { color: C.accent2, bold: true, children: "\u2B07 GET " }), _jsxs(Text, { color: C.text, bold: true, children: [" ", g.title] }), _jsxs(Text, { color: C.dim, children: [" ", g.controls] })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", g.blurb] })] }, g.id));
272
+ 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 ? "▸ " : " " }), added ? _jsx(Text, { color: C.green, bold: true, children: "\u2713 ADDED" }) : _jsx(Text, { color: C.accent2, bold: true, children: "\u2193 GET " }), _jsxs(Text, { color: C.text, bold: true, children: [" ", g.title] }), _jsxs(Text, { color: C.dim, children: [" ", g.controls] })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", g.blurb] })] }, g.id));
273
273
  })] }));
274
274
  }
275
275
  return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [plugins.map((p, i) => {
@@ -284,7 +284,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
284
284
  ].filter(Boolean)
285
285
  : [];
286
286
  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"
287
- ? _jsx(Text, { color: C.yellow, wrap: "truncate-end", children: " \u26A0 Changed since you enabled it. Review the code, then press Enter to enable again." })
287
+ ? _jsx(Text, { color: C.yellow, wrap: "truncate-end", children: " \u25B2 Changed since you enabled it. Review the code, then press Enter to enable again." })
288
288
  : p.error
289
289
  ? _jsxs(Text, { color: C.red, wrap: "truncate-end", children: [" \u2717 ", p.error] })
290
290
  : _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", p.plugin?.description ?? (enabled ? "" : "Enable to load it.")] })] }, p.id));
@@ -293,7 +293,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
293
293
  const hints = tab === "Model"
294
294
  ? 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" : ""}`
295
295
  : tab === "Engine" ? "↑↓ move · Enter use this engine" : tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change option" : tab === "Games" ? "↑↓ move · Enter add / remove · /games opens the panel" : "↑↓ move · Enter toggle · r rescan";
296
- 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" })] })] }));
296
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, bold: true, children: "\u25C7 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" })] })] }));
297
297
  }
298
298
  /** What a connect field starts with: the current URL to edit (keys stay hidden, so they start empty). */
299
299
  function initialValue(f) {
@@ -2,13 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { memo, useEffect, useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
4
  import { C } from "./theme.js";
5
+ import { usePaneWheel } from "./scroll.js";
5
6
  const COLORS = { out: C.text, command: C.accent, info: C.dim, error: C.red };
6
7
  /**
7
8
  * The terminal panel, under the code like VS Code's: type a command and Enter runs it. While one runs,
8
9
  * what you type goes to it; Ctrl+C stops it. ↑↓ go through what you ran, Ctrl+L clears, Esc leaves.
9
10
  * Drag its top border (or Alt+↑/↓) to make it taller or shorter.
10
11
  */
11
- export const Terminal = memo(function Terminal({ shell, version, width, height, focused, wheel, onLeave, onClose, onResize }) {
12
+ export const Terminal = memo(function Terminal({ shell, version, width, height, focused, wheelPane, onLeave, onClose, onResize }) {
13
+ const wheel = usePaneWheel(wheelPane);
12
14
  const [input, setInput] = useState("");
13
15
  const [history, setHistory] = useState([]);
14
16
  const [back, setBack] = useState(-1);
@@ -1,42 +1,51 @@
1
1
  /**
2
- * Whether the terminal's background is light or dark, so Nou can pick colors that are readable on it.
3
- *
4
- * COLORFGBG (set by rxvt, Konsole and others: "foreground;background" as color numbers) answers at once.
5
- * Otherwise Nou asks the terminal for its background color (OSC 11, answered by iTerm2, Terminal.app,
6
- * kitty, Ghostty, WezTerm, Alacritty, foot, GNOME's and VS Code's terminals and recent Windows Terminal).
7
- * A terminal that doesn't answer within `timeoutMs` gets null, and Nou keeps its dark colors.
2
+ * Asks the terminal, once, before Nou draws anything:
3
+ * - its background color (OSC 11), so the colors are readable on it (COLORFGBG answers that without asking);
4
+ * - whether it speaks the kitty keyboard protocol (CSI ? u), which tells Shift/Ctrl/Cmd+Enter, Cmd+F and
5
+ * Ctrl+= apart from plain keys. Ink can ask too, but only after the first frame is drawn, and with a big
6
+ * project the answer lands after its 200 ms timeout, so the protocol never came on (Shift+Enter sent).
7
+ * Then DA1 (CSI c), which every terminal answers, and in order: once its answer is in, so are the others.
8
8
  */
9
- export async function detectBackground(timeoutMs = 150) {
9
+ export async function detectTerminal(timeoutMs = 400) {
10
10
  const fromEnv = paletteFromColorFgBg(process.env.COLORFGBG);
11
- if (fromEnv)
12
- return fromEnv;
13
11
  const stdin = process.stdin;
14
12
  if (!stdin.isTTY || !process.stdout.isTTY || typeof stdin.setRawMode !== "function")
15
- return null;
13
+ return { palette: fromEnv, kitty: null };
16
14
  return new Promise((resolve) => {
17
15
  let reply = "";
18
16
  const wasRaw = stdin.isRaw;
19
- const finish = (result) => {
17
+ const finish = () => {
20
18
  clearTimeout(timer);
21
19
  stdin.off("data", onData);
22
20
  stdin.setRawMode(wasRaw);
23
21
  stdin.pause();
24
- resolve(result);
22
+ resolve(readTerminalReply(reply, fromEnv));
25
23
  };
26
24
  const onData = (data) => {
27
25
  reply += data.toString("latin1");
28
- const palette = paletteFromOsc11(reply);
29
- if (palette)
30
- finish(palette);
26
+ if (DA1.test(reply))
27
+ finish();
31
28
  };
32
- const timer = setTimeout(() => finish(null), timeoutMs);
29
+ const timer = setTimeout(finish, timeoutMs);
33
30
  stdin.setRawMode(true);
34
31
  stdin.resume();
35
32
  stdin.on("data", onData);
36
- // ST-terminated (ESC \): the form most terminals answer in kind.
37
- process.stdout.write("\x1b]11;?\x1b\\");
33
+ // OSC 11 is ST-terminated (ESC \): the form most terminals answer in kind.
34
+ process.stdout.write(`${fromEnv ? "" : "\x1b]11;?\x1b\\"}\x1b[?u\x1b[c`);
38
35
  });
39
36
  }
37
+ // eslint-disable-next-line no-control-regex -- terminal answers start with ESC
38
+ const KITTY = /\x1b\[\?\d+u/;
39
+ // eslint-disable-next-line no-control-regex -- terminal answers start with ESC
40
+ const DA1 = /\x1b\[\?[\d;]*c/;
41
+ /** What the terminal's answers say (see detectTerminal); `fromEnv` is the background COLORFGBG gave, if any. */
42
+ export function readTerminalReply(reply, fromEnv = null) {
43
+ return { palette: fromEnv ?? paletteFromOsc11(reply), kitty: KITTY.test(reply) ? true : DA1.test(reply) ? false : null };
44
+ }
45
+ /** Just the background (see detectTerminal). */
46
+ export async function detectBackground(timeoutMs = 400) {
47
+ return (await detectTerminal(timeoutMs)).palette;
48
+ }
40
49
  /** "15;0" (light text on black) → dark; "0;15" → light. The last number is the background's color index. */
41
50
  export function paletteFromColorFgBg(value) {
42
51
  const bg = Number(value?.split(";").at(-1));
@@ -2,7 +2,7 @@
2
2
  * Commands that only change what's on screen (or handle a busy Nou themselves): they run right away
3
3
  * while Nou works instead of waiting in the queue behind it.
4
4
  */
5
- const INSTANT = new Set(["zoom", "now", "fold", "unfold", "redraw", "new", "tab", "close", "terminal", "games", "open", "settings", "auto", "diff", "sessions", "history", "problems", "copy", "help", "version", "cancel"]);
5
+ const INSTANT = new Set(["files", "actions", "palette", "zoom", "now", "fold", "unfold", "redraw", "new", "tab", "close", "terminal", "games", "open", "settings", "auto", "diff", "sessions", "history", "problems", "copy", "help", "version", "cancel"]);
6
6
  /** Whether a typed command runs right away while Nou works (see INSTANT); `/showme` counts only to look or step. */
7
7
  /**
8
8
  * The command a mistyped name means: the same letters in another order ("/termnial"), or one letter off
@@ -53,6 +53,8 @@ export const SLASH_COMMANDS = [
53
53
  { name: "open", args: "<file>[:line]", description: "open a file in the code view (part of its name is enough)" },
54
54
  { name: "effort", args: "[fastest|fast|balanced|smarter|smartest]", description: "speed vs smarts: how hard the model thinks (cheaper + faster, or smarter)" },
55
55
  { name: "workspace", args: "[new <folder> | n]", description: "several projects in this window, a tab each in the header: open one, or switch" },
56
+ { name: "files", description: "find a file by part of its name (also Ctrl+P)" },
57
+ { name: "actions", description: "every action Nou has, searchable, with its key (also ?)" },
56
58
  { name: "zoom", args: "in|out|reset", description: "make the code panel bigger or smaller (+ and - with the code focused, = resets)" },
57
59
  { name: "now", description: "stop Nou and send your queued messages right away (or click them)" },
58
60
  { name: "settings", description: "models, engine, preferences and plugins" },
@@ -48,9 +48,14 @@ export function installFastWidth() {
48
48
  if (typeof registerHooks !== "function" || process.env.NOU_NO_FAST_WIDTH)
49
49
  return false;
50
50
  const shim = new URL(`./width-shim${import.meta.url.endsWith(".ts") ? ".ts" : ".js"}`, import.meta.url).href;
51
+ const lineShim = new URL(`./line-update-shim${import.meta.url.endsWith(".ts") ? ".ts" : ".js"}`, import.meta.url).href;
51
52
  registerHooks({
52
53
  resolve(specifier, context, nextResolve) {
53
54
  const resolved = nextResolve(specifier, context);
55
+ // Ink's line-update gets a fast path too (see line-update-shim.ts).
56
+ if (specifier === "./line-update.js" && /[\\/]ink[\\/]build[\\/]log-update\.js$/.test(context.parentURL ?? "")) {
57
+ return { ...resolved, url: `${lineShim}?of=${encodeURIComponent(resolved.url)}` };
58
+ }
54
59
  if (specifier !== "string-width" || context.parentURL?.startsWith(shim))
55
60
  return resolved;
56
61
  // Each copy of string-width (Ink may have its own) gets its own wrapper.
@@ -1,8 +1,7 @@
1
1
  /**
2
- * Keys named the way the platform names them. On a Mac, Find is Cmd+F: terminals that report the Cmd key
3
- * (kitty's keyboard protocol, which Nou asks for: Ghostty, kitty, WezTerm, iTerm2) send it as "super". Ctrl+F
4
- * works everywhere too, for terminals that keep Cmd for themselves.
2
+ * Find is shown as Ctrl+F everywhere (it works in every terminal). On a Mac, Cmd+F works too where the
3
+ * terminal reports Cmd (the kitty keyboard protocol, which Nou asks for: Ghostty, kitty, WezTerm, iTerm2).
5
4
  */
6
- export const FIND_KEY = process.platform === "darwin" ? "Cmd+F" : "Ctrl+F";
5
+ export const FIND_KEY = "Ctrl+F";
7
6
  /** Ctrl+F, or Cmd+F where the terminal reports Cmd. */
8
7
  export const isFind = (input, key) => input === "f" && (!!key.ctrl || !!key.super);