nou-cli 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,12 +3,23 @@ import { memo, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Box, Text, useAnimation, useInput } from "ink";
4
4
  import { statSync } from "fs";
5
5
  import path from "path";
6
- import { linkAt, linkFileRefs, renderMarkdown, wrap } from "./text.js";
6
+ import { closeOpenMarkdown, linkAt, linkFileRefs, linkUrls, renderMarkdown, wrap } from "./text.js";
7
7
  import { ChatInput, inputHeight } from "./ChatInput.js";
8
- import { C, SPINNER } from "./theme.js";
8
+ import { copyToClipboard } from "./clipboard.js";
9
+ import { C, SPINNER, ANIMATION_MS } from "./theme.js";
9
10
  import { Pane } from "./Pane.js";
10
11
  import { matchCommands } from "./commands.js";
11
12
  import { Scrollbar, topFor } from "./scroll.js";
13
+ /**
14
+ * What the agent did between replies (files read, commands run...) folds into one line, like Claude's
15
+ * "▸ 5 steps": click it (or Ctrl+O for all of them) to see each step. `open` holds the runs shown open.
16
+ */
17
+ function stepLines(steps, open, live, inner) {
18
+ const last = steps[steps.length - 1].text.replace(/\s+/g, " ");
19
+ const summary = `${open ? "▾" : "▸"} ${steps.length} steps${open ? "" : ` · ${live ? "now: " : "last: "}${last}`}`;
20
+ const head = { text: ` ${summary.length > inner - 4 ? `${summary.slice(0, inner - 5)}…` : summary}`, color: open ? C.accent2 : C.dim, lead: " ⚙", leadColor: C.dim, toggle: steps[0] };
21
+ return open ? [head, ...steps.flatMap((e) => cachedLines(e, inner).map((l) => (l.lead === " ⚙" ? { ...l, lead: " " } : l)))] : [head];
22
+ }
12
23
  const BAR = "▌ ";
13
24
  // Whether a path in a message is a file in the project, remembered so each one is checked once.
14
25
  const knownFiles = new Map();
@@ -53,7 +64,7 @@ function entryLines(entry, inner, links = true) {
53
64
  ? { icon: "⚙", color: C.dim }
54
65
  : entry.kind === "error" ? { icon: "✖", color: C.red } : { icon: "ℹ", color: C.accent };
55
66
  wrap(entry.text, inner - 4).forEach((line, i) => {
56
- const spans = links && entry.kind !== "user" ? linkFileRefs([{ text: ` ${line}` }], isProjectFile) : [];
67
+ const spans = links && entry.kind !== "user" ? linkFileRefs(linkUrls([{ text: ` ${line}` }]), isProjectFile) : [];
57
68
  out.push({ text: ` ${line}`, color: style.color, lead: i === 0 ? ` ${style.icon}` : " ", leadColor: style.color, ...(spans.some((sp) => sp.link) ? { spans } : {}) });
58
69
  });
59
70
  }
@@ -70,17 +81,68 @@ function cachedLines(entry, inner) {
70
81
  laidOut.set(entry, { inner, lines });
71
82
  return lines;
72
83
  }
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 = [] }) {
74
- const [input, setInput] = useState("");
84
+ /**
85
+ * Where each tab sits in the chat's title row (columns from the pane's inner left edge), and the "+"
86
+ * that opens a new one: shared by the drawing and the clicks.
87
+ */
88
+ export function chatTabZones(tabs) {
89
+ // One tab: just "Chat +", to leave the title row its room.
90
+ if (tabs.length === 1)
91
+ return { zones: [{ id: tabs[0].id, from: 3, to: 7 }], plus: 8, width: 9 };
92
+ let x = 3; // " ◆ "
93
+ const zones = tabs.map((t) => {
94
+ const w = tabText(t).length;
95
+ const zone = { id: t.id, from: x, to: x + w };
96
+ x += w + 1;
97
+ return zone;
98
+ });
99
+ return { zones, plus: x + 1, width: x + 3 };
100
+ }
101
+ const tabText = (t) => ` ${t.state === "busy" ? "● " : t.state === "waiting" ? "! " : ""}${t.label} `;
102
+ const lineText = (line) => (line.spans ? line.spans.map((s) => s.text).join("") : line.text ?? "");
103
+ export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [] }) {
104
+ const [input, setInputText] = useState("");
105
+ // Whether the chat has the keyboard right now: a key handler from a render before focus moved away can
106
+ // still be attached for a moment (on a busy machine), and its keys belong to the pane that has focus.
107
+ const focusedNow = useRef(focused);
108
+ focusedNow.current = focused;
109
+ const [menuRow, setMenuRow] = useState(0);
110
+ /** A new input also puts the command menu back on its first row, in the same render. */
111
+ const setInput = (text) => {
112
+ setInputText(text);
113
+ setMenuRow(0);
114
+ };
75
115
  useEffect(() => {
76
116
  if (prefill)
77
117
  setInput(prefill.text);
78
118
  }, [prefill?.nonce]);
79
119
  const [scrollBack, setScrollBack] = useState(0);
80
- const { frame } = useAnimation({ interval: 80, isActive: !!busy });
120
+ const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy });
81
121
  const inner = width - 3; // minus the borders and the scrollbar
82
- const history = useMemo(() => entries.flatMap((e) => cachedLines(e, inner)), [entries, inner]);
83
- const lines = useMemo(() => (streaming ? [...history, ...entryLines({ kind: "assistant", text: streaming }, inner, false)] : history), [history, streaming, inner]);
122
+ const [openSteps, setOpenSteps] = useState(new Set());
123
+ const [allOpen, setAllOpen] = useState(false);
124
+ const history = useMemo(() => {
125
+ const out = [];
126
+ for (let i = 0; i < entries.length;) {
127
+ if (entries[i].kind !== "tool") {
128
+ out.push(...cachedLines(entries[i], inner));
129
+ i++;
130
+ continue;
131
+ }
132
+ // A run of steps: one alone is shown as is, two or more fold into a line.
133
+ let j = i;
134
+ while (j < entries.length && entries[j].kind === "tool")
135
+ j++;
136
+ const steps = entries.slice(i, j);
137
+ if (steps.length === 1)
138
+ out.push(...cachedLines(steps[0], inner));
139
+ else
140
+ out.push(...stepLines(steps, allOpen || openSteps.has(steps[0]), !!busy && j === entries.length, inner));
141
+ i = j;
142
+ }
143
+ return out;
144
+ }, [entries, inner, openSteps, allOpen, !!busy]);
145
+ const lines = useMemo(() => (streaming ? [...history, ...entryLines({ kind: "assistant", text: closeOpenMarkdown(streaming) }, inner, false)] : history), [history, streaming, inner]);
84
146
  // At the bottom, new output scrolls into view. Scrolled up to read, the view stays where it is
85
147
  // (counted from the bottom, so it moves up by however many lines arrived) until you scroll back down.
86
148
  const lastCount = useRef(lines.length);
@@ -133,8 +195,6 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
133
195
  // Typing "/" opens a menu of commands with what each does; ↑↓ pick one, Tab completes, Enter runs it.
134
196
  const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
135
197
  const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
136
- const [menuRow, setMenuRow] = useState(0);
137
- useEffect(() => setMenuRow(0), [input]);
138
198
  const showMenu = matches.length > 0;
139
199
  useEffect(() => onMenu?.(showMenu), [showMenu]);
140
200
  const menuRows = Math.min(matches.length, Math.max(1, Math.min(7, height - 12)));
@@ -162,10 +222,74 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
162
222
  if (!click)
163
223
  return;
164
224
  const line = visible[click.row];
225
+ const run = line?.toggle;
226
+ if (run) {
227
+ setOpenSteps((open) => {
228
+ const next = new Set(open);
229
+ if (next.has(run) || allOpen)
230
+ next.delete(run);
231
+ else
232
+ next.add(run);
233
+ return next;
234
+ });
235
+ if (allOpen)
236
+ setAllOpen(false);
237
+ return;
238
+ }
165
239
  const ref = line?.spans ? linkAt(line.spans, click.col) : null;
166
240
  if (ref)
167
241
  onOpenRef?.(ref);
168
242
  }, [click?.nonce]);
243
+ // Drag over the messages to select them (in lines of the conversation, so it holds while it scrolls);
244
+ // letting go copies them. Dragging past the top or bottom scrolls.
245
+ const [selection, setSelection] = useState(null);
246
+ const [flash, setFlash] = useState(null);
247
+ const anchor = useRef(null);
248
+ useEffect(() => {
249
+ if (!select)
250
+ return;
251
+ const at = { line: Math.max(0, Math.min(lines.length - 1, firstShown + select.row)), col: Math.max(0, select.col) };
252
+ if (select.kind === "press") {
253
+ anchor.current = at;
254
+ setSelection(null);
255
+ return;
256
+ }
257
+ if (!anchor.current)
258
+ return;
259
+ if (select.kind === "drag") {
260
+ if (select.row < 0)
261
+ setScrollBack((b) => Math.min(maxBack, b + 1));
262
+ else if (select.row >= rows)
263
+ setScrollBack((b) => Math.max(0, b - 1));
264
+ }
265
+ const moved = at.line !== anchor.current.line || at.col !== anchor.current.col;
266
+ const range = moved ? (at.line < anchor.current.line || (at.line === anchor.current.line && at.col < anchor.current.col) ? { from: at, to: anchor.current } : { from: anchor.current, to: at }) : null;
267
+ setSelection(range);
268
+ if (select.kind !== "release")
269
+ return;
270
+ anchor.current = null;
271
+ if (!range)
272
+ return;
273
+ const text = lines.slice(range.from.line, range.to.line + 1).map((line, i, all) => {
274
+ const t = lineText(line);
275
+ return t.slice(i === 0 ? range.from.col : 0, i === all.length - 1 ? range.to.col + 1 : undefined).trimEnd();
276
+ }).join("\n");
277
+ void copyToClipboard(text).then((ok) => {
278
+ setFlash(ok ? `Copied ${text.split("\n").length > 1 ? `${text.split("\n").length} lines` : "it"} ✓` : "Can't copy: press m, then select");
279
+ setTimeout(() => setFlash(null), 2000);
280
+ });
281
+ }, [select?.nonce]);
282
+ // Typing or new messages leave the selection be; a new conversation clears it.
283
+ useEffect(() => {
284
+ if (!entries.length)
285
+ setSelection(null);
286
+ }, [entries.length]);
287
+ /** The selected columns of a line, if any. */
288
+ const selectedIn = (index) => {
289
+ if (!selection || index < selection.from.line || index > selection.to.line)
290
+ return null;
291
+ return [index === selection.from.line ? selection.from.col : 0, index === selection.to.line ? selection.to.col + 1 : Infinity];
292
+ };
169
293
  // While something waits for approval, y/n into an empty input answer it; any other key starts a message.
170
294
  useInput((typed) => {
171
295
  if (/^[yYnN]$/.test(typed))
@@ -173,13 +297,19 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
173
297
  else if (typed && typed >= " ")
174
298
  setInput(typed); // printable, not a control key
175
299
  }, { isActive: focused && !!approval && !input });
176
- useInput((_input, key) => {
300
+ useInput((input, key) => {
177
301
  if (key.pageUp || (key.upArrow && key.shift))
178
302
  setScrollBack((b) => Math.min(maxBack, Math.min(b, maxBack) + Math.max(1, rows - 2)));
179
303
  else if (key.pageDown || (key.downArrow && key.shift))
180
304
  setScrollBack((b) => Math.max(0, Math.min(b, maxBack) - Math.max(1, rows - 2)));
181
305
  else if (key.ctrl && key.home)
182
306
  setScrollBack(maxBack);
307
+ else if (key.ctrl && input === "o") {
308
+ // Open every run of steps, or fold them all (the ones opened by a click too).
309
+ setAllOpen(!allOpen);
310
+ if (allOpen)
311
+ setOpenSteps(new Set());
312
+ }
183
313
  else if (key.ctrl && key.end)
184
314
  setScrollBack(0);
185
315
  }, { isActive: focused });
@@ -210,13 +340,22 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
210
340
  ? line.spans.map((span, i) => (_jsx(Text, { bold: !!span.bold || !!line.bold, italic: !!span.italic, dimColor: !!span.dim && !span.link, underline: !!span.link, ...(span.link ? { color: C.cyan } : span.code ? { color: C.yellow } : line.color ? { color: line.color } : { color: C.text }), children: span.text }, i)))
211
341
  : _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
212
342
  const empty = entries.length === 0 && !streaming;
213
- const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back
343
+ const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " ", tabs && tabs.items.length > 1 ? (_jsxs(_Fragment, { children: [tabs.items.map((t) => (_jsxs(Text, { children: [_jsx(Text, { ...(t.id === tabs.active ? { backgroundColor: C.panel, color: C.text, bold: true } : { color: t.state === "waiting" ? C.yellow : C.dim }), children: tabText(t) }), " "] }, t.id))), _jsx(Text, { color: C.accent2, bold: true, children: " + " })] })) : _jsxs(_Fragment, { children: ["Chat", tabs ? _jsx(Text, { color: C.accent2, bold: true, children: " +" }) : null] }), flash ? _jsxs(Text, { color: C.green, bold: true, children: [" ", flash] }) : back
214
344
  ? unread
215
345
  ? _jsxs(Text, { color: C.green, bold: true, children: [" \u2193 ", unread, " new lines below", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
216
346
  : _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
217
347
  : maxBack > 0 ? _jsx(Text, { color: C.dim, children: " PgUp / wheel to scroll" }) : null] }));
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) => {
348
+ // The messages, rebuilt only when they or the scroll position change: typing, the context chip and the
349
+ // spinner re-render the chat without touching its (often hundreds of) lines.
350
+ const messages = useMemo(() => (_jsx(_Fragment, { children: empty ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Try:" }), _jsx(Text, { color: C.text, children: " \u201Cexplain this file\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cwhat changed since my last commit?\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cadd error handling here\u201D" }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "I can see the file you have open." })] })) : (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { children: visible.map((line, i) => _jsx(Text, { color: line.leadColor ?? C.dim, children: `${line.lead ?? " "}${i < visible.length - 1 ? "\n" : ""}` }, i)) }) }), _jsx(Box, { flexDirection: "column", width: inner - 2, flexShrink: 0, children: visible.map((line, i) => {
351
+ const range = selectedIn(firstShown + i);
352
+ if (!range)
353
+ return render(line, firstShown + i, false);
354
+ const t = lineText(line) || " ";
355
+ return (_jsxs(Text, { wrap: "truncate-end", color: C.text, children: [t.slice(0, range[0]), _jsx(Text, { inverse: true, children: t.slice(range[0], range[1]) || " " }), t.slice(range[1])] }, firstShown + i));
356
+ }) })] })) })), [lines, firstShown, visible.length, selection, inner, empty]);
357
+ return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: messages }), _jsx(Scrollbar, { total: lines.length, top: firstShown, rows: rows, focused: focused })] }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), showMenu ? (_jsx(Box, { flexDirection: "column", children: matches.slice(menuStart, menuStart + menuRows).map((c, i) => {
219
358
  const selected = menuStart + i === menuRow;
220
359
  return (_jsxs(Text, { wrap: "truncate-end", ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸" : " " }), _jsxs(Text, { color: selected ? C.accent : C.text, bold: selected, children: ["/", c.name] }), c.args && inner >= 56 ? _jsxs(Text, { color: C.dim, children: [" ", c.args] }) : null /* narrow: the description matters more */, _jsxs(Text, { color: C.dim, children: [" ", c.description] })] }, c.name));
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" })] }));
360
+ }) })) : (_jsx(Text, { wrap: "truncate-end", color: C.dim, children: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " })), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner + 1)) }), _jsx(ChatInput, { value: input, onChange: (text) => focusedNow.current && setInput(text), onSubmit: submit, focus: focused && !(approval && !input), width: inner + 1, history: sent, arrowsTaken: showMenu, placeholder: busy ? (inner >= 44 ? "Enter queues · Alt+Enter sends now · Esc stops" : "Enter queues · Esc stops") : showMenu ? "↑↓ choose · Tab complete · Enter run" : inner >= 44 ? "Ask anything · / for commands · ↑ history" : "Ask anything · / commands" })] }));
222
361
  });
@@ -78,6 +78,11 @@ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder
78
78
  if (arrowsTaken && (key.upArrow || key.downArrow))
79
79
  return;
80
80
  if (key.return) {
81
+ // Ctrl/Cmd/Shift+Enter add a line (in terminals that report them); so does \ then Enter, everywhere.
82
+ if (key.ctrl || key.super || key.shift)
83
+ return insert("\n");
84
+ if (value[pos - 1] === "\\")
85
+ return set(`${value.slice(0, pos - 1)}\n${value.slice(pos)}`, pos);
81
86
  setRecall(null);
82
87
  onSubmit(value, key.meta);
83
88
  return;
@@ -181,7 +181,7 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
181
181
  ["drag", "a pane's border to resize it"],
182
182
  ["s", "settings: models, keys, plugins"],
183
183
  ];
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)))] }) }));
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, 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)))] }) }));
185
185
  }
186
186
  const gutter = String(lines.length).length;
187
187
  const { icon, color } = fileIcon(filePath);
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { memo, useEffect, useMemo, useRef, useState } from "react";
3
3
  import path from "path";
4
4
  import { Box, Text, useInput, usePaste } from "ink";
@@ -8,6 +8,7 @@ import { SEVERITY_STYLE } from "./ProblemsView.js";
8
8
  import { Pane } from "./Pane.js";
9
9
  import { Scrollbar, topFor } from "./scroll.js";
10
10
  import { copyToClipboard, readClipboard } from "./clipboard.js";
11
+ import { guideProgress } from "../guide/index.js";
11
12
  const TAB = " ";
12
13
  const expand = (s) => s.replace(/\t/g, TAB);
13
14
  /** Screen column of buffer column `col` on `line` (tabs take two cells). */
@@ -87,13 +88,24 @@ const LINE_COMMENTS = {
87
88
  ".toml": "#", ".r": "#", ".pl": "#", ".ex": "#", ".exs": "#", ".dockerfile": "#", ".sql": "--", ".lua": "--", ".hs": "--",
88
89
  };
89
90
  const commentFor = (file) => LINE_COMMENTS[path.extname(file).toLowerCase()] ?? (path.basename(file).toLowerCase() === "dockerfile" ? "#" : null);
91
+ /** A line without the cursor or a selection: kept as is while you type on other lines. */
92
+ const PlainRow = memo(function PlainRow({ num, numColor, text }) {
93
+ return _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: numColor, children: num }), text || " "] });
94
+ });
90
95
  /** A text editor: arrows and the mouse to move, Shift to select, Ctrl+C/X/V clipboard, Ctrl+S save, Ctrl+Z undo, Esc leave. */
91
- export const Editor = memo(function Editor({ width, height, focused, filePath, initial, baseline, startLine, startCol, problems, autoSave, syntaxVersion, wheel, scrollTo, mouse, onSave, onExit, onState, onHover, onDefinition, tabs }) {
96
+ export const Editor = memo(function Editor({ width, height, focused, filePath, initial, baseline, startLine, startCol, problems, autoSave, syntaxVersion, wheel, scrollTo, mouse, onSave, onExit, onState, onHover, onDefinition, tabs, guide, onGuideDone }) {
92
97
  const eol = useMemo(() => (baseline.includes("\r\n") ? "\r\n" : "\n"), [baseline]);
93
98
  const indentUnit = useMemo(() => (/^\t/m.test(initial) ? "\t" : " "), [initial]);
94
99
  const comment = useMemo(() => commentFor(filePath), [filePath]);
95
100
  const [buf, setBuf] = useState(() => {
96
101
  const lines = initial.replace(/\r\n/g, "\n").split("\n");
102
+ if (guide) {
103
+ // A fresh line for the guided code, indented like its first line.
104
+ const row = Math.max(0, Math.min(lines.length, guide.row));
105
+ const indent = guide.code.match(/^[ \t]*/)[0];
106
+ lines.splice(row, 0, indent);
107
+ return { lines, row, col: indent.length, anchor: null };
108
+ }
97
109
  const row = Math.max(0, Math.min(lines.length - 1, (startLine ?? 1) - 1));
98
110
  const col = startCol ? Math.min(lines[row].length, startCol - 1) : lines[row].match(/^\s*/)[0].length;
99
111
  return { lines, row, col, anchor: null };
@@ -517,6 +529,12 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
517
529
  }
518
530
  return insert(`\n${indent}${opens ? indentUnit : ""}`);
519
531
  }
532
+ if (key.tab && !key.shift && !hasSelection && guide && ghost) {
533
+ // Guided: Tab types the rest of the line for you (and the next line's indentation).
534
+ const end = ghost.indexOf("\n");
535
+ const chunk = end < 0 ? ghost : ghost.slice(0, end + 1) + ghost.slice(end + 1).match(/^[ \t]*/)[0];
536
+ return insert(chunk);
537
+ }
520
538
  if (key.tab) {
521
539
  // With a selection, or Shift+Tab: indent or outdent whole lines.
522
540
  if (key.shift)
@@ -589,16 +607,32 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
589
607
  return map;
590
608
  }, [problems]);
591
609
  const sel = selection(buf);
610
+ // Guided typing: how much of the step's code is typed (from its line's start to the cursor).
611
+ const guideRow = guide ? Math.max(0, Math.min(buf.lines.length - 1, guide.row)) : -1;
612
+ const typed = guide && (buf.row > guideRow || (buf.row === guideRow)) ? [...buf.lines.slice(guideRow, buf.row), buf.lines[buf.row].slice(0, buf.col)].join("\n") : null;
613
+ const progress = guide && typed !== null ? guideProgress(typed, guide.code) : -1;
614
+ const ghost = guide && progress >= 0 ? guide.code.slice(progress) : "";
615
+ const guideDone = useRef(false);
616
+ useEffect(() => {
617
+ if (guide && progress === guide.code.length && !guideDone.current) {
618
+ guideDone.current = true;
619
+ onGuideDone?.();
620
+ }
621
+ }, [progress]);
592
622
  const cursorProblem = byLine.get(buf.row + 1)?.[0];
593
623
  const selectedCount = sel ? selectedText(buf).length : 0;
594
- const status = confirmExit
624
+ const guideStatus = !guide ? null
625
+ : progress < 0 ? "Not quite: that doesn't match the step's code. Backspace and try again (the dim text shows what goes here)."
626
+ : ghost ? `Type the dim text${ghost.includes("\n") ? ` (${ghost.split("\n").length} lines to go)` : ""} · Tab fills in the rest of the line · Esc stop`
627
+ : "✓ Step done";
628
+ const status = guideStatus && !confirmExit && !prompt ? guideStatus : confirmExit
595
629
  ? "Unsaved changes! Ctrl+S save & close · Esc discard · any key keep editing"
596
630
  : prompt
597
631
  ? prompt.kind === "find" ? `Find: ${prompt.text}▏ Enter/↓ next · Shift+Enter/↑ previous · Esc close` : `Go to line: ${prompt.text}▏ Enter go · Esc cancel`
598
632
  : flash ?? (cursorProblem
599
633
  ? `${SEVERITY_STYLE[cursorProblem.severity].icon} ${cursorProblem.message} (${cursorProblem.source})`
600
634
  : `Ln ${buf.row + 1}, Col ${buf.col + 1}${sel ? ` (${selectedCount} selected · Ctrl+C copy · Ctrl+X cut)` : ""}${dirty ? " ● modified" : ""} Ctrl+F find · Ctrl+/ comment · Esc done`);
601
- const statusColor = confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
635
+ const statusColor = guideStatus && !confirmExit && !prompt ? (progress < 0 ? C.red : C.accent2) : confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
602
636
  const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
603
637
  const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
604
638
  return (_jsxs(Pane, { title: _jsxs(_Fragment, { children: [tabs ?? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, dirty ? _jsx(Text, { color: C.yellow, children: " \u25CF" }) : ""] }), " ", _jsx(Text, { backgroundColor: C.green, color: "black", children: " \u270E EDITING " }), !cursorVisible ? _jsxs(Text, { color: C.dim, children: [" cursor on line ", buf.row + 1, " \u00B7 \u2191\u2193 to go back"] }) : null] }), width: width, height: height, focused: focused, accent: C.green, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: width - 3, flexShrink: 0, children: visible.map((line, i) => {
@@ -609,7 +643,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
609
643
  const num = (_jsxs(Text, { color: mark?.color ?? (isCursorLine ? C.green : C.dim), children: [String(n + 1).padStart(gutter - 2), mark ? ` ${mark.icon}` : isCursorLine ? " ▸" : " "] }));
610
644
  const inSelection = sel && n >= sel.start.row && n <= sel.end.row;
611
645
  if (!isCursorLine && !inSelection) {
612
- return _jsxs(Text, { wrap: "truncate-end", children: [num, colored[i] || " "] }, n);
646
+ return _jsx(PlainRow, { num: `${String(n + 1).padStart(gutter - 2)}${mark ? ` ${mark.icon}` : " "}`, numColor: mark?.color ?? C.dim, text: colored[i] ?? "" }, n);
613
647
  }
614
648
  // Lines with the cursor or a selection show plain text, so both can be drawn exactly.
615
649
  const shown = expand(line).slice(v.left, v.left + textWidth);
@@ -626,8 +660,11 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
626
660
  else
627
661
  cells.push(cell);
628
662
  }
663
+ // The code still to type, dim, from the cursor to the end of its line (only past the line's text).
664
+ const ghostLine = isCursorLine && ghost && buf.col >= line.length ? ghost.split("\n")[0] : "";
665
+ const ghostMore = isCursorLine && ghost && buf.col >= line.length ? ghost.split("\n").length - 1 : 0;
629
666
  return (_jsxs(Text, { wrap: "truncate-end", children: [num, cells.map((c, j) => c.cursor
630
- ? _jsx(Text, { inverse: true, color: C.green, children: c.text }, j)
631
- : _jsx(Text, { color: C.text, ...(c.selected ? { backgroundColor: "#364a82" } : {}), children: c.text }, j))] }, n));
667
+ ? _jsx(Text, { inverse: true, color: C.green, children: ghostLine ? ghostLine[0] : c.text }, j)
668
+ : _jsx(Text, { color: C.text, ...(c.selected ? { backgroundColor: "#364a82" } : {}), children: c.text }, j)), ghostLine.length > 1 ? _jsx(Text, { color: C.dim, italic: true, children: expand(ghostLine.slice(1)) }) : null, ghostMore ? _jsxs(Text, { color: C.accent2, children: [ghostLine ? " " : "", "\u23CE", ghostMore > 1 ? ` +${ghostMore - 1} line${ghostMore > 2 ? "s" : ""}` : ""] }) : null] }, n));
632
669
  }) }), _jsx(Scrollbar, { total: buf.lines.length, top: v.top, rows: rows, focused: focused })] }), hoverLines.length > 0 && (_jsx(Box, { position: "absolute", top: Math.max(1, rows - hoverLines.length - 1), left: gutter + 1, flexDirection: "column", borderStyle: "round", borderColor: C.accent2, width: Math.min(width - gutter - 3, 2 + Math.max(...hoverLines.map((l) => l.length))), children: hoverLines.map((l, i) => _jsx(Text, { color: C.text, wrap: "truncate-end", children: l.padEnd(width) }, i)) })), _jsx(Text, { wrap: "truncate-end", color: statusColor, children: ` ${status}` })] }));
633
670
  });
@@ -2,9 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { memo, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { readdirSync } from "fs";
4
4
  import path from "path";
5
- import { Box, Text, useInput } from "ink";
5
+ import { Box, Text, useAnimation, useInput } from "ink";
6
6
  import { IGNORED } from "../commands/index.js";
7
- import { C, GIT_COLORS, fileIcon } from "./theme.js";
7
+ import { C, GIT_COLORS, fileIcon, gradient, ANIMATION_MS } from "./theme.js";
8
8
  import { Pane } from "./Pane.js";
9
9
  import { Scrollbar, topFor } from "./scroll.js";
10
10
  function listDir(dir) {
@@ -31,7 +31,7 @@ function flatten(expanded) {
31
31
  walk(".", 0);
32
32
  return nodes;
33
33
  }
34
- export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, git, problemCounts, wheel, scrollTo, click, onOpen, onHover }) {
34
+ export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, working, git, problemCounts, wheel, scrollTo, click, onOpen, onHover }) {
35
35
  const [expanded, setExpanded] = useState(() => new Set(["src"]));
36
36
  const [cursor, setCursor] = useState(0);
37
37
  // The first row shown. The wheel moves it on its own; the keyboard moves the cursor and the view follows.
@@ -167,6 +167,7 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
167
167
  else
168
168
  onOpen(node.path);
169
169
  }, [click?.nonce]);
170
+ const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!working?.size });
170
171
  const inner = width - 3; // minus the borders and the scrollbar
171
172
  const title = changesOnly
172
173
  ? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.yellow, children: "\u00B1" }), " Changes ", _jsxs(Text, { color: C.dim, children: ["(", nodes.length, ") c: all files"] })] })
@@ -187,6 +188,6 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
187
188
  const room = Math.max(1, inner - marker.length - 1);
188
189
  const shown = label.length > room ? `${label.slice(0, room - 1)}…` : label.padEnd(room);
189
190
  const iconAt = shown.indexOf(icon);
190
- return (_jsxs(Text, { wrap: "truncate-end", ...(selected && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, children: shown.slice(0, iconAt) }), _jsx(Text, { color: color, children: shown.slice(iconAt, iconAt + icon.length) }), _jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, inverse: selected && focused, children: shown.slice(iconAt + icon.length) }), _jsxs(Text, { color: markerColor, children: [" ", marker] })] }, node.path));
191
+ return (_jsxs(Text, { wrap: "truncate-end", ...(selected && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, children: shown.slice(0, iconAt) }), _jsx(Text, { color: color, children: shown.slice(iconAt, iconAt + icon.length) }), working?.has(node.path) ? (_jsx(Text, { bold: true, inverse: selected && focused, children: gradient(shown.slice(iconAt + icon.length), frame).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) })) : (_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, inverse: selected && focused, children: shown.slice(iconAt + icon.length) })), _jsxs(Text, { color: markerColor, children: [" ", marker] })] }, node.path));
191
192
  })] }), _jsx(Scrollbar, { total: nodes.length, top: start, rows: rows, focused: focused })] }) }));
192
193
  });
@@ -3,9 +3,8 @@ import { memo, useEffect, useRef, useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
4
  import { GAMES, gameById } from "../games/index.js";
5
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 };
6
+ /** The panel's tallest height (borders included) when docked under a pane, and the least it needs. */
7
+ export const GAME_PANEL = { height: 19, minWidth: 26, minHeight: 10 };
9
8
  const KEYS = { w: "up", s: "down", a: "left", d: "right", k: "up", j: "down", h: "left", l: "right" };
10
9
  /** A row of the playfield, colored per character in runs so it stays a handful of Text nodes. */
11
10
  function Row({ line, colors }) {
@@ -21,10 +20,11 @@ function Row({ line, colors }) {
21
20
  return _jsx(Text, { children: runs.map((r, i) => _jsx(Text, { ...(r.color ? { color: r.color } : {}), children: r.text }, i)) });
22
21
  }
23
22
  /**
24
- * A floating panel for little games. The app places it (it can be dragged by its title bar) and gives it
23
+ * A panel for little games, docked under a pane (drag its title to another pane; × closes it). The app gives it
25
24
  * the keyboard while it's focused; a game only runs while it has the keyboard, so it pauses as you leave.
26
25
  */
27
- export const GamePanel = memo(function GamePanel({ x, y, focused, installed, onLeave, onOpenSettings }) {
26
+ export const GamePanel = memo(function GamePanel({ width, height, focused, moving, installed, onLeave, onOpenSettings }) {
27
+ const FIELD = { width: Math.max(10, width - 4), height: Math.max(3, height - 6) };
28
28
  const games = installed.map(gameById).filter((g) => !!g);
29
29
  const [row, setRow] = useState(0);
30
30
  const [playing, setPlaying] = useState(null);
@@ -42,6 +42,11 @@ export const GamePanel = memo(function GamePanel({ x, y, focused, installed, onL
42
42
  }, playing.game.tickMs);
43
43
  return () => clearInterval(timer);
44
44
  }, [running, playing?.game]);
45
+ // A different size (moved to another pane, or the window changed) starts the game over at that size.
46
+ useEffect(() => {
47
+ if (playing)
48
+ setPlaying({ game: playing.game, state: playing.game.create(FIELD.width, FIELD.height) });
49
+ }, [FIELD.width, FIELD.height]);
45
50
  const start = (game) => {
46
51
  inputs.current = [];
47
52
  setPaused(false);
@@ -74,12 +79,14 @@ export const GamePanel = memo(function GamePanel({ x, y, focused, installed, onL
74
79
  if (pressed)
75
80
  inputs.current.push(pressed);
76
81
  }, { 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) => {
82
+ const color = focused || moving ? C.accent2 : C.dim;
83
+ const inner = width - 2;
84
+ const title = moving ? " ✦ Drop it on a pane: files, code or chat" : `${playing ? ` ✦ ${playing.game.title}` : " ✦ Games"} ⠿ drag to move · top edge resizes`;
85
+ // The title row: the name and the drag handle, and × (close) at the right.
86
+ const head = title.length > inner - 3 ? `${title.slice(0, inner - 4)}…` : title.padEnd(inner - 3);
87
+ const hint = !focused ? "click to play" : playing ? (view?.over ? "Enter again · q games · Esc leave" : `${paused ? "PAUSED · " : ""}Space pause · q games · Esc leave`) : "↑↓ choose · Enter play · Esc leave";
88
+ const small = width < GAME_PANEL.minWidth || height < GAME_PANEL.minHeight;
89
+ return (_jsxs(Box, { width: width, height: height, flexDirection: "column", borderStyle: "round", borderColor: color, overflow: "hidden", children: [_jsxs(Text, { backgroundColor: C.panel, wrap: "truncate-end", children: [_jsx(Text, { color: color, bold: true, children: head }), _jsx(Text, { color: C.text, bold: true, children: " \u00D7 " })] }), small ? (_jsx(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: _jsx(Text, { color: C.dim, wrap: "wrap", children: "Too small here: drag me to a bigger pane, or make this one wider." }) })) : null, small ? null : playing && view ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: [view.lines.map((line, i) => {
83
90
  // The end-of-game message goes over the middle of the field.
84
91
  if (view.over && i === Math.floor(FIELD.height / 2)) {
85
92
  const msg = ` ${view.over} `;
@@ -2,11 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { memo } from "react";
3
3
  import path from "path";
4
4
  import { Box, Text, useAnimation } from "ink";
5
- import { C, SPINNER, gradient } from "./theme.js";
5
+ import { C, SPINNER, gradient, ANIMATION_MS } from "./theme.js";
6
6
  import { currentVersion } from "../update/index.js";
7
7
  const VERSION = `v${currentVersion()}`;
8
8
  export const Header = memo(function Header({ width, branch, changes, model, local, busy, update }) {
9
- const { frame } = useAnimation({ interval: 120, isActive: !!busy });
9
+ const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: !!busy });
10
10
  const project = path.basename(process.cwd());
11
11
  return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, flexGrow: 1, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsxs(Text, { color: C.dim, children: [" ", VERSION] }), _jsx(Text, { color: C.dim, children: " \u2502 " }), _jsx(Text, { color: C.text, children: project }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] })), busy && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " ", busy] })] }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate-end", children: [update && width >= 110 && _jsxs(Text, { color: C.green, bold: true, children: ["\u2B06 v", update, " available \u00B7 run `nou update` "] }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: "black", bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] }) })] }));
12
12
  });
@@ -1,7 +1,7 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useState } from "react";
3
3
  import { Box, Text, useAnimation, useInput } from "ink";
4
- import { C, SPINNER, fileIcon } from "./theme.js";
4
+ import { C, SPINNER, fileIcon, ANIMATION_MS } from "./theme.js";
5
5
  import { Pane } from "./Pane.js";
6
6
  export const SEVERITY_STYLE = {
7
7
  error: { icon: "✖", color: C.red },
@@ -11,7 +11,7 @@ export const SEVERITY_STYLE = {
11
11
  export function ProblemsView({ width, height, focused, problems, checking, wheel, onJump, onClose }) {
12
12
  const [cursor, setCursor] = useState(0);
13
13
  const [onlyErrors, setOnlyErrors] = useState(false);
14
- const { frame } = useAnimation({ interval: 80, isActive: checking.length > 0 });
14
+ const { frame } = useAnimation({ interval: ANIMATION_MS, isActive: checking.length > 0 });
15
15
  const rows = useMemo(() => {
16
16
  const list = onlyErrors ? problems.filter((d) => d.severity === "error") : problems;
17
17
  const out = [];
@@ -11,7 +11,7 @@ import { PLUGIN_DIRS, isEnabled, trustState } from "../plugins/index.js";
11
11
  import { currentVersion } from "../update/index.js";
12
12
  import { describe, providers, rejectedKey } from "../providers/index.js";
13
13
  import { ENGINES, isInstalled } from "../engines/index.js";
14
- import { C, SPINNER } from "./theme.js";
14
+ import { C, SPINNER, ANIMATION_MS } from "./theme.js";
15
15
  /** A few lines of code to show what a code theme looks like. */
16
16
  const THEME_PREVIEW = `// Greets someone, politely
17
17
  export function greet(name: string, times = 2): string {
@@ -40,7 +40,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
40
40
  const [models, setModels] = useState({});
41
41
  const [connecting, setConnecting] = useState(connectTo?.connect ? { step: 0, values: {} } : null);
42
42
  const [field, setField] = useState(() => (connectTo?.connect ? initialValue(connectTo.connect[0]) : ""));
43
- const { frame } = useAnimation({ interval: 80 });
43
+ const { frame } = useAnimation({ interval: ANIMATION_MS });
44
44
  const provider = providers[row] ?? providers[0];
45
45
  const listed = models[provider.id];
46
46
  // Load the model list for the highlighted provider.
@@ -16,8 +16,8 @@ function labels(tabs) {
16
16
  * are left out at the ends, marked with ‹ ›.
17
17
  */
18
18
  export function layoutTabs(tabs, active, width) {
19
- const all = labels(tabs).map((label, i) => ({ file: tabs[i], label }));
20
- const size = (t) => t.label.length + 4 + (t.file === active ? 2 : 0) + 1; // " ◆ name × " and "│"
19
+ const all = labels(tabs).map((label, i) => ({ file: tabs[i], label: label.length > 24 ? `${label.slice(0, 23)}…` : label }));
20
+ const size = (t) => t.label.length + 6 + 1; // " ◆ name × " and "│"
21
21
  const at = Math.max(0, all.findIndex((t) => t.file === active));
22
22
  let from = at;
23
23
  let to = at + 1;
@@ -39,10 +39,11 @@ export function layoutTabs(tabs, active, width) {
39
39
  let x = from > 0 ? 1 : 0;
40
40
  for (const t of shown) {
41
41
  const w = size(t) - 1;
42
- zones.push({ file: t.file, from: x, to: x + w, close: t.file === active ? x + w - 2 : null });
42
+ zones.push({ file: t.file, from: x, to: x + w, close: x + w - 2 });
43
43
  x += w + 1;
44
44
  }
45
- return { shown, before: from > 0, after: to < all.length, zones };
45
+ // ‹ and › (tabs that don't fit) are clickable too: they go to the previous / next tab.
46
+ return { shown, before: from > 0, after: to < all.length, zones, prev: from > 0 ? 0 : null, next: to < all.length ? x : null };
46
47
  }
47
48
  /** The open files as tabs, for a pane's title row: the active one highlighted, with × to close it. */
48
49
  export function Tabs({ tabs, active, width, dirty }) {
@@ -50,6 +51,6 @@ export function Tabs({ tabs, active, width, dirty }) {
50
51
  return (_jsxs(_Fragment, { children: [before ? _jsx(Text, { color: C.dim, children: "\u2039" }) : null, shown.map((t) => {
51
52
  const on = t.file === active;
52
53
  const { icon, color } = fileIcon(t.file);
53
- return (_jsxs(Fragment, { children: [_jsxs(Text, { ...(on ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { children: " " }), _jsx(Text, { color: color, children: icon }), _jsxs(Text, { color: on ? C.text : C.dim, bold: on, children: [" ", t.label] }), on ? _jsxs(Text, { color: dirty ? C.yellow : C.dim, children: [" ", dirty ? "●" : "×"] }) : null, _jsx(Text, { children: " " })] }), _jsx(Text, { color: C.dim, children: "\u2502" })] }, t.file));
54
+ return (_jsxs(Fragment, { children: [_jsxs(Text, { ...(on ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { children: " " }), _jsx(Text, { color: color, children: icon }), _jsxs(Text, { color: on ? C.text : C.dim, bold: on, children: [" ", t.label] }), _jsxs(Text, { color: on && dirty ? C.yellow : on ? C.text : C.dim, children: [" ", on && dirty ? "●" : "×"] }), _jsx(Text, { children: " " })] }), _jsx(Text, { color: C.dim, children: "\u2502" })] }, t.file));
54
55
  }), after ? _jsx(Text, { color: C.dim, children: "\u203A" }) : null] }));
55
56
  }