nou-cli 0.3.0 → 0.5.0
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/.env.example +1 -0
- package/README.md +26 -8
- package/dist/index.js +4 -5
- package/dist/src/agent/index.js +3 -2
- package/dist/src/config/index.js +2 -1
- package/dist/src/env.js +3 -0
- package/dist/src/providers/index.js +16 -0
- package/dist/src/providers/openai.js +7 -4
- package/dist/src/syntax/index.js +11 -2
- package/dist/src/ui/App.js +300 -70
- package/dist/src/ui/Chat.js +105 -49
- package/dist/src/ui/ChatInput.js +11 -2
- package/dist/src/ui/CodeView.js +42 -23
- package/dist/src/ui/Editor.js +471 -99
- package/dist/src/ui/FileTree.js +74 -42
- package/dist/src/ui/Header.js +3 -2
- package/dist/src/ui/ProblemsView.js +2 -2
- package/dist/src/ui/Settings.js +2 -4
- package/dist/src/ui/clipboard.js +59 -0
- package/dist/src/ui/fast-output.js +250 -0
- package/dist/src/ui/mouse.js +8 -3
- package/dist/src/ui/render-options.js +11 -0
- package/dist/src/ui/scroll.js +30 -0
- package/dist/src/ui/text.js +155 -0
- package/package.json +3 -2
package/dist/src/ui/Chat.js
CHANGED
|
@@ -1,45 +1,81 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
|
-
import { wrap } from "./text.js";
|
|
4
|
+
import { renderMarkdown, wrap } from "./text.js";
|
|
5
5
|
import { ChatInput, inputHeight } from "./ChatInput.js";
|
|
6
6
|
import { C, SPINNER } from "./theme.js";
|
|
7
7
|
import { Pane } from "./Pane.js";
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
import { Scrollbar, topFor } from "./scroll.js";
|
|
9
|
+
const BAR = "▌ ";
|
|
10
|
+
export const COMMANDS = ["/model", "/models", "/settings", "/auto", "/engine", "/copy", "/undo", "/rewind", "/history", "/problems", "/version", "/clear", "/help"];
|
|
11
|
+
/** Display lines for one message, wrapped to `inner` columns. */
|
|
12
|
+
function entryLines(entry, inner) {
|
|
13
|
+
const out = [];
|
|
14
|
+
if (entry.kind === "user") {
|
|
15
|
+
out.push({ text: "you", color: C.cyan, bold: true, lead: BAR, leadColor: C.cyan });
|
|
16
|
+
for (const line of wrap(entry.text, inner - 3))
|
|
17
|
+
out.push({ text: line, color: C.text, lead: BAR, leadColor: C.cyan });
|
|
18
|
+
out.push({ text: "" });
|
|
19
|
+
}
|
|
20
|
+
else if (entry.kind === "assistant") {
|
|
21
|
+
out.push({ text: "◆ nou", color: C.accent2, bold: true, lead: BAR, leadColor: C.accent2 });
|
|
22
|
+
for (const line of renderMarkdown(entry.text, inner - 3)) {
|
|
23
|
+
const color = line.kind === "heading" ? C.accent2 : line.kind === "quote" ? C.dim : undefined;
|
|
24
|
+
out.push({ text: "", spans: line.spans, ...(color ? { color } : {}), lead: BAR, leadColor: C.accent2 });
|
|
25
|
+
}
|
|
26
|
+
out.push({ text: "" });
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
const style = entry.kind === "tool"
|
|
30
|
+
? { icon: "⚙", color: C.dim }
|
|
31
|
+
: entry.kind === "error" ? { icon: "✖", color: C.red } : { icon: "ℹ", color: C.accent };
|
|
32
|
+
wrap(entry.text, inner - 4).forEach((line, i) => out.push({ text: ` ${line}`, color: style.color, lead: i === 0 ? ` ${style.icon}` : " ", leadColor: style.color }));
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
// Finished messages never change, so each is laid out once per width; only the reply being streamed
|
|
37
|
+
// is redone as it grows. Without this every streamed token re-rendered the whole conversation.
|
|
38
|
+
const laidOut = new WeakMap();
|
|
39
|
+
function cachedLines(entry, inner) {
|
|
40
|
+
const hit = laidOut.get(entry);
|
|
41
|
+
if (hit?.inner === inner)
|
|
42
|
+
return hit.lines;
|
|
43
|
+
const lines = entryLines(entry, inner);
|
|
44
|
+
laidOut.set(entry, { inner, lines });
|
|
45
|
+
return lines;
|
|
46
|
+
}
|
|
47
|
+
export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, onSubmit, onAnswer }) {
|
|
10
48
|
const [input, setInput] = useState("");
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (prefill)
|
|
51
|
+
setInput(prefill.text);
|
|
52
|
+
}, [prefill?.nonce]);
|
|
11
53
|
const [scrollBack, setScrollBack] = useState(0);
|
|
12
54
|
const { frame } = useAnimation({ interval: 80, isActive: !!busy });
|
|
13
|
-
const inner = width -
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return out;
|
|
40
|
-
}, [entries, streaming, inner]);
|
|
41
|
-
// New output snaps the view back to the bottom.
|
|
42
|
-
useEffect(() => setScrollBack(0), [entries.length, streaming]);
|
|
55
|
+
const inner = width - 3; // minus the borders and the scrollbar
|
|
56
|
+
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]);
|
|
58
|
+
// At the bottom, new output scrolls into view. Scrolled up to read, the view stays where it is
|
|
59
|
+
// (counted from the bottom, so it moves up by however many lines arrived) until you scroll back down.
|
|
60
|
+
const lastCount = useRef(lines.length);
|
|
61
|
+
const [unread, setUnread] = useState(0);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const added = lines.length - lastCount.current;
|
|
64
|
+
lastCount.current = lines.length;
|
|
65
|
+
if (added <= 0)
|
|
66
|
+
return;
|
|
67
|
+
setScrollBack((b) => (b > 0 ? b + added : 0));
|
|
68
|
+
setUnread((u) => (scrollBack > 0 ? u + added : 0));
|
|
69
|
+
}, [lines.length]);
|
|
70
|
+
// Sending a message always goes back to the bottom.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (entries[entries.length - 1]?.kind === "user")
|
|
73
|
+
setScrollBack(0);
|
|
74
|
+
}, [entries.length]);
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (scrollBack === 0)
|
|
77
|
+
setUnread(0);
|
|
78
|
+
}, [scrollBack]);
|
|
43
79
|
const footer = [];
|
|
44
80
|
if (approval?.kind === "command") {
|
|
45
81
|
footer.push({ text: "⚡ Run this command?", color: C.yellow, bold: true });
|
|
@@ -54,13 +90,13 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
54
90
|
footer.push({ text: " [y] allow [n] deny [Esc] stop", color: C.yellow });
|
|
55
91
|
}
|
|
56
92
|
else if (approval?.kind === "change") {
|
|
57
|
-
footer.push({ text: `✎
|
|
93
|
+
footer.push({ text: `✎ Nou wants to ${approval.change.isNew ? "create" : "edit"} ${approval.change.path}`, color: C.yellow, bold: true });
|
|
58
94
|
if (approvalNote) {
|
|
59
95
|
footer.push({ text: "⚠ This edit adds problems:", color: C.red, bold: true });
|
|
60
96
|
for (const line of wrap(approvalNote, inner - 2).slice(0, 6))
|
|
61
97
|
footer.push({ text: ` ${line}`, color: C.red });
|
|
62
98
|
}
|
|
63
|
-
footer.push({ text: " [y] accept [n] reject [Esc] stop", color: C.yellow });
|
|
99
|
+
footer.push({ text: " [y] accept [n] reject [o] show it [Esc] stop", color: C.yellow });
|
|
64
100
|
}
|
|
65
101
|
else if (busy) {
|
|
66
102
|
footer.push({ text: `${SPINNER[frame % SPINNER.length]} ${busy}`, color: C.accent2 });
|
|
@@ -68,21 +104,37 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
68
104
|
const suggestions = input.startsWith("/") && !input.includes(" ")
|
|
69
105
|
? COMMANDS.filter((c) => c.startsWith(input) && c !== input)
|
|
70
106
|
: [];
|
|
71
|
-
const inputRows = inputHeight(input, inner);
|
|
107
|
+
const inputRows = inputHeight(input, inner + 1);
|
|
72
108
|
const rows = Math.max(1, height - 3 - footer.length - 2 - inputRows);
|
|
73
|
-
const
|
|
109
|
+
const sent = useMemo(() => entries.filter((e) => e.kind === "user").map((e) => e.text), [entries]);
|
|
74
110
|
const maxBack = Math.max(0, lines.length - rows);
|
|
75
111
|
const back = Math.min(scrollBack, maxBack);
|
|
76
|
-
const
|
|
112
|
+
const firstShown = Math.max(0, lines.length - rows - back);
|
|
113
|
+
const visible = lines.slice(firstShown, lines.length - back);
|
|
77
114
|
useEffect(() => {
|
|
78
|
-
if (wheel)
|
|
79
|
-
setScrollBack((b) => Math.max(0, Math.min(maxBack, b - wheel.
|
|
115
|
+
if (wheel?.lines)
|
|
116
|
+
setScrollBack((b) => Math.max(0, Math.min(maxBack, Math.min(b, maxBack) - wheel.lines)));
|
|
80
117
|
}, [wheel?.nonce]);
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (scrollTo)
|
|
120
|
+
setScrollBack(maxBack - topFor(scrollTo.fraction, lines.length, rows));
|
|
121
|
+
}, [scrollTo?.nonce]);
|
|
122
|
+
// While something waits for approval, y/n into an empty input answer it; any other key starts a message.
|
|
123
|
+
useInput((typed) => {
|
|
124
|
+
if (/^[yYnN]$/.test(typed))
|
|
125
|
+
onAnswer(/[yY]/.test(typed));
|
|
126
|
+
else if (typed && typed >= " ")
|
|
127
|
+
setInput(typed); // printable, not a control key
|
|
128
|
+
}, { isActive: focused && !!approval && !input });
|
|
81
129
|
useInput((_input, key) => {
|
|
82
130
|
if (key.pageUp || (key.upArrow && key.shift))
|
|
83
|
-
setScrollBack((b) => Math.min(maxBack, b + Math.max(1, rows - 2)));
|
|
131
|
+
setScrollBack((b) => Math.min(maxBack, Math.min(b, maxBack) + Math.max(1, rows - 2)));
|
|
84
132
|
else if (key.pageDown || (key.downArrow && key.shift))
|
|
85
|
-
setScrollBack((b) => Math.max(0, b - Math.max(1, rows - 2)));
|
|
133
|
+
setScrollBack((b) => Math.max(0, Math.min(b, maxBack) - Math.max(1, rows - 2)));
|
|
134
|
+
else if (key.ctrl && key.home)
|
|
135
|
+
setScrollBack(maxBack);
|
|
136
|
+
else if (key.ctrl && key.end)
|
|
137
|
+
setScrollBack(0);
|
|
86
138
|
}, { isActive: focused });
|
|
87
139
|
const submit = (value) => {
|
|
88
140
|
if (!value.trim() || busy || approval)
|
|
@@ -92,12 +144,16 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
92
144
|
setInput("");
|
|
93
145
|
onSubmit(command);
|
|
94
146
|
};
|
|
95
|
-
const render = (line, key) => (_jsxs(Text, { wrap: "truncate-end", children: [line.
|
|
147
|
+
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)))
|
|
149
|
+
: _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
|
|
96
150
|
const empty = entries.length === 0 && !streaming;
|
|
97
151
|
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back
|
|
98
|
-
?
|
|
99
|
-
|
|
100
|
-
|
|
152
|
+
? unread
|
|
153
|
+
? _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
|
+
: _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
|
|
155
|
+
: maxBack > 0 ? _jsx(Text, { color: C.dim, children: " PgUp / wheel to scroll" }) : null] }));
|
|
156
|
+
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: empty ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Try:" }), _jsx(Text, { color: C.text, children: " \u201Cexplain this file\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cwhat changed since my last commit?\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cadd error handling here\u201D" }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "I can see the file you have open." })] })) : (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { children: visible.map((line, i) => _jsx(Text, { color: line.leadColor ?? C.dim, children: `${line.lead ?? " "}${i < visible.length - 1 ? "\n" : ""}` }, i)) }) }), _jsx(Box, { flexDirection: "column", width: inner - 2, flexShrink: 0, children: visible.map((line, i) => render(line, firstShown + i, false)) })] })) }), _jsx(Scrollbar, { total: lines.length, top: firstShown, rows: rows, focused: focused })] }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), _jsx(Text, { wrap: "truncate-end", color: C.dim, children: suggestions.length
|
|
101
157
|
? _jsxs(Text, { color: C.accent, children: [suggestions.join(" "), " ", _jsx(Text, { color: C.dim, children: suggestions.length === 1 ? "Enter ↵" : "" })] })
|
|
102
|
-
: 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)) }), _jsx(ChatInput, { value: input, onChange: setInput, onSubmit: submit, focus: focused && !approval, width: inner, history:
|
|
103
|
-
}
|
|
158
|
+
: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " }), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner + 1)) }), _jsx(ChatInput, { value: input, onChange: setInput, onSubmit: submit, focus: focused && !(approval && !input), width: inner + 1, history: sent, placeholder: busy ? "working… Esc to stop" : "Ask anything · / for commands · ↑ history" })] }));
|
|
159
|
+
});
|
package/dist/src/ui/ChatInput.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useRef, useState } from "react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
import { Box, Text, useInput, usePaste } from "ink";
|
|
4
4
|
import { C } from "./theme.js";
|
|
5
5
|
/** Word-wraps `text` into segments of at most `width` characters, honoring newlines. */
|
|
@@ -39,11 +39,20 @@ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder
|
|
|
39
39
|
const [cursor, setCursor] = useState(value.length);
|
|
40
40
|
const [recall, setRecall] = useState(null);
|
|
41
41
|
const stash = useRef("");
|
|
42
|
+
// Text put in from outside (e.g. "/" typed in another pane) puts the cursor at its end.
|
|
43
|
+
const own = useRef(value);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (value !== own.current)
|
|
46
|
+
setCursor(value.length);
|
|
47
|
+
own.current = value;
|
|
48
|
+
}, [value]);
|
|
42
49
|
const textWidth = Math.max(4, width - 2);
|
|
43
|
-
|
|
50
|
+
// Until the effect above catches up, text from outside puts the cursor at its end right away.
|
|
51
|
+
const pos = value !== own.current ? value.length : Math.min(cursor, value.length);
|
|
44
52
|
const segments = layout(value, textWidth);
|
|
45
53
|
const line = cursorLine(segments, pos);
|
|
46
54
|
const set = (next, nextCursor) => {
|
|
55
|
+
own.current = next;
|
|
47
56
|
onChange(next);
|
|
48
57
|
setCursor(Math.max(0, Math.min(next.length, nextCursor)));
|
|
49
58
|
};
|
package/dist/src/ui/CodeView.js
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { memo, useEffect, useMemo, 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 } from "./text.js";
|
|
6
|
+
import { highlightLines, sideBySide, 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
|
+
/** Columns ←/→ and Shift+wheel move sideways. */
|
|
11
|
+
const SIDE_STEP = 8;
|
|
12
|
+
export const CodeView = memo(function CodeView({ width, height, focused, filePath, content, diff, diffLabel, pending, marked, problems, focusLine, syntaxVersion, diffNote, wheel, scrollTo, onViewport }) {
|
|
10
13
|
const [top, setTop] = useState(0);
|
|
14
|
+
// Columns scrolled sideways. Scrolled lines show without colors (slicing colored text could cut a color code in half).
|
|
15
|
+
const [left, setLeft] = useState(0);
|
|
11
16
|
const rows = Math.max(1, height - 3);
|
|
12
|
-
const inner = width -
|
|
17
|
+
const inner = width - 3; // minus the borders and the scrollbar
|
|
13
18
|
const lines = useMemo(() => (filePath ? highlightLines(filePath, content) : []), [filePath, content, syntaxVersion]);
|
|
19
|
+
const plain = useMemo(() => (left > 0 ? splitLines(content) : []), [content, left > 0]);
|
|
14
20
|
const problemsByLine = useMemo(() => {
|
|
15
21
|
const map = new Map();
|
|
16
22
|
for (const d of problems)
|
|
@@ -25,6 +31,7 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
|
|
|
25
31
|
? diffRows.findIndex((r) => r.changed)
|
|
26
32
|
: marked.size ? Math.min(...marked) - 1 : 0;
|
|
27
33
|
setTop(Math.max(0, first - 3));
|
|
34
|
+
setLeft(0);
|
|
28
35
|
}, [filePath, diff, diffRows, marked]);
|
|
29
36
|
useEffect(() => {
|
|
30
37
|
if (focusLine && !diff)
|
|
@@ -33,9 +40,17 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
|
|
|
33
40
|
const maxTop = Math.max(0, total - rows);
|
|
34
41
|
const start = Math.min(top, maxTop);
|
|
35
42
|
useEffect(() => {
|
|
36
|
-
if (wheel)
|
|
37
|
-
|
|
43
|
+
if (!wheel)
|
|
44
|
+
return;
|
|
45
|
+
if (wheel.lines)
|
|
46
|
+
setTop((t) => Math.max(0, Math.min(maxTop, Math.min(t, maxTop) + wheel.lines)));
|
|
47
|
+
if (wheel.columns)
|
|
48
|
+
setLeft((l) => Math.max(0, l + wheel.columns * 2));
|
|
38
49
|
}, [wheel?.nonce]);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (scrollTo)
|
|
52
|
+
setTop(topFor(scrollTo.fraction, total, rows));
|
|
53
|
+
}, [scrollTo?.nonce]);
|
|
39
54
|
useEffect(() => {
|
|
40
55
|
if (filePath && !diff)
|
|
41
56
|
onViewport(start + 1, Math.min(total, start + rows));
|
|
@@ -45,15 +60,19 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
|
|
|
45
60
|
setTop((t) => Math.max(0, Math.min(t, maxTop) - 1));
|
|
46
61
|
else if (key.downArrow || input === "j")
|
|
47
62
|
setTop((t) => Math.min(maxTop, t + 1));
|
|
48
|
-
else if (key.pageUp
|
|
63
|
+
else if (key.pageUp)
|
|
49
64
|
setTop((t) => Math.max(0, Math.min(t, maxTop) - rows));
|
|
50
65
|
else if (key.pageDown || input === "d" || input === " ")
|
|
51
66
|
setTop((t) => Math.min(maxTop, t + rows));
|
|
52
|
-
else if (input === "g")
|
|
67
|
+
else if (input === "g" || key.home)
|
|
53
68
|
setTop(0);
|
|
54
|
-
else if (input === "G")
|
|
69
|
+
else if (input === "G" || key.end)
|
|
55
70
|
setTop(maxTop);
|
|
56
|
-
else if (
|
|
71
|
+
else if (key.leftArrow || input === "h")
|
|
72
|
+
setLeft((l) => Math.max(0, l - SIDE_STEP));
|
|
73
|
+
else if (key.rightArrow || input === "l")
|
|
74
|
+
setLeft((l) => l + SIDE_STEP);
|
|
75
|
+
else if (diff && !pending && (input === "n" || input === "N")) {
|
|
57
76
|
// Jump between change hunks.
|
|
58
77
|
const starts = diffRows.flatMap((r, i) => (r.changed && !diffRows[i - 1]?.changed ? [i] : []));
|
|
59
78
|
const target = input === "n" ? starts.find((s) => s - 3 > start) : [...starts].reverse().find((s) => s - 3 < start);
|
|
@@ -61,7 +80,7 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
|
|
|
61
80
|
setTop(Math.max(0, Math.min(maxTop, target - 3)));
|
|
62
81
|
}
|
|
63
82
|
}, { isActive: focused });
|
|
64
|
-
const scroll = total > rows ? ` ${start + 1}-${Math.min(total, start + rows)}/${total}` : "";
|
|
83
|
+
const scroll = (total > rows ? ` ${start + 1}-${Math.min(total, start + rows)}/${total}` : "") + (left ? ` →${left}` : "");
|
|
65
84
|
const percent = total > rows ? Math.round(((start + rows) / total) * 100) : 100;
|
|
66
85
|
if (diff) {
|
|
67
86
|
const half = Math.floor((inner - 1) / 2);
|
|
@@ -72,11 +91,11 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
|
|
|
72
91
|
if (!c)
|
|
73
92
|
return _jsx(Box, { width: w, children: _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "╱".repeat(Math.max(0, w)) }) });
|
|
74
93
|
const color = changed ? (side === "-" ? C.red : C.green) : undefined;
|
|
75
|
-
return (_jsx(Box, { width: w, children: _jsxs(Text, { wrap: "truncate-end", ...(color ? { color } : { color: C.dim }), children: [String(c.n).padStart(gutter - 1), changed ? side : " ", c.text || " "] }) }));
|
|
94
|
+
return (_jsx(Box, { width: w, children: _jsxs(Text, { wrap: "truncate-end", ...(color ? { color } : { color: C.dim }), children: [String(c.n).padStart(gutter - 1), changed ? side : " ", c.text.slice(left) || " "] }) }));
|
|
76
95
|
};
|
|
77
96
|
const { icon, color } = fileIcon(diff.path);
|
|
78
97
|
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", diff.path, " ", _jsx(Text, { backgroundColor: pending ? C.yellow : C.accent2, color: "black", children: pending ? (diff.isNew ? " NEW FILE " : " PROPOSED ") : ` ${diffLabel.toUpperCase()} ` }), " ", _jsxs(Text, { color: C.green, children: ["+", added] }), " ", _jsxs(Text, { color: C.red, children: ["-", removed] }), diffNote ? _jsxs(Text, { color: diffNote.startsWith("✓") ? C.green : C.red, children: [" ", diffNote] }) : null, _jsxs(Text, { color: C.dim, children: [pending ? " y accept · n reject" : " n/N next/prev change", scroll] })] }));
|
|
79
|
-
return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, ...(pending ? { accent: C.yellow } : {}), children: _jsx(Box, { flexDirection: "column", children: diffRows.slice(start, start + rows).map((row, i) => (_jsxs(Box, { flexDirection: "row", children: [cell(row.left, "-", row.changed, half), _jsx(Text, { color: C.dim, children: "\u2502" }), cell(row.right, "+", row.changed, inner - half - 1)] }, start + i))) }) }));
|
|
98
|
+
return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, ...(pending ? { accent: C.yellow } : {}), children: _jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: diffRows.slice(start, start + rows).map((row, i) => (_jsxs(Box, { flexDirection: "row", children: [cell(row.left, "-", row.changed, half), _jsx(Text, { color: C.dim, children: "\u2502" }), cell(row.right, "+", row.changed, inner - half - 1)] }, start + i))) }), _jsx(Scrollbar, { total: total, top: start, rows: rows, focused: focused })] }) }));
|
|
80
99
|
}
|
|
81
100
|
if (!filePath) {
|
|
82
101
|
const tips = [
|
|
@@ -92,13 +111,13 @@ export function CodeView({ width, height, focused, filePath, content, diff, diff
|
|
|
92
111
|
const { icon, color } = fileIcon(filePath);
|
|
93
112
|
const errorCount = problems.filter((d) => d.severity === "error").length;
|
|
94
113
|
const warningCount = problems.filter((d) => d.severity === "warning").length;
|
|
95
|
-
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, errorCount > 0 && _jsxs(Text, { color: C.red, children: [" \u2716 ", errorCount] }), warningCount > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", warningCount] }), _jsxs(Text, { color: C.dim, children: [scroll, total > rows ? ` (${percent}%)` : "", focused ? " e edit · v diff" : ""] })] }));
|
|
96
|
-
return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, children: _jsx(Box, { flexDirection: "column", children: lines.slice(start, start + rows).map((line, i) => {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
114
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", filePath, errorCount > 0 && _jsxs(Text, { color: C.red, children: [" \u2716 ", errorCount] }), warningCount > 0 && _jsxs(Text, { color: C.yellow, children: [" \u25B2 ", warningCount] }), _jsxs(Text, { color: C.dim, children: [scroll, total > rows ? ` (${percent}%)` : "", focused ? " click or e to edit · v diff" : ""] })] }));
|
|
115
|
+
return (_jsx(Pane, { title: title, width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: lines.slice(start, start + rows).map((line, i) => {
|
|
116
|
+
const n = start + i + 1;
|
|
117
|
+
const isMarked = marked.has(n);
|
|
118
|
+
const here = problemsByLine.get(n);
|
|
119
|
+
const worst = here?.[0];
|
|
120
|
+
const style = worst ? SEVERITY_STYLE[worst.severity] : null;
|
|
121
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: style?.color ?? (isMarked ? C.green : C.dim), children: String(n).padStart(gutter) }), style ? _jsx(Text, { color: style.color, children: style.icon }) : _jsx(Text, { color: isMarked ? C.green : C.dim, children: isMarked ? "▎" : " " }), (left ? plain[start + i]?.slice(left) : line) || " ", worst && style ? _jsxs(Text, { color: style.color, dimColor: true, children: [" ", style.icon, " ", worst.message, here.length > 1 ? ` (+${here.length - 1})` : ""] }) : null] }, n));
|
|
122
|
+
}) }), _jsx(Scrollbar, { total: total, top: start, rows: rows, focused: focused })] }) }));
|
|
123
|
+
});
|