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.
- package/README.md +48 -9
- package/dist/index.js +9 -99
- package/dist/src/agent/index.js +199 -33
- package/dist/src/cli.js +117 -0
- package/dist/src/commands/index.js +233 -25
- package/dist/src/config/index.js +14 -0
- package/dist/src/context/index.js +33 -10
- package/dist/src/conversations/external.js +165 -0
- package/dist/src/conversations/index.js +71 -0
- package/dist/src/engines/claude.js +10 -0
- package/dist/src/engines/codex.js +60 -5
- package/dist/src/games/index.js +217 -0
- package/dist/src/mcp/import.js +106 -0
- package/dist/src/mcp/index.js +184 -0
- package/dist/src/providers/anthropic.js +23 -3
- package/dist/src/providers/index.js +13 -0
- package/dist/src/providers/openai.js +6 -2
- package/dist/src/syntax/index.js +10 -20
- package/dist/src/syntax/themes.js +57 -0
- package/dist/src/terminal/index.js +127 -0
- package/dist/src/tools/index.js +86 -2
- package/dist/src/ui/App.js +900 -118
- package/dist/src/ui/Chat.js +143 -22
- package/dist/src/ui/ChatInput.js +9 -2
- package/dist/src/ui/CodeView.js +111 -20
- package/dist/src/ui/Editor.js +13 -6
- package/dist/src/ui/FileTree.js +6 -5
- package/dist/src/ui/GamePanel.js +99 -0
- package/dist/src/ui/Header.js +1 -1
- package/dist/src/ui/QuickOpen.js +51 -12
- package/dist/src/ui/Settings.js +58 -13
- package/dist/src/ui/Tabs.js +56 -0
- package/dist/src/ui/Terminal.js +84 -0
- package/dist/src/ui/clipboard.js +9 -7
- package/dist/src/ui/commands.js +36 -0
- package/dist/src/ui/definition.js +135 -0
- package/dist/src/ui/fast-width.js +62 -0
- package/dist/src/ui/mouse.js +4 -1
- package/dist/src/ui/render-options.js +4 -0
- package/dist/src/ui/text.js +132 -12
- package/dist/src/ui/width-shim.js +7 -0
- package/package.json +2 -1
package/dist/src/ui/Chat.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
105
|
-
|
|
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
|
-
|
|
140
|
-
if (
|
|
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
|
-
|
|
143
|
-
|
|
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(
|
|
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) =>
|
|
157
|
-
|
|
158
|
-
|
|
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
|
});
|
package/dist/src/ui/ChatInput.js
CHANGED
|
@@ -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)
|
package/dist/src/ui/CodeView.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
17
|
+
// Columns scrolled sideways (colors are kept: see sliceAnsi).
|
|
15
18
|
const [left, setLeft] = useState(0);
|
|
16
|
-
|
|
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
|
-
["
|
|
103
|
-
["
|
|
104
|
-
["
|
|
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
|
-
["
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
});
|
package/dist/src/ui/Editor.js
CHANGED
|
@@ -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
|
|
575
|
-
|
|
576
|
-
|
|
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];
|
package/dist/src/ui/FileTree.js
CHANGED
|
@@ -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 ?
|
|
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
|
});
|