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