nou-cli 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +48 -9
  2. package/dist/index.js +9 -99
  3. package/dist/src/agent/index.js +199 -33
  4. package/dist/src/cli.js +117 -0
  5. package/dist/src/commands/index.js +233 -25
  6. package/dist/src/config/index.js +14 -0
  7. package/dist/src/context/index.js +33 -10
  8. package/dist/src/conversations/external.js +165 -0
  9. package/dist/src/conversations/index.js +71 -0
  10. package/dist/src/engines/claude.js +10 -0
  11. package/dist/src/engines/codex.js +60 -5
  12. package/dist/src/games/index.js +217 -0
  13. package/dist/src/mcp/import.js +106 -0
  14. package/dist/src/mcp/index.js +184 -0
  15. package/dist/src/providers/anthropic.js +23 -3
  16. package/dist/src/providers/index.js +13 -0
  17. package/dist/src/providers/openai.js +6 -2
  18. package/dist/src/syntax/index.js +10 -20
  19. package/dist/src/syntax/themes.js +57 -0
  20. package/dist/src/terminal/index.js +127 -0
  21. package/dist/src/tools/index.js +86 -2
  22. package/dist/src/ui/App.js +900 -118
  23. package/dist/src/ui/Chat.js +143 -22
  24. package/dist/src/ui/ChatInput.js +9 -2
  25. package/dist/src/ui/CodeView.js +111 -20
  26. package/dist/src/ui/Editor.js +13 -6
  27. package/dist/src/ui/FileTree.js +6 -5
  28. package/dist/src/ui/GamePanel.js +99 -0
  29. package/dist/src/ui/Header.js +1 -1
  30. package/dist/src/ui/QuickOpen.js +51 -12
  31. package/dist/src/ui/Settings.js +58 -13
  32. package/dist/src/ui/Tabs.js +56 -0
  33. package/dist/src/ui/Terminal.js +84 -0
  34. package/dist/src/ui/clipboard.js +9 -7
  35. package/dist/src/ui/commands.js +36 -0
  36. package/dist/src/ui/definition.js +135 -0
  37. package/dist/src/ui/fast-width.js +62 -0
  38. package/dist/src/ui/mouse.js +4 -1
  39. package/dist/src/ui/render-options.js +4 -0
  40. package/dist/src/ui/text.js +132 -12
  41. package/dist/src/ui/width-shim.js +7 -0
  42. package/package.json +2 -1
@@ -1,15 +1,39 @@
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 { closeOpenMarkdown, linkAt, linkFileRefs, renderMarkdown, wrap } from "./text.js";
5
7
  import { ChatInput, inputHeight } from "./ChatInput.js";
8
+ import { copyToClipboard } from "./clipboard.js";
6
9
  import { C, SPINNER } from "./theme.js";
7
10
  import { Pane } from "./Pane.js";
11
+ import { matchCommands } from "./commands.js";
8
12
  import { Scrollbar, topFor } from "./scroll.js";
9
13
  const BAR = "▌ ";
10
- export const COMMANDS = ["/model", "/models", "/settings", "/auto", "/engine", "/copy", "/undo", "/rewind", "/history", "/problems", "/version", "/clear", "/help"];
14
+ // Whether a path in a message is a file in the project, remembered so each one is checked once.
15
+ const knownFiles = new Map();
16
+ function isProjectFile(file) {
17
+ let known = knownFiles.get(file);
18
+ if (known === undefined) {
19
+ const full = path.resolve(file);
20
+ const rel = path.relative(process.cwd(), full);
21
+ try {
22
+ known = !rel.startsWith("..") && !path.isAbsolute(rel) && statSync(full).isFile();
23
+ }
24
+ catch {
25
+ known = false;
26
+ }
27
+ if (knownFiles.size > 5000)
28
+ knownFiles.clear();
29
+ // Only files that exist are remembered: one Nou is about to create becomes a link once it's there.
30
+ if (known)
31
+ knownFiles.set(file, known);
32
+ }
33
+ return known;
34
+ }
11
35
  /** Display lines for one message, wrapped to `inner` columns. */
12
- function entryLines(entry, inner) {
36
+ function entryLines(entry, inner, links = true) {
13
37
  const out = [];
14
38
  if (entry.kind === "user") {
15
39
  out.push({ text: "you", color: C.cyan, bold: true, lead: BAR, leadColor: C.cyan });
@@ -19,7 +43,7 @@ function entryLines(entry, inner) {
19
43
  }
20
44
  else if (entry.kind === "assistant") {
21
45
  out.push({ text: "◆ nou", color: C.accent2, bold: true, lead: BAR, leadColor: C.accent2 });
22
- for (const line of renderMarkdown(entry.text, inner - 3)) {
46
+ for (const line of renderMarkdown(entry.text, inner - 3, links ? isProjectFile : undefined)) {
23
47
  const color = line.kind === "heading" ? C.accent2 : line.kind === "quote" ? C.dim : undefined;
24
48
  out.push({ text: "", spans: line.spans, ...(color ? { color } : {}), lead: BAR, leadColor: C.accent2 });
25
49
  }
@@ -29,7 +53,10 @@ function entryLines(entry, inner) {
29
53
  const style = entry.kind === "tool"
30
54
  ? { icon: "⚙", color: C.dim }
31
55
  : 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 }));
56
+ wrap(entry.text, inner - 4).forEach((line, i) => {
57
+ const spans = links && entry.kind !== "user" ? linkFileRefs([{ text: ` ${line}` }], isProjectFile) : [];
58
+ out.push({ text: ` ${line}`, color: style.color, lead: i === 0 ? ` ${style.icon}` : " ", leadColor: style.color, ...(spans.some((sp) => sp.link) ? { spans } : {}) });
59
+ });
33
60
  }
34
61
  return out;
35
62
  }
@@ -44,7 +71,8 @@ function cachedLines(entry, inner) {
44
71
  laidOut.set(entry, { inner, lines });
45
72
  return lines;
46
73
  }
47
- export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, onSubmit, onAnswer }) {
74
+ const lineText = (line) => (line.spans ? line.spans.map((s) => s.text).join("") : line.text ?? "");
75
+ export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, queued = [] }) {
48
76
  const [input, setInput] = useState("");
49
77
  useEffect(() => {
50
78
  if (prefill)
@@ -54,7 +82,7 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
54
82
  const { frame } = useAnimation({ interval: 80, isActive: !!busy });
55
83
  const inner = width - 3; // minus the borders and the scrollbar
56
84
  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]);
85
+ const lines = useMemo(() => (streaming ? [...history, ...entryLines({ kind: "assistant", text: closeOpenMarkdown(streaming) }, inner, false)] : history), [history, streaming, inner]);
58
86
  // At the bottom, new output scrolls into view. Scrolled up to read, the view stays where it is
59
87
  // (counted from the bottom, so it moves up by however many lines arrived) until you scroll back down.
60
88
  const lastCount = useRef(lines.length);
@@ -84,7 +112,7 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
84
112
  footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
85
113
  }
86
114
  else if (approval?.kind === "tool") {
87
- footer.push({ text: `🔌 Plugin tool: ${approval.name}`, color: C.yellow, bold: true });
115
+ 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
116
  for (const line of wrap(JSON.stringify(approval.args), inner - 2).slice(0, 4))
89
117
  footer.push({ text: ` ${line}`, color: C.text });
90
118
  footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
@@ -101,11 +129,23 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
101
129
  else if (busy) {
102
130
  footer.push({ text: `${SPINNER[frame % SPINNER.length]} ${busy}`, color: C.accent2 });
103
131
  }
104
- const suggestions = input.startsWith("/") && !input.includes(" ")
105
- ? COMMANDS.filter((c) => c.startsWith(input) && c !== input)
106
- : [];
132
+ for (const text of queued) {
133
+ footer.push({ text: `⏳ next: ${text.replace(/\s+/g, " ")}`, color: C.dim });
134
+ }
135
+ // Typing "/" opens a menu of commands with what each does; ↑↓ pick one, Tab completes, Enter runs it.
136
+ const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
137
+ const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
138
+ const [menuRow, setMenuRow] = useState(0);
139
+ useEffect(() => setMenuRow(0), [input]);
140
+ const showMenu = matches.length > 0;
141
+ useEffect(() => onMenu?.(showMenu), [showMenu]);
142
+ const menuRows = Math.min(matches.length, Math.max(1, Math.min(7, height - 12)));
143
+ const menuStart = Math.max(0, Math.min(menuRow - menuRows + 1, matches.length - menuRows));
144
+ const picked = matches[menuRow];
145
+ /** Puts the picked command in the input, with a space when it takes arguments. */
146
+ const complete = (c) => setInput(`/${c.name}${c.args ? " " : ""}`);
107
147
  const inputRows = inputHeight(input, inner + 1);
108
- const rows = Math.max(1, height - 3 - footer.length - 2 - inputRows);
148
+ const rows = Math.max(1, height - 3 - footer.length - 2 - inputRows - (showMenu ? menuRows - 1 : 0));
109
149
  const sent = useMemo(() => entries.filter((e) => e.kind === "user").map((e) => e.text), [entries]);
110
150
  const maxBack = Math.max(0, lines.length - rows);
111
151
  const back = Math.min(scrollBack, maxBack);
@@ -119,6 +159,65 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
119
159
  if (scrollTo)
120
160
  setScrollBack(maxBack - topFor(scrollTo.fraction, lines.length, rows));
121
161
  }, [scrollTo?.nonce]);
162
+ // Clicking a file named in a message opens it in the code view.
163
+ useEffect(() => {
164
+ if (!click)
165
+ return;
166
+ const line = visible[click.row];
167
+ const ref = line?.spans ? linkAt(line.spans, click.col) : null;
168
+ if (ref)
169
+ onOpenRef?.(ref);
170
+ }, [click?.nonce]);
171
+ // Drag over the messages to select them (in lines of the conversation, so it holds while it scrolls);
172
+ // letting go copies them. Dragging past the top or bottom scrolls.
173
+ const [selection, setSelection] = useState(null);
174
+ const [flash, setFlash] = useState(null);
175
+ const anchor = useRef(null);
176
+ useEffect(() => {
177
+ if (!select)
178
+ return;
179
+ const at = { line: Math.max(0, Math.min(lines.length - 1, firstShown + select.row)), col: Math.max(0, select.col) };
180
+ if (select.kind === "press") {
181
+ anchor.current = at;
182
+ setSelection(null);
183
+ return;
184
+ }
185
+ if (!anchor.current)
186
+ return;
187
+ if (select.kind === "drag") {
188
+ if (select.row < 0)
189
+ setScrollBack((b) => Math.min(maxBack, b + 1));
190
+ else if (select.row >= rows)
191
+ setScrollBack((b) => Math.max(0, b - 1));
192
+ }
193
+ const moved = at.line !== anchor.current.line || at.col !== anchor.current.col;
194
+ 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;
195
+ setSelection(range);
196
+ if (select.kind !== "release")
197
+ return;
198
+ anchor.current = null;
199
+ if (!range)
200
+ return;
201
+ const text = lines.slice(range.from.line, range.to.line + 1).map((line, i, all) => {
202
+ const t = lineText(line);
203
+ return t.slice(i === 0 ? range.from.col : 0, i === all.length - 1 ? range.to.col + 1 : undefined).trimEnd();
204
+ }).join("\n");
205
+ void copyToClipboard(text).then((ok) => {
206
+ setFlash(ok ? `Copied ${text.split("\n").length > 1 ? `${text.split("\n").length} lines` : "it"} ✓` : "Can't copy: press m, then select");
207
+ setTimeout(() => setFlash(null), 2000);
208
+ });
209
+ }, [select?.nonce]);
210
+ // Typing or new messages leave the selection be; a new conversation clears it.
211
+ useEffect(() => {
212
+ if (!entries.length)
213
+ setSelection(null);
214
+ }, [entries.length]);
215
+ /** The selected columns of a line, if any. */
216
+ const selectedIn = (index) => {
217
+ if (!selection || index < selection.from.line || index > selection.to.line)
218
+ return null;
219
+ return [index === selection.from.line ? selection.from.col : 0, index === selection.to.line ? selection.to.col + 1 : Infinity];
220
+ };
122
221
  // While something waits for approval, y/n into an empty input answer it; any other key starts a message.
123
222
  useInput((typed) => {
124
223
  if (/^[yYnN]$/.test(typed))
@@ -136,24 +235,46 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
136
235
  else if (key.ctrl && key.end)
137
236
  setScrollBack(0);
138
237
  }, { isActive: focused });
139
- const submit = (value) => {
140
- if (!value.trim() || busy || approval)
238
+ useInput((_input, key) => {
239
+ if (key.upArrow && !key.shift)
240
+ setMenuRow((r) => (r - 1 + matches.length) % matches.length);
241
+ else if (key.downArrow && !key.shift)
242
+ setMenuRow((r) => (r + 1) % matches.length);
243
+ else if (key.tab && picked)
244
+ complete(picked);
245
+ else if (key.escape)
246
+ setInput("");
247
+ }, { isActive: focused && showMenu });
248
+ const submit = (value, now = false) => {
249
+ if (!value.trim() || approval)
141
250
  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();
251
+ let text = value.trim();
252
+ if (showMenu && picked) {
253
+ // Enter runs the highlighted command; one that needs an argument is completed instead, to type it.
254
+ if (picked.args?.startsWith("<"))
255
+ return complete(picked);
256
+ text = `/${picked.name}`;
257
+ }
144
258
  setInput("");
145
- onSubmit(command);
259
+ onSubmit(text, now);
146
260
  };
147
261
  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)))
262
+ ? 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
263
  : _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
150
264
  const empty = entries.length === 0 && !streaming;
151
- const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back
265
+ const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", flash ? _jsxs(Text, { color: C.green, bold: true, children: [" ", flash] }) : back
152
266
  ? unread
153
267
  ? _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
268
  : _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
155
269
  : 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" })] }));
270
+ return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: empty ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Try:" }), _jsx(Text, { color: C.text, children: " \u201Cexplain this file\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cwhat changed since my last commit?\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cadd error handling here\u201D" }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "I can see the file you have open." })] })) : (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { children: visible.map((line, i) => _jsx(Text, { color: line.leadColor ?? C.dim, children: `${line.lead ?? " "}${i < visible.length - 1 ? "\n" : ""}` }, i)) }) }), _jsx(Box, { flexDirection: "column", width: inner - 2, flexShrink: 0, children: visible.map((line, i) => {
271
+ const range = selectedIn(firstShown + i);
272
+ if (!range)
273
+ return render(line, firstShown + i, false);
274
+ const t = lineText(line) || " ";
275
+ return (_jsxs(Text, { wrap: "truncate-end", color: C.text, children: [t.slice(0, range[0]), _jsx(Text, { inverse: true, children: t.slice(range[0], range[1]) || " " }), t.slice(range[1])] }, firstShown + i));
276
+ }) })] })) }), _jsx(Scrollbar, { total: lines.length, top: firstShown, rows: rows, focused: focused })] }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), showMenu ? (_jsx(Box, { flexDirection: "column", children: matches.slice(menuStart, menuStart + menuRows).map((c, i) => {
277
+ const selected = menuStart + i === menuRow;
278
+ return (_jsxs(Text, { wrap: "truncate-end", ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸" : " " }), _jsxs(Text, { color: selected ? C.accent : C.text, bold: selected, children: ["/", c.name] }), c.args && inner >= 56 ? _jsxs(Text, { color: C.dim, children: [" ", c.args] }) : null /* narrow: the description matters more */, _jsxs(Text, { color: C.dim, children: [" ", c.description] })] }, c.name));
279
+ }) })) : (_jsx(Text, { wrap: "truncate-end", color: C.dim, children: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " })), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner + 1)) }), _jsx(ChatInput, { value: input, onChange: setInput, onSubmit: submit, focus: focused && !(approval && !input), width: inner + 1, history: sent, arrowsTaken: showMenu, placeholder: busy ? (inner >= 44 ? "Enter queues · Alt+Enter sends now · Esc stops" : "Enter queues · Esc stops") : showMenu ? "↑↓ choose · Tab complete · Enter run" : inner >= 44 ? "Ask anything · / for commands · ↑ history" : "Ask anything · / commands" })] }));
159
280
  });
@@ -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,16 @@ 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) {
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);
79
86
  setRecall(null);
80
- onSubmit(value);
87
+ onSubmit(value, key.meta);
81
88
  return;
82
89
  }
83
90
  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,25 +172,43 @@ 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
- 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)))] }) }));
109
185
  }
110
186
  const gutter = String(lines.length).length;
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 ? " double-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]);
@@ -329,6 +329,11 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
329
329
  const col = bufferCol(buf.lines[row], Math.max(0, mouse.x - gutter) + v.left);
330
330
  setHover(null);
331
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
+ }
332
337
  setBuf((b) => {
333
338
  if (mouse.kind === "press") {
334
339
  dragFrom.current = { row, col };
@@ -571,9 +576,11 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
571
576
  v.top = Math.max(0, Math.min(v.top, maxTop));
572
577
  const visible = buf.lines.slice(v.top, v.top + rows);
573
578
  const colored = useMemo(
574
- // Scrolled sideways: every line shifts by the same amount (plain text, since slicing highlighted
575
- // text could cut a color code in half).
576
- () => (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]);
577
584
  const { icon, color } = fileIcon(filePath);
578
585
  const byLine = useMemo(() => {
579
586
  const map = new Map();
@@ -594,7 +601,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
594
601
  const statusColor = confirmExit ? C.red : prompt ? C.accent : flash ? C.green : cursorProblem ? SEVERITY_STYLE[cursorProblem.severity].color : C.dim;
595
602
  const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
596
603
  const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
597
- 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) => {
598
605
  const n = v.top + i;
599
606
  const isCursorLine = n === buf.row;
600
607
  const problem = byLine.get(n + 1)?.[0];
@@ -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 } 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,10 +167,11 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
167
167
  else
168
168
  onOpen(node.path);
169
169
  }, [click?.nonce]);
170
+ const { frame } = useAnimation({ interval: 120, isActive: !!working?.size });
170
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"] })] })
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] });
174
+ : _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
175
  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
176
  const selected = start + i === cursor;
176
177
  const code = git.get(node.path);
@@ -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
  });