nou-cli 0.5.0 → 0.5.2

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.
@@ -1,15 +1,38 @@
1
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 { Box, Text, useAnimation, useInput } from "ink";
4
- import { renderMarkdown, wrap } from "./text.js";
4
+ import { statSync } from "fs";
5
+ import path from "path";
6
+ import { linkAt, linkFileRefs, renderMarkdown, wrap } from "./text.js";
5
7
  import { ChatInput, inputHeight } from "./ChatInput.js";
6
8
  import { C, SPINNER } from "./theme.js";
7
9
  import { Pane } from "./Pane.js";
10
+ import { matchCommands } from "./commands.js";
8
11
  import { Scrollbar, topFor } from "./scroll.js";
9
12
  const BAR = "▌ ";
10
- export const COMMANDS = ["/model", "/models", "/settings", "/auto", "/engine", "/copy", "/undo", "/rewind", "/history", "/problems", "/version", "/clear", "/help"];
13
+ // Whether a path in a message is a file in the project, remembered so each one is checked once.
14
+ const knownFiles = new Map();
15
+ function isProjectFile(file) {
16
+ let known = knownFiles.get(file);
17
+ if (known === undefined) {
18
+ const full = path.resolve(file);
19
+ const rel = path.relative(process.cwd(), full);
20
+ try {
21
+ known = !rel.startsWith("..") && !path.isAbsolute(rel) && statSync(full).isFile();
22
+ }
23
+ catch {
24
+ known = false;
25
+ }
26
+ if (knownFiles.size > 5000)
27
+ knownFiles.clear();
28
+ // Only files that exist are remembered: one Nou is about to create becomes a link once it's there.
29
+ if (known)
30
+ knownFiles.set(file, known);
31
+ }
32
+ return known;
33
+ }
11
34
  /** Display lines for one message, wrapped to `inner` columns. */
12
- function entryLines(entry, inner) {
35
+ function entryLines(entry, inner, links = true) {
13
36
  const out = [];
14
37
  if (entry.kind === "user") {
15
38
  out.push({ text: "you", color: C.cyan, bold: true, lead: BAR, leadColor: C.cyan });
@@ -19,7 +42,7 @@ function entryLines(entry, inner) {
19
42
  }
20
43
  else if (entry.kind === "assistant") {
21
44
  out.push({ text: "◆ nou", color: C.accent2, bold: true, lead: BAR, leadColor: C.accent2 });
22
- for (const line of renderMarkdown(entry.text, inner - 3)) {
45
+ for (const line of renderMarkdown(entry.text, inner - 3, links ? isProjectFile : undefined)) {
23
46
  const color = line.kind === "heading" ? C.accent2 : line.kind === "quote" ? C.dim : undefined;
24
47
  out.push({ text: "", spans: line.spans, ...(color ? { color } : {}), lead: BAR, leadColor: C.accent2 });
25
48
  }
@@ -29,7 +52,10 @@ function entryLines(entry, inner) {
29
52
  const style = entry.kind === "tool"
30
53
  ? { icon: "⚙", color: C.dim }
31
54
  : entry.kind === "error" ? { icon: "✖", color: C.red } : { icon: "ℹ", color: C.accent };
32
- wrap(entry.text, inner - 4).forEach((line, i) => out.push({ text: ` ${line}`, color: style.color, lead: i === 0 ? ` ${style.icon}` : " ", leadColor: style.color }));
55
+ wrap(entry.text, inner - 4).forEach((line, i) => {
56
+ const spans = links && entry.kind !== "user" ? linkFileRefs([{ text: ` ${line}` }], isProjectFile) : [];
57
+ out.push({ text: ` ${line}`, color: style.color, lead: i === 0 ? ` ${style.icon}` : " ", leadColor: style.color, ...(spans.some((sp) => sp.link) ? { spans } : {}) });
58
+ });
33
59
  }
34
60
  return out;
35
61
  }
@@ -44,7 +70,7 @@ function cachedLines(entry, inner) {
44
70
  laidOut.set(entry, { inner, lines });
45
71
  return lines;
46
72
  }
47
- export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, onSubmit, onAnswer }) {
73
+ export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, queued = [] }) {
48
74
  const [input, setInput] = useState("");
49
75
  useEffect(() => {
50
76
  if (prefill)
@@ -54,7 +80,7 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
54
80
  const { frame } = useAnimation({ interval: 80, isActive: !!busy });
55
81
  const inner = width - 3; // minus the borders and the scrollbar
56
82
  const history = useMemo(() => entries.flatMap((e) => cachedLines(e, inner)), [entries, inner]);
57
- const lines = useMemo(() => (streaming ? [...history, ...entryLines({ kind: "assistant", text: streaming }, inner)] : history), [history, streaming, inner]);
83
+ const lines = useMemo(() => (streaming ? [...history, ...entryLines({ kind: "assistant", text: streaming }, inner, false)] : history), [history, streaming, inner]);
58
84
  // At the bottom, new output scrolls into view. Scrolled up to read, the view stays where it is
59
85
  // (counted from the bottom, so it moves up by however many lines arrived) until you scroll back down.
60
86
  const lastCount = useRef(lines.length);
@@ -84,7 +110,7 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
84
110
  footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
85
111
  }
86
112
  else if (approval?.kind === "tool") {
87
- footer.push({ text: `🔌 Plugin tool: ${approval.name}`, color: C.yellow, bold: true });
113
+ 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 });
88
114
  for (const line of wrap(JSON.stringify(approval.args), inner - 2).slice(0, 4))
89
115
  footer.push({ text: ` ${line}`, color: C.text });
90
116
  footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
@@ -101,11 +127,23 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
101
127
  else if (busy) {
102
128
  footer.push({ text: `${SPINNER[frame % SPINNER.length]} ${busy}`, color: C.accent2 });
103
129
  }
104
- const suggestions = input.startsWith("/") && !input.includes(" ")
105
- ? COMMANDS.filter((c) => c.startsWith(input) && c !== input)
106
- : [];
130
+ for (const text of queued) {
131
+ footer.push({ text: `⏳ next: ${text.replace(/\s+/g, " ")}`, color: C.dim });
132
+ }
133
+ // Typing "/" opens a menu of commands with what each does; ↑↓ pick one, Tab completes, Enter runs it.
134
+ const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
135
+ const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
136
+ const [menuRow, setMenuRow] = useState(0);
137
+ useEffect(() => setMenuRow(0), [input]);
138
+ const showMenu = matches.length > 0;
139
+ useEffect(() => onMenu?.(showMenu), [showMenu]);
140
+ const menuRows = Math.min(matches.length, Math.max(1, Math.min(7, height - 12)));
141
+ const menuStart = Math.max(0, Math.min(menuRow - menuRows + 1, matches.length - menuRows));
142
+ const picked = matches[menuRow];
143
+ /** Puts the picked command in the input, with a space when it takes arguments. */
144
+ const complete = (c) => setInput(`/${c.name}${c.args ? " " : ""}`);
107
145
  const inputRows = inputHeight(input, inner + 1);
108
- const rows = Math.max(1, height - 3 - footer.length - 2 - inputRows);
146
+ const rows = Math.max(1, height - 3 - footer.length - 2 - inputRows - (showMenu ? menuRows - 1 : 0));
109
147
  const sent = useMemo(() => entries.filter((e) => e.kind === "user").map((e) => e.text), [entries]);
110
148
  const maxBack = Math.max(0, lines.length - rows);
111
149
  const back = Math.min(scrollBack, maxBack);
@@ -119,6 +157,15 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
119
157
  if (scrollTo)
120
158
  setScrollBack(maxBack - topFor(scrollTo.fraction, lines.length, rows));
121
159
  }, [scrollTo?.nonce]);
160
+ // Clicking a file named in a message opens it in the code view.
161
+ useEffect(() => {
162
+ if (!click)
163
+ return;
164
+ const line = visible[click.row];
165
+ const ref = line?.spans ? linkAt(line.spans, click.col) : null;
166
+ if (ref)
167
+ onOpenRef?.(ref);
168
+ }, [click?.nonce]);
122
169
  // While something waits for approval, y/n into an empty input answer it; any other key starts a message.
123
170
  useInput((typed) => {
124
171
  if (/^[yYnN]$/.test(typed))
@@ -136,16 +183,31 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
136
183
  else if (key.ctrl && key.end)
137
184
  setScrollBack(0);
138
185
  }, { isActive: focused });
139
- const submit = (value) => {
140
- if (!value.trim() || busy || approval)
186
+ useInput((_input, key) => {
187
+ if (key.upArrow && !key.shift)
188
+ setMenuRow((r) => (r - 1 + matches.length) % matches.length);
189
+ else if (key.downArrow && !key.shift)
190
+ setMenuRow((r) => (r + 1) % matches.length);
191
+ else if (key.tab && picked)
192
+ complete(picked);
193
+ else if (key.escape)
194
+ setInput("");
195
+ }, { isActive: focused && showMenu });
196
+ const submit = (value, now = false) => {
197
+ if (!value.trim() || approval)
141
198
  return;
142
- // "/se" + Enter runs /settings when only one command matches.
143
- const command = !COMMANDS.includes(value.trim()) && suggestions.length === 1 ? suggestions[0] : value.trim();
199
+ let text = value.trim();
200
+ if (showMenu && picked) {
201
+ // Enter runs the highlighted command; one that needs an argument is completed instead, to type it.
202
+ if (picked.args?.startsWith("<"))
203
+ return complete(picked);
204
+ text = `/${picked.name}`;
205
+ }
144
206
  setInput("");
145
- onSubmit(command);
207
+ onSubmit(text, now);
146
208
  };
147
209
  const render = (line, key, withLead = true) => (_jsxs(Text, { wrap: "truncate-end", children: [withLead && line.lead ? _jsx(Text, { color: line.leadColor ?? C.dim, children: line.lead }) : null, line.spans?.length
148
- ? line.spans.map((span, i) => (_jsx(Text, { bold: !!span.bold || !!line.bold, italic: !!span.italic, dimColor: !!span.dim, ...(span.code ? { color: C.yellow } : line.color ? { color: line.color } : { color: C.text }), children: span.text }, i)))
210
+ ? 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)))
149
211
  : _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
150
212
  const empty = entries.length === 0 && !streaming;
151
213
  const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back
@@ -153,7 +215,8 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
153
215
  ? _jsxs(Text, { color: C.green, bold: true, children: [" \u2193 ", unread, " new lines below", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
154
216
  : _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
155
217
  : maxBack > 0 ? _jsx(Text, { color: C.dim, children: " PgUp / wheel to scroll" }) : null] }));
156
- 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) => render(line, firstShown + i, false)) })] })) }), _jsx(Scrollbar, { total: lines.length, top: firstShown, rows: rows, focused: focused })] }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), _jsx(Text, { wrap: "truncate-end", color: C.dim, children: suggestions.length
157
- ? _jsxs(Text, { color: C.accent, children: [suggestions.join(" "), " ", _jsx(Text, { color: C.dim, children: suggestions.length === 1 ? "Enter ↵" : "" })] })
158
- : 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, placeholder: busy ? "working… Esc to stop" : "Ask anything · / for commands · ↑ history" })] }));
218
+ 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) => render(line, firstShown + i, false)) })] })) }), _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) => {
219
+ const selected = menuStart + i === menuRow;
220
+ 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));
221
+ }) })) : (_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" })] }));
159
222
  });
@@ -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 }) {
38
+ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder, history, maxLines = 6, arrowsTaken = false }) {
39
39
  const [cursor, setCursor] = useState(value.length);
40
40
  const [recall, setRecall] = useState(null);
41
41
  const stash = useRef("");
@@ -75,9 +75,11 @@ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder
75
75
  useInput((input, key) => {
76
76
  if (key.tab || key.escape || key.pageUp || key.pageDown || (key.shift && (key.upArrow || key.downArrow)))
77
77
  return;
78
+ if (arrowsTaken && (key.upArrow || key.downArrow))
79
+ return;
78
80
  if (key.return) {
79
81
  setRecall(null);
80
- onSubmit(value);
82
+ onSubmit(value, key.meta);
81
83
  return;
82
84
  }
83
85
  if (key.backspace)
@@ -1,22 +1,35 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { memo, useEffect, useMemo, useState } from "react";
2
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
4
  import { SEVERITY_STYLE } from "./ProblemsView.js";
5
5
  import { artWork } from "../util/index.js";
6
- import { highlightLines, sideBySide, splitLines } from "./text.js";
6
+ import { highlightLines, markAnsi, sideBySide, sliceAnsi, splitLines } from "./text.js";
7
7
  import { C, fileIcon, gradient } from "./theme.js";
8
8
  import { Pane } from "./Pane.js";
9
9
  import { Scrollbar, topFor } from "./scroll.js";
10
+ /** The current match: a yellow background; the others are shown inverted. */
11
+ const CURRENT_MATCH = ["\x1b[43m\x1b[30m", "\x1b[49m\x1b[39m"];
10
12
  /** Columns ←/→ and Shift+wheel move sideways. */
11
13
  const SIDE_STEP = 8;
12
- export const CodeView = memo(function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheel, scrollTo, onViewport }) {
14
+ const EDIT_HINT = " double-click or e to edit · v diff";
15
+ export const CodeView = memo(function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheel, scrollTo, onViewport, defineAt, onDefine, tabs, onFinding, findRequest }) {
13
16
  const [top, setTop] = useState(0);
14
- // Columns scrolled sideways. Scrolled lines show without colors (slicing colored text could cut a color code in half).
17
+ // Columns scrolled sideways (colors are kept: see sliceAnsi).
15
18
  const [left, setLeft] = useState(0);
16
- const rows = Math.max(1, height - 3);
19
+ // Ctrl+F: what's being looked for, and which match is current.
20
+ const [find, setFind] = useState(null);
21
+ useEffect(() => onFinding?.(!!find), [!!find]);
22
+ useEffect(() => setFind(null), [filePath]);
23
+ // Only a new request opens it: not the old one seen again when the view comes back (after the editor, settings...).
24
+ const lastFindRequest = useRef(findRequest);
25
+ useEffect(() => {
26
+ if (findRequest && findRequest !== lastFindRequest.current)
27
+ setFind({ query: "", index: 0 });
28
+ lastFindRequest.current = findRequest;
29
+ }, [findRequest]);
30
+ const rows = Math.max(1, height - 3 - (find ? 1 : 0));
17
31
  const inner = width - 3; // minus the borders and the scrollbar
18
32
  const lines = useMemo(() => (filePath ? highlightLines(filePath, content) : []), [filePath, content, syntaxVersion]);
19
- const plain = useMemo(() => (left > 0 ? splitLines(content) : []), [content, left > 0]);
20
33
  const problemsByLine = useMemo(() => {
21
34
  const map = new Map();
22
35
  for (const d of problems)
@@ -39,6 +52,31 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
39
52
  }, [focusLine]);
40
53
  const maxTop = Math.max(0, total - rows);
41
54
  const start = Math.min(top, maxTop);
55
+ const shownLines = useMemo(() => (find ? splitLines(content) : []), [content, !!find]);
56
+ const matches = useMemo(() => {
57
+ const out = [];
58
+ const q = find?.query.toLowerCase();
59
+ if (!q)
60
+ return out;
61
+ shownLines.forEach((text, i) => {
62
+ const lower = text.toLowerCase();
63
+ for (let at = lower.indexOf(q); at >= 0 && out.length < 10_000; at = lower.indexOf(q, at + q.length))
64
+ out.push({ line: i, col: at });
65
+ });
66
+ return out;
67
+ }, [shownLines, find?.query]);
68
+ const current = find && matches.length ? matches[((find.index % matches.length) + matches.length) % matches.length] : null;
69
+ // Bring the current match into view (a third of the way down, like a jump), sideways too.
70
+ useEffect(() => {
71
+ if (!current)
72
+ return;
73
+ if (current.line < start || current.line >= start + rows)
74
+ setTop(Math.max(0, current.line - Math.floor(rows / 3)));
75
+ const room = inner - String(lines.length).length - 2;
76
+ if (current.col < left || current.col + (find?.query.length ?? 0) > left + room)
77
+ setLeft(Math.max(0, current.col - Math.floor(room / 3)));
78
+ }, [current?.line, current?.col]);
79
+ const found = focusLine?.path && focusLine.path === filePath ? { from: focusLine.line, to: Math.max(focusLine.line, focusLine.to ?? focusLine.line) } : null;
42
80
  useEffect(() => {
43
81
  if (!wheel)
44
82
  return;
@@ -55,7 +93,42 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
55
93
  if (filePath && !diff)
56
94
  onViewport(start + 1, Math.min(total, start + rows));
57
95
  }, [filePath, diff, start, rows, total]);
96
+ useEffect(() => {
97
+ if (!defineAt || diff || !filePath)
98
+ return;
99
+ const n = start + defineAt.row + 1;
100
+ const raw = content.replace(/\r\n/g, "\n").split("\n")[n - 1];
101
+ if (raw === undefined)
102
+ return;
103
+ // Screen column to the file's own column: tabs are shown as two spaces.
104
+ const want = defineAt.x - (String(lines.length).length + 1) + left;
105
+ let shown = 0;
106
+ let col = 0;
107
+ while (col < raw.length && shown + (raw[col] === "\t" ? 2 : 1) <= want)
108
+ shown += raw[col++] === "\t" ? 2 : 1;
109
+ onDefine?.(n, col + 1);
110
+ }, [defineAt?.nonce]);
58
111
  useInput((input, key) => {
112
+ if (!find)
113
+ return;
114
+ if (key.escape)
115
+ return setFind(null);
116
+ if (key.return || key.downArrow || (key.ctrl && input === "g"))
117
+ return setFind((f) => f && { ...f, index: f.index + 1 });
118
+ if (key.upArrow)
119
+ return setFind((f) => f && { ...f, index: f.index - 1 });
120
+ if (key.backspace || key.delete)
121
+ return setFind((f) => f && { query: f.query.slice(0, -1), index: 0 });
122
+ if (key.ctrl && input === "f")
123
+ return;
124
+ if (input && !key.ctrl && !key.meta && input >= " ")
125
+ setFind((f) => f && { query: f.query + input, index: 0 });
126
+ }, { isActive: focused && !!find });
127
+ useInput((input, key) => {
128
+ if (find)
129
+ return;
130
+ if (key.ctrl && input === "f" && !diff)
131
+ return setFind({ query: "", index: 0 });
59
132
  if (key.upArrow || input === "k")
60
133
  setTop((t) => Math.max(0, Math.min(t, maxTop) - 1));
61
134
  else if (key.downArrow || input === "j")
@@ -99,11 +172,14 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
99
172
  }
100
173
  if (!filePath) {
101
174
  const tips = [
102
- ["Ctrl+P", "find a file"],
103
- ["Tab", "switch panes"],
104
- ["e", "edit the open file"],
175
+ ["?", "everything Nou can do, searchable"],
176
+ ["Ctrl+P", "find a file (recent ones first)"],
177
+ ["/", "commands, with a menu"],
178
+ ["Tab", "switch panes (or click one)"],
179
+ ["e", "edit the open file (or double-click it)"],
105
180
  ["v", "diff vs git HEAD"],
106
- ["/settings", "models, preferences, plugins"],
181
+ ["drag", "a pane's border to resize it"],
182
+ ["s", "settings: models, keys, plugins"],
107
183
  ];
108
184
  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, 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)))] }) }));
109
185
  }
@@ -111,13 +187,28 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
111
187
  const { icon, color } = fileIcon(filePath);
112
188
  const errorCount = problems.filter((d) => d.severity === "error").length;
113
189
  const warningCount = problems.filter((d) => d.severity === "warning").length;
114
- const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, errorCount > 0 && _jsxs(Text, { color: C.red, children: [" \u2716 ", errorCount] }), warningCount > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", warningCount] }), _jsxs(Text, { color: C.dim, children: [scroll, total > rows ? ` (${percent}%)` : "", focused ? " click or e to edit · v diff" : ""] })] }));
115
- return (_jsx(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: lines.slice(start, start + rows).map((line, i) => {
116
- const n = start + i + 1;
117
- const isMarked = marked.has(n);
118
- const here = problemsByLine.get(n);
119
- const worst = here?.[0];
120
- const style = worst ? SEVERITY_STYLE[worst.severity] : null;
121
- return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: style?.color ?? (isMarked ? C.green : C.dim), children: String(n).padStart(gutter) }), style ? _jsx(Text, { color: style.color, children: style.icon }) : _jsx(Text, { color: isMarked ? C.green : C.dim, children: isMarked ? "▎" : " " }), (left ? plain[start + i]?.slice(left) : line) || " ", worst && style ? _jsxs(Text, { color: style.color, dimColor: true, children: [" ", style.icon, " ", worst.message, here.length > 1 ? ` (+${here.length - 1})` : ""] }) : null] }, n));
122
- }) }), _jsx(Scrollbar, { total: total, top: start, rows: rows, focused: focused })] }) }));
190
+ // The edit hint only when it fits: a cut-off hint reads as noise.
191
+ const titleRoom = tabs ? 0 : width - 4 - (3 + filePath.length + (errorCount ? 5 + String(errorCount).length : 0) + (warningCount ? 5 + String(warningCount).length : 0) + scroll.length + (total > rows ? 7 : 0));
192
+ const title = (_jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath] }), errorCount > 0 && _jsxs(Text, { color: C.red, children: [" \u2716 ", errorCount] }), warningCount > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", warningCount] }), _jsxs(Text, { color: C.dim, children: [scroll, total > rows ? ` (${percent}%)` : "", focused && titleRoom >= EDIT_HINT.length ? EDIT_HINT : ""] })] }));
193
+ 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: lines.slice(start, start + rows).map((line, i) => {
194
+ const n = start + i + 1;
195
+ const isFound = !!found && n >= found.from && n <= found.to;
196
+ const isMarked = marked.has(n);
197
+ const here = problemsByLine.get(n);
198
+ const worst = here?.[0];
199
+ const style = worst ? SEVERITY_STYLE[worst.severity] : null;
200
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: style?.color ?? (isMarked ? C.green : C.dim), children: String(n).padStart(gutter) }), style ? _jsx(Text, { color: style.color, children: style.icon }) : isFound ? _jsx(Text, { color: C.cyan, bold: true, children: "\u258C" }) : _jsx(Text, { color: isMarked ? C.green : C.dim, children: isMarked ? "▎" : " " }), (() => {
201
+ let text = line;
202
+ if (find?.query) {
203
+ for (const m of matches) {
204
+ if (m.line !== n - 1)
205
+ continue;
206
+ const now = m === current;
207
+ text = markAnsi(text, m.col, m.col + find.query.length, ...(now ? CURRENT_MATCH : []));
208
+ }
209
+ }
210
+ text = (left ? sliceAnsi(text, left) : text) || " ";
211
+ return isFound ? _jsx(Text, { backgroundColor: C.panel, children: text }) : text;
212
+ })(), worst && style ? _jsxs(Text, { color: style.color, dimColor: true, children: [" ", style.icon, " ", worst.message, here.length > 1 ? ` (+${here.length - 1})` : ""] }) : null] }, n));
213
+ }) }), _jsx(Scrollbar, { total: total, top: start, rows: rows, focused: focused })] }), find ? (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: C.accent2, bold: true, children: " \u2315 " }), _jsx(Text, { backgroundColor: C.panel, children: find.query }), _jsx(Text, { inverse: true, children: " " }), _jsxs(Text, { color: find.query && !matches.length ? C.red : C.dim, children: [" ", find.query ? (matches.length ? `${matches.indexOf(current) + 1}/${matches.length}${matches.length >= 10_000 ? "+" : ""}` : "no matches") : "type to find", " ", "Enter/\u2193 next \u00B7 \u2191 previous \u00B7 Esc close"] })] })) : null] }));
123
214
  });
@@ -2,7 +2,7 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
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";
5
- import { highlightLines } from "./text.js";
5
+ import { highlightLines, sliceAnsi } from "./text.js";
6
6
  import { C, fileIcon } from "./theme.js";
7
7
  import { SEVERITY_STYLE } from "./ProblemsView.js";
8
8
  import { Pane } from "./Pane.js";
@@ -88,7 +88,7 @@ const LINE_COMMENTS = {
88
88
  };
89
89
  const commentFor = (file) => LINE_COMMENTS[path.extname(file).toLowerCase()] ?? (path.basename(file).toLowerCase() === "dockerfile" ? "#" : null);
90
90
  /** 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 }) {
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 }) {
92
92
  const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
93
93
  const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
94
94
  const comment = useMemo(() => commentFor(filePath), [filePath]);
@@ -309,10 +309,17 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
309
309
  view.current.top = topFor(scrollTo.fraction, buf.lines.length, rows);
310
310
  rerender((n) => n + 1);
311
311
  }, [scrollTo?.nonce]);
312
- // Click to put the cursor there; drag to select.
312
+ // Click to put the cursor there; double-click to select a word; drag to select.
313
+ const lastMouse = useRef(mouse?.[mouse.length - 1]?.seq ?? 0); // events from before the editor opened are done
313
314
  useEffect(() => {
314
- if (!mouse)
315
- return;
315
+ for (const event of mouse ?? []) {
316
+ if (event.seq <= lastMouse.current)
317
+ continue;
318
+ lastMouse.current = event.seq;
319
+ handleMouse(event);
320
+ }
321
+ }, [mouse]);
322
+ const handleMouse = (mouse) => {
316
323
  if (mouse.kind === "release") {
317
324
  dragFrom.current = null;
318
325
  return;
@@ -322,17 +329,31 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
322
329
  const col = bufferCol(buf.lines[row], Math.max(0, mouse.x - gutter) + v.left);
323
330
  setHover(null);
324
331
  setPrompt((p) => (p?.kind === "line" ? null : p));
332
+ if (mouse.kind === "define") {
333
+ dragFrom.current = null;
334
+ setBuf((b) => ({ ...b, row, col, anchor: null }));
335
+ return onDefinition(buf.lines.join("\n"), row + 1, col + 1);
336
+ }
325
337
  setBuf((b) => {
326
338
  if (mouse.kind === "press") {
327
339
  dragFrom.current = { row, col };
328
340
  return { ...b, row, col, anchor: null };
329
341
  }
342
+ if (mouse.kind === "double") {
343
+ // Double-click selects the word under the pointer.
344
+ const line = b.lines[row];
345
+ const at = isWord(line[col]) ? col : isWord(line[col - 1]) ? col - 1 : -1;
346
+ dragFrom.current = null;
347
+ if (at < 0)
348
+ return { ...b, row, col, anchor: null };
349
+ return { ...b, row, col: wordRight(line, at), anchor: { row, col: wordLeft(line, at + 1) } };
350
+ }
330
351
  return { ...b, row, col, anchor: dragFrom.current ?? b.anchor ?? { row: b.row, col: b.col } };
331
352
  });
332
353
  // Dragging past the top or bottom edge scrolls.
333
354
  if (mouse.kind === "drag" && (mouse.y < 0 || mouse.y >= rows))
334
355
  reveal.current = true;
335
- }, [mouse?.nonce]);
356
+ };
336
357
  usePaste((text) => {
337
358
  if (prompt)
338
359
  return setPrompt({ ...prompt, text: prompt.text + text.replace(/\s+/g, " ") });
@@ -555,9 +576,11 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
555
576
  v.top = Math.max(0, Math.min(v.top, maxTop));
556
577
  const visible = buf.lines.slice(v.top, v.top + rows);
557
578
  const colored = useMemo(
558
- // Scrolled sideways: every line shifts by the same amount (plain text, since slicing highlighted
559
- // text could cut a color code in half).
560
- () => (v.left === 0 ? highlightLines(filePath, visible.join("\n")) : visible.map((l) => expand(l).slice(v.left, v.left + textWidth))), [filePath, visible.join("\n"), v.left, textWidth, syntaxVersion]);
579
+ // Scrolled sideways: every line shifts by the same amount, colors and all.
580
+ () => {
581
+ const lines = highlightLines(filePath, visible.join("\n"));
582
+ return v.left === 0 ? lines : lines.map((l) => sliceAnsi(l, v.left, textWidth));
583
+ }, [filePath, visible.join("\n"), v.left, textWidth, syntaxVersion]);
561
584
  const { icon, color } = fileIcon(filePath);
562
585
  const byLine = useMemo(() => {
563
586
  const map = new Map();
@@ -578,7 +601,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
578
601
  const statusColor = confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
579
602
  const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
580
603
  const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
581
- return (_jsxs(Pane, { title: _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) => {
604
+ 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) => {
582
605
  const n = v.top + i;
583
606
  const isCursorLine = n === buf.row;
584
607
  const problem = byLine.get(n + 1)?.[0];
@@ -170,7 +170,7 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
170
170
  const inner = width - 3; // minus the borders and the scrollbar
171
171
  const title = changesOnly
172
172
  ? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.yellow, children: "\u00B1" }), " Changes ", _jsxs(Text, { color: C.dim, children: ["(", nodes.length, ") c: all files"] })] })
173
- : _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent, children: "\u25A4" }), " Explorer", git.size ? _jsxs(Text, { color: C.dim, children: [" c: changes (", git.size, ")"] }) : null] });
173
+ : _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent, children: "\u25A4" }), " Explorer", git.size ? _jsx(Text, { color: C.dim, children: inner >= 30 ? ` c: changes (${git.size})` : ` ±${git.size}` }) : null] });
174
174
  return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "row", height: rows, children: [_jsxs(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: [visible.length === 0 && _jsxs(Text, { color: C.dim, children: [" ", changesOnly ? "No changes ✓" : "(empty)"] }), visible.map((node, i) => {
175
175
  const selected = start + i === cursor;
176
176
  const code = git.get(node.path);
@@ -0,0 +1,92 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { memo, useEffect, useRef, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { GAMES, gameById } from "../games/index.js";
5
+ import { C } from "./theme.js";
6
+ /** The panel's size on screen, borders included. */
7
+ export const GAME_PANEL = { width: 40, height: 19 };
8
+ const FIELD = { width: GAME_PANEL.width - 4, height: GAME_PANEL.height - 6 };
9
+ const KEYS = { w: "up", s: "down", a: "left", d: "right", k: "up", j: "down", h: "left", l: "right" };
10
+ /** A row of the playfield, colored per character in runs so it stays a handful of Text nodes. */
11
+ function Row({ line, colors }) {
12
+ const runs = [];
13
+ for (const ch of line) {
14
+ const color = colors[ch];
15
+ const last = runs[runs.length - 1];
16
+ if (last && last.color === color)
17
+ last.text += ch;
18
+ else
19
+ runs.push({ text: ch, color });
20
+ }
21
+ return _jsx(Text, { children: runs.map((r, i) => _jsx(Text, { ...(r.color ? { color: r.color } : {}), children: r.text }, i)) });
22
+ }
23
+ /**
24
+ * A floating panel for little games. The app places it (it can be dragged by its title bar) and gives it
25
+ * the keyboard while it's focused; a game only runs while it has the keyboard, so it pauses as you leave.
26
+ */
27
+ export const GamePanel = memo(function GamePanel({ x, y, focused, installed, onLeave, onOpenSettings }) {
28
+ const games = installed.map(gameById).filter((g) => !!g);
29
+ const [row, setRow] = useState(0);
30
+ const [playing, setPlaying] = useState(null);
31
+ const [paused, setPaused] = useState(false);
32
+ const inputs = useRef([]);
33
+ const view = playing ? playing.game.view(playing.state) : null;
34
+ const running = !!playing && focused && !paused && !view?.over;
35
+ useEffect(() => {
36
+ if (!running)
37
+ return;
38
+ const timer = setInterval(() => {
39
+ const pressed = inputs.current;
40
+ inputs.current = [];
41
+ setPlaying((p) => (p ? { ...p, state: p.game.tick(p.state, pressed) } : p));
42
+ }, playing.game.tickMs);
43
+ return () => clearInterval(timer);
44
+ }, [running, playing?.game]);
45
+ const start = (game) => {
46
+ inputs.current = [];
47
+ setPaused(false);
48
+ setPlaying({ game, state: game.create(FIELD.width, FIELD.height) });
49
+ };
50
+ useInput((input, key) => {
51
+ if (key.escape)
52
+ return onLeave();
53
+ if (!playing) {
54
+ if (key.upArrow || input === "k")
55
+ setRow((r) => Math.max(0, r - 1));
56
+ else if (key.downArrow || input === "j")
57
+ setRow((r) => Math.min(games.length - 1, r + 1));
58
+ else if ((key.return || input === " ") && games[row])
59
+ start(games[row]);
60
+ else if (input === "s" && !games.length)
61
+ onOpenSettings();
62
+ return;
63
+ }
64
+ if (input === "q")
65
+ return setPlaying(null);
66
+ if (view?.over) {
67
+ if (key.return || input === " " || input === "r")
68
+ start(playing.game);
69
+ return;
70
+ }
71
+ if (input === " " || input === "p")
72
+ return setPaused((p) => !p);
73
+ const pressed = key.upArrow ? "up" : key.downArrow ? "down" : key.leftArrow ? "left" : key.rightArrow ? "right" : KEYS[input];
74
+ if (pressed)
75
+ inputs.current.push(pressed);
76
+ }, { isActive: focused });
77
+ const color = focused ? C.accent2 : C.dim;
78
+ const inner = GAME_PANEL.width - 2;
79
+ const pad = (s) => (s.length >= inner ? s.slice(0, inner) : s + " ".repeat(inner - s.length));
80
+ const title = playing ? ` ✦ ${playing.game.title}` : " ✦ Games";
81
+ const hint = !focused ? "click to play · drag the title to move" : playing ? (view?.over ? "Enter again · q games · Esc leave" : `${paused ? "PAUSED · " : ""}Space pause · q games · Esc leave`) : "↑↓ choose · Enter play · Esc leave";
82
+ return (_jsxs(Box, { position: "absolute", top: y, left: x, width: GAME_PANEL.width, height: GAME_PANEL.height, flexDirection: "column", borderStyle: "round", borderColor: color, children: [_jsx(Text, { backgroundColor: C.panel, color: color, bold: true, wrap: "truncate-end", children: pad(`${title} ⠿ drag`) }), playing && view ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: [view.lines.map((line, i) => {
83
+ // The end-of-game message goes over the middle of the field.
84
+ if (view.over && i === Math.floor(FIELD.height / 2)) {
85
+ const msg = ` ${view.over} `;
86
+ const left = Math.max(0, Math.floor((FIELD.width - msg.length) / 2));
87
+ return _jsxs(Text, { children: [line.slice(0, left), _jsx(Text, { backgroundColor: C.yellow, color: "black", bold: true, children: msg }), line.slice(left + msg.length)] }, i);
88
+ }
89
+ return _jsx(Row, { line: line, colors: playing.game.colors }, i);
90
+ }), _jsx(Text, { color: C.dim, children: "─".repeat(FIELD.width) }), _jsx(Text, { color: C.text, wrap: "truncate-end", children: view.status })] })) : (_jsx(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: games.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.text, children: "No games yet." }), _jsxs(Text, { color: C.dim, wrap: "wrap", children: ["Add some in settings \u2192 Games", focused ? " (press s)" : "", ": Pong, Snake and Breakout."] })] })) : games.map((g, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(i === row && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: i === row ? C.accent2 : C.dim, children: i === row ? "▸ " : " " }), _jsx(Text, { color: C.text, bold: i === row, children: g.title }), _jsxs(Text, { color: C.dim, children: [" ", g.controls] })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", g.blurb] })] }, g.id))) })), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", hint] })] }));
91
+ });
92
+ export { GAMES };
@@ -8,5 +8,5 @@ const VERSION = `v${currentVersion()}`;
8
8
  export const Header = memo(function Header({ width, branch, changes, model, local, busy, update }) {
9
9
  const { frame } = useAnimation({ interval: 120, isActive: !!busy });
10
10
  const project = path.basename(process.cwd());
11
- return (_jsxs(Box, { width: width, justifyContent: "space-between", 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] })] }), _jsxs(Text, { children: [update && _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} ` }), " "] })] }));
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
  });