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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { memo, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { GAMES, gameById } from "../games/index.js";
|
|
5
|
+
import { C } from "./theme.js";
|
|
6
|
+
/** The panel's tallest height (borders included) when docked under a pane, and the least it needs. */
|
|
7
|
+
export const GAME_PANEL = { height: 19, minWidth: 26, minHeight: 10 };
|
|
8
|
+
const KEYS = { w: "up", s: "down", a: "left", d: "right", k: "up", j: "down", h: "left", l: "right" };
|
|
9
|
+
/** A row of the playfield, colored per character in runs so it stays a handful of Text nodes. */
|
|
10
|
+
function Row({ line, colors }) {
|
|
11
|
+
const runs = [];
|
|
12
|
+
for (const ch of line) {
|
|
13
|
+
const color = colors[ch];
|
|
14
|
+
const last = runs[runs.length - 1];
|
|
15
|
+
if (last && last.color === color)
|
|
16
|
+
last.text += ch;
|
|
17
|
+
else
|
|
18
|
+
runs.push({ text: ch, color });
|
|
19
|
+
}
|
|
20
|
+
return _jsx(Text, { children: runs.map((r, i) => _jsx(Text, { ...(r.color ? { color: r.color } : {}), children: r.text }, i)) });
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A panel for little games, docked under a pane (drag its title to another pane; × closes it). The app gives it
|
|
24
|
+
* the keyboard while it's focused; a game only runs while it has the keyboard, so it pauses as you leave.
|
|
25
|
+
*/
|
|
26
|
+
export const GamePanel = memo(function GamePanel({ width, height, focused, moving, installed, onLeave, onOpenSettings }) {
|
|
27
|
+
const FIELD = { width: Math.max(10, width - 4), height: Math.max(3, height - 6) };
|
|
28
|
+
const games = installed.map(gameById).filter((g) => !!g);
|
|
29
|
+
const [row, setRow] = useState(0);
|
|
30
|
+
const [playing, setPlaying] = useState(null);
|
|
31
|
+
const [paused, setPaused] = useState(false);
|
|
32
|
+
const inputs = useRef([]);
|
|
33
|
+
const view = playing ? playing.game.view(playing.state) : null;
|
|
34
|
+
const running = !!playing && focused && !paused && !view?.over;
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!running)
|
|
37
|
+
return;
|
|
38
|
+
const timer = setInterval(() => {
|
|
39
|
+
const pressed = inputs.current;
|
|
40
|
+
inputs.current = [];
|
|
41
|
+
setPlaying((p) => (p ? { ...p, state: p.game.tick(p.state, pressed) } : p));
|
|
42
|
+
}, playing.game.tickMs);
|
|
43
|
+
return () => clearInterval(timer);
|
|
44
|
+
}, [running, playing?.game]);
|
|
45
|
+
// A different size (moved to another pane, or the window changed) starts the game over at that size.
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (playing)
|
|
48
|
+
setPlaying({ game: playing.game, state: playing.game.create(FIELD.width, FIELD.height) });
|
|
49
|
+
}, [FIELD.width, FIELD.height]);
|
|
50
|
+
const start = (game) => {
|
|
51
|
+
inputs.current = [];
|
|
52
|
+
setPaused(false);
|
|
53
|
+
setPlaying({ game, state: game.create(FIELD.width, FIELD.height) });
|
|
54
|
+
};
|
|
55
|
+
useInput((input, key) => {
|
|
56
|
+
if (key.escape)
|
|
57
|
+
return onLeave();
|
|
58
|
+
if (!playing) {
|
|
59
|
+
if (key.upArrow || input === "k")
|
|
60
|
+
setRow((r) => Math.max(0, r - 1));
|
|
61
|
+
else if (key.downArrow || input === "j")
|
|
62
|
+
setRow((r) => Math.min(games.length - 1, r + 1));
|
|
63
|
+
else if ((key.return || input === " ") && games[row])
|
|
64
|
+
start(games[row]);
|
|
65
|
+
else if (input === "s" && !games.length)
|
|
66
|
+
onOpenSettings();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (input === "q")
|
|
70
|
+
return setPlaying(null);
|
|
71
|
+
if (view?.over) {
|
|
72
|
+
if (key.return || input === " " || input === "r")
|
|
73
|
+
start(playing.game);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (input === " " || input === "p")
|
|
77
|
+
return setPaused((p) => !p);
|
|
78
|
+
const pressed = key.upArrow ? "up" : key.downArrow ? "down" : key.leftArrow ? "left" : key.rightArrow ? "right" : KEYS[input];
|
|
79
|
+
if (pressed)
|
|
80
|
+
inputs.current.push(pressed);
|
|
81
|
+
}, { isActive: focused });
|
|
82
|
+
const color = focused || moving ? C.accent2 : C.dim;
|
|
83
|
+
const inner = width - 2;
|
|
84
|
+
const title = moving ? " ✦ Drop it on a pane: files, code or chat" : playing ? ` ✦ ${playing.game.title} ⠿ drag to move` : " ✦ Games ⠿ drag to move";
|
|
85
|
+
// The title row: the name and the drag handle, and × (close) at the right.
|
|
86
|
+
const head = title.length > inner - 3 ? `${title.slice(0, inner - 4)}…` : title.padEnd(inner - 3);
|
|
87
|
+
const hint = !focused ? "click to play" : playing ? (view?.over ? "Enter again · q games · Esc leave" : `${paused ? "PAUSED · " : ""}Space pause · q games · Esc leave`) : "↑↓ choose · Enter play · Esc leave";
|
|
88
|
+
const small = width < GAME_PANEL.minWidth || height < GAME_PANEL.minHeight;
|
|
89
|
+
return (_jsxs(Box, { width: width, height: height, flexDirection: "column", borderStyle: "round", borderColor: color, overflow: "hidden", children: [_jsxs(Text, { backgroundColor: C.panel, wrap: "truncate-end", children: [_jsx(Text, { color: color, bold: true, children: head }), _jsx(Text, { color: C.text, bold: true, children: " \u00D7 " })] }), small ? (_jsx(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: _jsx(Text, { color: C.dim, wrap: "wrap", children: "Too small here: drag me to a bigger pane, or make this one wider." }) })) : null, small ? null : playing && view ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: [view.lines.map((line, i) => {
|
|
90
|
+
// The end-of-game message goes over the middle of the field.
|
|
91
|
+
if (view.over && i === Math.floor(FIELD.height / 2)) {
|
|
92
|
+
const msg = ` ${view.over} `;
|
|
93
|
+
const left = Math.max(0, Math.floor((FIELD.width - msg.length) / 2));
|
|
94
|
+
return _jsxs(Text, { children: [line.slice(0, left), _jsx(Text, { backgroundColor: C.yellow, color: "black", bold: true, children: msg }), line.slice(left + msg.length)] }, i);
|
|
95
|
+
}
|
|
96
|
+
return _jsx(Row, { line: line, colors: playing.game.colors }, i);
|
|
97
|
+
}), _jsx(Text, { color: C.dim, children: "─".repeat(FIELD.width) }), _jsx(Text, { color: C.text, wrap: "truncate-end", children: view.status })] })) : (_jsx(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: games.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.text, children: "No games yet." }), _jsxs(Text, { color: C.dim, wrap: "wrap", children: ["Add some in settings \u2192 Games", focused ? " (press s)" : "", ": Pong, Snake and Breakout."] })] })) : games.map((g, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { ...(i === row && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: i === row ? C.accent2 : C.dim, children: i === row ? "▸ " : " " }), _jsx(Text, { color: C.text, bold: i === row, children: g.title }), _jsxs(Text, { color: C.dim, children: [" ", g.controls] })] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", g.blurb] })] }, g.id))) })), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", hint] })] }));
|
|
98
|
+
});
|
|
99
|
+
export { GAMES };
|
package/dist/src/ui/Header.js
CHANGED
|
@@ -8,5 +8,5 @@ const VERSION = `v${currentVersion()}`;
|
|
|
8
8
|
export const Header = memo(function Header({ width, branch, changes, model, local, busy, update }) {
|
|
9
9
|
const { frame } = useAnimation({ interval: 120, isActive: !!busy });
|
|
10
10
|
const project = path.basename(process.cwd());
|
|
11
|
-
return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsxs(Text, { color: C.dim, children: [" ", VERSION] }), _jsx(Text, { color: C.dim, children: " \u2502 " }), _jsx(Text, { color: C.text, children: project }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] })), busy && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " ", busy] })] }), _jsxs(Text, { children: [update && _jsxs(Text, { color: C.green, bold: true, children: ["\u2B06 v", update, " available \u00B7 run `nou update` "] }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: "black", bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] })] }));
|
|
11
|
+
return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, flexGrow: 1, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate-end", children: [" ", gradient("◆ NOU", busy ? frame : 0).map((g, i) => _jsx(Text, { bold: true, color: g.color, children: g.ch }, i)), _jsxs(Text, { color: C.dim, children: [" ", VERSION] }), _jsx(Text, { color: C.dim, children: " \u2502 " }), _jsx(Text, { color: C.text, children: project }), branch && (_jsxs(_Fragment, { children: [_jsx(Text, { color: C.dim, children: " \u2502 " }), _jsxs(Text, { color: C.accent2, children: ["\u2387 ", branch] }), changes > 0 && _jsxs(Text, { color: C.yellow, children: [" \u00B1", changes] })] })), busy && _jsxs(Text, { color: C.accent2, children: [" ", SPINNER[frame % SPINNER.length], " ", busy] })] }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate-end", children: [update && width >= 110 && _jsxs(Text, { color: C.green, bold: true, children: ["\u2B06 v", update, " available \u00B7 run `nou update` "] }), _jsx(Text, { backgroundColor: local ? C.green : C.accent2, color: "black", bold: true, children: ` ${local ? "⌂ LOCAL" : "☁ CLOUD"} ` }), _jsx(Text, { backgroundColor: C.panel, color: C.text, children: ` ${model} ` }), " "] }) })] }));
|
|
12
12
|
});
|
package/dist/src/ui/QuickOpen.js
CHANGED
|
@@ -32,7 +32,7 @@ export function listAllFiles(limit = 5000) {
|
|
|
32
32
|
return files;
|
|
33
33
|
}
|
|
34
34
|
/** Subsequence fuzzy match; higher is better, null if no match. Returns matched indexes for highlighting. */
|
|
35
|
-
function score(file, query) {
|
|
35
|
+
export function score(file, query) {
|
|
36
36
|
if (!query)
|
|
37
37
|
return { score: 0, hits: [] };
|
|
38
38
|
const lower = file.toLowerCase();
|
|
@@ -57,35 +57,74 @@ function score(file, query) {
|
|
|
57
57
|
}
|
|
58
58
|
return { score: total - file.length * 0.05, hits };
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
|
|
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 = "", onPick, onRun, onClose }) {
|
|
62
|
+
const [query, setQuery] = useState(initialQuery);
|
|
62
63
|
const [cursor, setCursor] = useState(0);
|
|
63
64
|
const boxWidth = Math.min(80, Math.max(40, width - 10));
|
|
64
65
|
const inner = boxWidth - 2; // fill every cell so the screen underneath doesn't show through
|
|
66
|
+
const commandMode = query.startsWith(">");
|
|
67
|
+
const rowsShown = 12;
|
|
65
68
|
const results = useMemo(() => {
|
|
69
|
+
if (commandMode)
|
|
70
|
+
return [];
|
|
71
|
+
// Recent files first when nothing's typed; a small boost otherwise, so the one you just had wins a tie.
|
|
72
|
+
const rank = new Map(recent.map((f, i) => [f, i]));
|
|
73
|
+
const boost = (f) => (rank.has(f) ? (query ? 3 : 1000) - rank.get(f) * 0.01 : 0);
|
|
66
74
|
return files
|
|
67
75
|
.map((f) => ({ file: f, m: score(f, query) }))
|
|
68
76
|
.filter((r) => r.m !== null)
|
|
69
|
-
.sort((a, b) => b.m.score - a.m.score)
|
|
70
|
-
.slice(0,
|
|
71
|
-
}, [files, query]);
|
|
77
|
+
.sort((a, b) => b.m.score + boost(b.file) - (a.m.score + boost(a.file)))
|
|
78
|
+
.slice(0, rowsShown);
|
|
79
|
+
}, [files, recent, query, commandMode]);
|
|
80
|
+
const found = useMemo(() => {
|
|
81
|
+
if (!commandMode)
|
|
82
|
+
return [];
|
|
83
|
+
const q = query.slice(1).trim();
|
|
84
|
+
return actions
|
|
85
|
+
.map((a, order) => ({ action: a, m: score(a.title, q), order }))
|
|
86
|
+
.filter((r) => r.m !== null)
|
|
87
|
+
.sort((a, b) => (q ? b.m.score - a.m.score : a.order - b.order));
|
|
88
|
+
}, [actions, query, commandMode]);
|
|
89
|
+
const count = commandMode ? found.length : results.length;
|
|
90
|
+
const first = Math.max(0, Math.min(cursor - rowsShown + 1, count - rowsShown));
|
|
72
91
|
useInput((_input, key) => {
|
|
73
92
|
if (key.escape)
|
|
74
93
|
onClose();
|
|
75
94
|
else if (key.upArrow)
|
|
76
|
-
setCursor((c) => Math.max(
|
|
95
|
+
setCursor((c) => (c - 1 + Math.max(1, count)) % Math.max(1, count));
|
|
77
96
|
else if (key.downArrow)
|
|
78
|
-
setCursor((c) =>
|
|
97
|
+
setCursor((c) => (c + 1) % Math.max(1, count));
|
|
98
|
+
else if (key.pageUp)
|
|
99
|
+
setCursor((c) => Math.max(0, c - rowsShown));
|
|
100
|
+
else if (key.pageDown)
|
|
101
|
+
setCursor((c) => Math.min(count - 1, c + rowsShown));
|
|
79
102
|
});
|
|
103
|
+
const recentSet = useMemo(() => new Set(recent), [recent]);
|
|
80
104
|
const pad = (s) => (s.length > inner ? `…${s.slice(-(inner - 1))}` : s.padEnd(inner));
|
|
81
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) => {
|
|
82
106
|
setQuery(v);
|
|
83
107
|
setCursor(0);
|
|
84
|
-
}, onSubmit: () =>
|
|
108
|
+
}, onSubmit: () => {
|
|
109
|
+
if (commandMode) {
|
|
110
|
+
if (found[cursor])
|
|
111
|
+
onRun(found[cursor].action);
|
|
112
|
+
}
|
|
113
|
+
else if (results[cursor])
|
|
114
|
+
onPick(results[cursor].file);
|
|
115
|
+
}, placeholder: "Go to file\u2026 (> for commands)" }), _jsx(Text, { children: " ".repeat(Math.max(0, inner - 4 - Math.max(query.length, 29))) })] }), _jsx(Text, { color: C.dim, children: "─".repeat(inner) }), commandMode && found.length === 0 && _jsx(Text, { color: C.dim, children: pad(" No matching commands") }), commandMode && found.slice(first, first + rowsShown).map((r, i) => {
|
|
116
|
+
const selected = first + i === cursor;
|
|
117
|
+
const keys = r.action.keys ?? "";
|
|
118
|
+
const room = inner - 3 - keys.length - 1;
|
|
119
|
+
const title = r.action.title.length > room ? `${r.action.title.slice(0, room - 1)}…` : r.action.title.padEnd(room);
|
|
120
|
+
const hits = new Set(r.m.hits);
|
|
121
|
+
return (_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▌" : " " }), _jsx(Text, { color: C.accent2, children: "\u203A " }), [...title].map((ch, j) => (_jsx(Text, { color: hits.has(j) ? C.yellow : selected ? C.text : C.dim, bold: hits.has(j), children: ch }, j))), _jsxs(Text, { color: C.accent, children: [" ", keys] })] }, r.action.title));
|
|
122
|
+
}), !commandMode && results.length === 0 && _jsx(Text, { color: C.dim, children: pad(" No matching files") }), results.map((r, i) => {
|
|
85
123
|
const selected = i === cursor;
|
|
86
124
|
const { icon, color } = fileIcon(r.file);
|
|
87
|
-
const
|
|
125
|
+
const tag = recentSet.has(r.file) ? " recent" : "";
|
|
126
|
+
const label = pad(` ${r.file}`).slice(0, inner - 2 - tag.length);
|
|
88
127
|
const hits = new Set(r.m.hits.map((h) => h + 1));
|
|
89
|
-
return (_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▌" : " " }), _jsx(Text, { color: color, children: icon }), [...label].map((ch, j) => (_jsx(Text, { color: hits.has(j) ? C.yellow : selected ? C.text : C.dim, bold: hits.has(j), children: ch }, j)))] }, r.file));
|
|
90
|
-
}), _jsx(Text, { color: C.dim, children: pad(" ↑↓ select · Enter open · Esc close") })] }));
|
|
128
|
+
return (_jsxs(Text, { ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▌" : " " }), _jsx(Text, { color: color, children: icon }), [...label].map((ch, j) => (_jsx(Text, { color: hits.has(j) ? C.yellow : selected ? C.text : C.dim, bold: hits.has(j), children: ch }, j))), _jsx(Text, { color: C.dim, children: tag })] }, r.file));
|
|
129
|
+
}), _jsx(Text, { color: C.dim, children: pad(commandMode ? ` ↑↓ select · Enter run · Esc close ${found.length} commands` : " ↑↓ select · Enter open · > commands · Esc close") })] }));
|
|
91
130
|
}
|
package/dist/src/ui/Settings.js
CHANGED
|
@@ -2,7 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
3
|
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
4
|
import TextInput from "ink-text-input";
|
|
5
|
-
import { AUTO_SAVE, EFFORTS } from "../config/index.js";
|
|
5
|
+
import { AUTO_SAVE, CODE_THEMES, EFFORTS, FOLLOW_SPEEDS } from "../config/index.js";
|
|
6
|
+
import { highlightLines } from "./text.js";
|
|
7
|
+
import { GAMES } from "../games/index.js";
|
|
6
8
|
import { saveCredentials } from "../config/credentials.js";
|
|
7
9
|
import { originOf, setOutsideNou } from "../config/origins.js";
|
|
8
10
|
import { PLUGIN_DIRS, isEnabled, trustState } from "../plugins/index.js";
|
|
@@ -10,9 +12,16 @@ import { currentVersion } from "../update/index.js";
|
|
|
10
12
|
import { describe, providers, rejectedKey } from "../providers/index.js";
|
|
11
13
|
import { ENGINES, isInstalled } from "../engines/index.js";
|
|
12
14
|
import { C, SPINNER } from "./theme.js";
|
|
13
|
-
|
|
15
|
+
/** A few lines of code to show what a code theme looks like. */
|
|
16
|
+
const THEME_PREVIEW = `// Greets someone, politely
|
|
17
|
+
export function greet(name: string, times = 2): string {
|
|
18
|
+
return \`Hello \${name}!\`.repeat(times) || null;
|
|
19
|
+
}`;
|
|
20
|
+
const TABS = ["Model", "Engine", "Preferences", "Plugins", "Games"];
|
|
14
21
|
const PREFS = [
|
|
15
22
|
{ key: "effort", label: "Reasoning effort", help: "How hard Claude thinks (low → max). Higher is smarter but slower and pricier." },
|
|
23
|
+
{ key: "followSpeed", label: "Follow mode speed", help: "How long each file Nou reads stays on screen in follow mode (f): slow, normal, fast, or as it happens." },
|
|
24
|
+
{ key: "codeTheme", label: "Code colors", help: "The theme for code. ←/→ to try them; github-light is for light terminals." },
|
|
16
25
|
{ key: "autoSave", label: "Auto-save", help: "delay: a second after you stop typing · focus: when you leave the editor · off: only Ctrl+S." },
|
|
17
26
|
{ key: "mouse", label: "Mouse", help: "Wheel scrolls the pane under the pointer; click to focus or open files. Hold Shift to select text." },
|
|
18
27
|
{ key: "followMode", label: "Follow mode on start", help: "The code view jumps to files Nou reads and edits." },
|
|
@@ -21,7 +30,7 @@ const PREFS = [
|
|
|
21
30
|
{ key: "autoApproveEdits", label: "Auto-approve edits", help: "Apply file edits without asking (diffs are still shown).", warn: true },
|
|
22
31
|
{ key: "autoApproveCommands", label: "Auto-approve commands", help: "Run shell commands without asking. Only turn on if you trust the model.", warn: true },
|
|
23
32
|
];
|
|
24
|
-
export function Settings({ width, height, settings, choice, plugins, connectTo, onModel, onEngine, onChange, onTogglePlugin, onRescanPlugins, onClose }) {
|
|
33
|
+
export function Settings({ width, height, settings, choice, plugins, connectTo, reason, onModel, onEngine, onChange, onTogglePlugin, onRescanPlugins, onClose }) {
|
|
25
34
|
const [tab, setTab] = useState("Model");
|
|
26
35
|
const [row, setRow] = useState(() => Math.max(0, providers.indexOf(connectTo ?? choice.provider)));
|
|
27
36
|
const [column, setColumn] = useState("providers");
|
|
@@ -72,7 +81,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
72
81
|
}
|
|
73
82
|
};
|
|
74
83
|
const modelItems = Array.isArray(listed) ? listed : [];
|
|
75
|
-
const rowsFor = (t) => (t === "Model" ? providers.length : t === "Engine" ? ENGINES.length : t === "Preferences" ? PREFS.length : plugins.length);
|
|
84
|
+
const rowsFor = (t) => (t === "Model" ? providers.length : t === "Engine" ? ENGINES.length : t === "Preferences" ? PREFS.length : t === "Games" ? GAMES.length : plugins.length);
|
|
76
85
|
useInput((input, key) => {
|
|
77
86
|
if (connecting) {
|
|
78
87
|
if (key.escape)
|
|
@@ -142,6 +151,12 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
142
151
|
if (pref.key === "effort") {
|
|
143
152
|
onChange({ effort: EFFORTS[(EFFORTS.indexOf(settings.effort) + step + EFFORTS.length) % EFFORTS.length] });
|
|
144
153
|
}
|
|
154
|
+
else if (pref.key === "followSpeed") {
|
|
155
|
+
onChange({ followSpeed: FOLLOW_SPEEDS[(FOLLOW_SPEEDS.indexOf(settings.followSpeed) + step + FOLLOW_SPEEDS.length) % FOLLOW_SPEEDS.length] });
|
|
156
|
+
}
|
|
157
|
+
else if (pref.key === "codeTheme") {
|
|
158
|
+
onChange({ codeTheme: CODE_THEMES[(CODE_THEMES.indexOf(settings.codeTheme) + step + CODE_THEMES.length) % CODE_THEMES.length] });
|
|
159
|
+
}
|
|
145
160
|
else if (pref.key === "autoSave") {
|
|
146
161
|
onChange({ autoSave: AUTO_SAVE[(AUTO_SAVE.indexOf(settings.autoSave) + step + AUTO_SAVE.length) % AUTO_SAVE.length] });
|
|
147
162
|
}
|
|
@@ -149,6 +164,10 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
149
164
|
onChange({ [pref.key]: !settings[pref.key] });
|
|
150
165
|
}
|
|
151
166
|
}
|
|
167
|
+
else if (tab === "Games" && (key.return || input === " ") && GAMES[row]) {
|
|
168
|
+
const id = GAMES[row].id;
|
|
169
|
+
onChange({ games: settings.games.includes(id) ? settings.games.filter((g) => g !== id) : [...settings.games, id] });
|
|
170
|
+
}
|
|
152
171
|
else if (tab === "Plugins") {
|
|
153
172
|
if (input === "r")
|
|
154
173
|
onRescanPlugins();
|
|
@@ -157,6 +176,20 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
157
176
|
}
|
|
158
177
|
});
|
|
159
178
|
const boxHeight = height - 6;
|
|
179
|
+
// Preferences take three rows each (label, help, gap), the code theme's preview four more: show the ones
|
|
180
|
+
// around the selected one that fit.
|
|
181
|
+
const prefWindow = (() => {
|
|
182
|
+
const room = Math.max(3, boxHeight - 4);
|
|
183
|
+
const cost = (i) => 3 + (tab === "Preferences" && i === row && PREFS[i]?.key === "codeTheme" ? 4 : 0);
|
|
184
|
+
let first = tab === "Preferences" ? row : 0;
|
|
185
|
+
let last = first + 1;
|
|
186
|
+
let used = cost(first);
|
|
187
|
+
while (last < PREFS.length && used + cost(last) <= room)
|
|
188
|
+
used += cost(last++);
|
|
189
|
+
while (first > 0 && used + cost(first - 1) <= room)
|
|
190
|
+
used += cost(--first);
|
|
191
|
+
return { first, last };
|
|
192
|
+
})();
|
|
160
193
|
const toggle = (on, warn) => (_jsx(Text, { color: on ? (warn ? C.orange : C.green) : C.dim, bold: true, children: on ? "● ON " : "○ OFF" }));
|
|
161
194
|
const body = () => {
|
|
162
195
|
if (tab === "Model") {
|
|
@@ -168,7 +201,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
168
201
|
const selected = i === row;
|
|
169
202
|
const current = p.id === choice.provider.id;
|
|
170
203
|
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: " \uD83D\uDD12" }) : null] }, p.id));
|
|
171
|
-
})] }), _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] }), connecting && provider.connect ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.accent2, bold: true, children: ["Connect ", provider.label] }), provider.connect.map((f, i) => {
|
|
204
|
+
})] }), _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: ["\u2716 ", reason] }) : null, connecting && provider.connect ? (_jsxs(_Fragment, { children: [_jsxs(Text, { color: C.accent2, bold: true, children: ["Connect ", provider.label] }), provider.connect.map((f, i) => {
|
|
172
205
|
const entered = connecting.values[f.env];
|
|
173
206
|
const shown = (v) => (f.secret ? "•".repeat(Math.min(v.length, 12)) : v);
|
|
174
207
|
if (i < connecting.step) {
|
|
@@ -205,13 +238,25 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
205
238
|
}), _jsx(Text, { color: C.dim, wrap: "truncate-end", children: "The engine is the agent that answers in the chat. Diffs, approvals, undo and problems work with each one." })] }));
|
|
206
239
|
}
|
|
207
240
|
if (tab === "Preferences") {
|
|
208
|
-
return (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
241
|
+
return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, overflow: "hidden", children: [prefWindow.first > 0 ? _jsxs(Text, { color: C.dim, children: [" \u2191 ", prefWindow.first, " more"] }) : null, PREFS.slice(prefWindow.first, prefWindow.last).map((pref, at) => {
|
|
242
|
+
const i = prefWindow.first + at;
|
|
243
|
+
const selected = i === row;
|
|
244
|
+
const value = settings[pref.key];
|
|
245
|
+
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 ? "▸ " : " " }), _jsx(Text, { color: C.text, bold: selected, children: pref.label.padEnd(26) }), pref.key === "codeTheme"
|
|
246
|
+
? _jsxs(Text, { children: [_jsx(Text, { color: C.dim, children: "\u2039 " }), _jsx(Text, { color: C.accent2, bold: true, children: String(value) }), _jsxs(Text, { color: C.dim, children: [" \u203A ", CODE_THEMES.indexOf(value) + 1, "/", CODE_THEMES.length] })] })
|
|
247
|
+
: pref.key === "effort" || pref.key === "autoSave" || pref.key === "followSpeed"
|
|
248
|
+
? _jsx(Text, { children: (pref.key === "effort" ? EFFORTS : pref.key === "autoSave" ? AUTO_SAVE : FOLLOW_SPEEDS).map((e) => _jsx(Text, { color: e === value ? C.accent2 : C.dim, bold: e === value, children: e === value ? `[${e}]` : ` ${e} ` }, e)) })
|
|
249
|
+
: toggle(value, pref.warn)] }), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", pref.help] }), pref.key === "codeTheme" && selected
|
|
250
|
+
? highlightLines("preview.ts", THEME_PREVIEW).map((line, n) => _jsxs(Text, { wrap: "truncate-end", children: [" ", line] }, n))
|
|
251
|
+
: null] }, pref.key));
|
|
252
|
+
}), prefWindow.last < PREFS.length ? _jsxs(Text, { color: C.dim, children: [" \u2193 ", PREFS.length - prefWindow.last, " more"] }) : null] }));
|
|
253
|
+
}
|
|
254
|
+
if (tab === "Games") {
|
|
255
|
+
return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Little games for a break while Nou works. Added ones show in the Games panel: /games, or ? then \"games\"." }), _jsx(Text, { children: " " }), GAMES.map((g, i) => {
|
|
256
|
+
const selected = i === row;
|
|
257
|
+
const added = settings.games.includes(g.id);
|
|
258
|
+
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: "\u2B07 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));
|
|
259
|
+
})] }));
|
|
215
260
|
}
|
|
216
261
|
return (_jsxs(Box, { flexDirection: "column", height: boxHeight, borderStyle: "round", borderColor: C.accent, paddingX: 1, children: [plugins.map((p, i) => {
|
|
217
262
|
const selected = i === row;
|
|
@@ -233,7 +278,7 @@ export function Settings({ width, height, settings, choice, plugins, connectTo,
|
|
|
233
278
|
};
|
|
234
279
|
const hints = tab === "Model"
|
|
235
280
|
? 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" : ""}`
|
|
236
|
-
: tab === "Engine" ? "↑↓ move · Enter use this engine" : tab === "Preferences" ? "↑↓ move · Enter/Space toggle · ←→ change option" : "↑↓ move · Enter toggle · r rescan";
|
|
281
|
+
: 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";
|
|
237
282
|
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: C.accent2, bold: true, children: "\u2699 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" })] })] }));
|
|
238
283
|
}
|
|
239
284
|
/** What a connect field starts with: the current URL to edit (keys stay hidden, so they start empty). */
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment } from "react";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { Text } from "ink";
|
|
5
|
+
import { C, fileIcon } from "./theme.js";
|
|
6
|
+
/** Tab labels: file names, with the folder added when two open files share a name. */
|
|
7
|
+
function labels(tabs) {
|
|
8
|
+
return tabs.map((file) => {
|
|
9
|
+
const name = path.posix.basename(file);
|
|
10
|
+
const twin = tabs.some((other) => other !== file && path.posix.basename(other) === name);
|
|
11
|
+
return twin ? `${path.posix.basename(path.posix.dirname(file))}/${name}` : name;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Lays out the tabs that fit in `width` columns, keeping the active one in view: the ones that don't fit
|
|
16
|
+
* are left out at the ends, marked with ‹ ›.
|
|
17
|
+
*/
|
|
18
|
+
export function layoutTabs(tabs, active, width) {
|
|
19
|
+
const all = labels(tabs).map((label, i) => ({ file: tabs[i], label: label.length > 24 ? `${label.slice(0, 23)}…` : label }));
|
|
20
|
+
const size = (t) => t.label.length + 6 + 1; // " ◆ name × " and "│"
|
|
21
|
+
const at = Math.max(0, all.findIndex((t) => t.file === active));
|
|
22
|
+
let from = at;
|
|
23
|
+
let to = at + 1;
|
|
24
|
+
let used = all[at] ? size(all[at]) : 0;
|
|
25
|
+
// Grow to the right, then to the left, while there's room (2 columns kept for ‹ ›).
|
|
26
|
+
for (let grew = true; grew;) {
|
|
27
|
+
grew = false;
|
|
28
|
+
if (to < all.length && used + size(all[to]) <= width - 2) {
|
|
29
|
+
used += size(all[to++]);
|
|
30
|
+
grew = true;
|
|
31
|
+
}
|
|
32
|
+
if (from > 0 && used + size(all[from - 1]) <= width - 2) {
|
|
33
|
+
used += size(all[--from]);
|
|
34
|
+
grew = true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const shown = all.slice(from, to);
|
|
38
|
+
const zones = [];
|
|
39
|
+
let x = from > 0 ? 1 : 0;
|
|
40
|
+
for (const t of shown) {
|
|
41
|
+
const w = size(t) - 1;
|
|
42
|
+
zones.push({ file: t.file, from: x, to: x + w, close: x + w - 2 });
|
|
43
|
+
x += w + 1;
|
|
44
|
+
}
|
|
45
|
+
// ‹ and › (tabs that don't fit) are clickable too: they go to the previous / next tab.
|
|
46
|
+
return { shown, before: from > 0, after: to < all.length, zones, prev: from > 0 ? 0 : null, next: to < all.length ? x : null };
|
|
47
|
+
}
|
|
48
|
+
/** The open files as tabs, for a pane's title row: the active one highlighted, with × to close it. */
|
|
49
|
+
export function Tabs({ tabs, active, width, dirty }) {
|
|
50
|
+
const { shown, before, after } = layoutTabs(tabs, active, width);
|
|
51
|
+
return (_jsxs(_Fragment, { children: [before ? _jsx(Text, { color: C.dim, children: "\u2039" }) : null, shown.map((t) => {
|
|
52
|
+
const on = t.file === active;
|
|
53
|
+
const { icon, color } = fileIcon(t.file);
|
|
54
|
+
return (_jsxs(Fragment, { children: [_jsxs(Text, { ...(on ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { children: " " }), _jsx(Text, { color: color, children: icon }), _jsxs(Text, { color: on ? C.text : C.dim, bold: on, children: [" ", t.label] }), _jsxs(Text, { color: on && dirty ? C.yellow : on ? C.text : C.dim, children: [" ", on && dirty ? "●" : "×"] }), _jsx(Text, { children: " " })] }), _jsx(Text, { color: C.dim, children: "\u2502" })] }, t.file));
|
|
55
|
+
}), after ? _jsx(Text, { color: C.dim, children: "\u203A" }) : null] }));
|
|
56
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { memo, useEffect, useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { C } from "./theme.js";
|
|
5
|
+
const COLORS = { out: C.text, command: C.accent, info: C.dim, error: C.red };
|
|
6
|
+
/**
|
|
7
|
+
* The terminal panel, under the code like VS Code's: type a command and Enter runs it. While one runs,
|
|
8
|
+
* what you type goes to it; Ctrl+C stops it. ↑↓ go through what you ran, Ctrl+L clears, Esc leaves.
|
|
9
|
+
*/
|
|
10
|
+
export const Terminal = memo(function Terminal({ shell, version, width, height, focused, wheel, onLeave }) {
|
|
11
|
+
const [input, setInput] = useState("");
|
|
12
|
+
const [history, setHistory] = useState([]);
|
|
13
|
+
const [back, setBack] = useState(-1);
|
|
14
|
+
const [scroll, setScroll] = useState(0);
|
|
15
|
+
const inner = width - 2;
|
|
16
|
+
const rows = Math.max(1, height - 3); // borders and the title
|
|
17
|
+
const lines = shell.view();
|
|
18
|
+
// Fit long lines by wrapping them, newest at the bottom.
|
|
19
|
+
const wrapped = [];
|
|
20
|
+
for (const line of lines.slice(-rows * 4)) {
|
|
21
|
+
const text = line.text || " ";
|
|
22
|
+
for (let i = 0; i < text.length; i += inner)
|
|
23
|
+
wrapped.push({ text: text.slice(i, i + inner), color: COLORS[line.kind] });
|
|
24
|
+
}
|
|
25
|
+
const promptText = shell.running ? "› " : shell.prompt();
|
|
26
|
+
const shownPrompt = `${promptText}${input}`;
|
|
27
|
+
const promptRows = Math.max(1, Math.ceil((shownPrompt.length + 1) / inner));
|
|
28
|
+
const outRows = Math.max(0, rows - promptRows);
|
|
29
|
+
const maxScroll = Math.max(0, wrapped.length - outRows);
|
|
30
|
+
const at = Math.min(scroll, maxScroll);
|
|
31
|
+
const shown = wrapped.slice(Math.max(0, wrapped.length - outRows - at), wrapped.length - at);
|
|
32
|
+
useEffect(() => setScroll(0), [version]); // new output: back to the bottom
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (wheel?.lines)
|
|
35
|
+
setScroll((s) => Math.max(0, Math.min(maxScroll, s - wheel.lines)));
|
|
36
|
+
}, [wheel?.nonce]);
|
|
37
|
+
useInput((typed, key) => {
|
|
38
|
+
if (key.escape)
|
|
39
|
+
return onLeave();
|
|
40
|
+
if (key.ctrl && typed === "c") {
|
|
41
|
+
if (shell.running)
|
|
42
|
+
shell.interrupt();
|
|
43
|
+
else
|
|
44
|
+
setInput("");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (key.ctrl && typed === "l")
|
|
48
|
+
return shell.clear();
|
|
49
|
+
if (key.return) {
|
|
50
|
+
if (!shell.running && input.trim())
|
|
51
|
+
setHistory((h) => [...h.filter((c) => c !== input.trim()), input.trim()].slice(-100));
|
|
52
|
+
if (shell.running)
|
|
53
|
+
shell.input(input);
|
|
54
|
+
else
|
|
55
|
+
shell.run(input);
|
|
56
|
+
setInput("");
|
|
57
|
+
setBack(-1);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (key.upArrow || key.downArrow) {
|
|
61
|
+
if (!history.length)
|
|
62
|
+
return;
|
|
63
|
+
const next = key.upArrow ? Math.min(history.length - 1, back + 1) : back - 1;
|
|
64
|
+
setBack(next);
|
|
65
|
+
setInput(next < 0 ? "" : history[history.length - 1 - next]);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (key.pageUp)
|
|
69
|
+
return setScroll((s) => Math.min(maxScroll, s + outRows - 1));
|
|
70
|
+
if (key.pageDown)
|
|
71
|
+
return setScroll((s) => Math.max(0, s - outRows + 1));
|
|
72
|
+
if (key.backspace || key.delete)
|
|
73
|
+
return setInput((v) => v.slice(0, -1));
|
|
74
|
+
if (key.ctrl && typed === "u")
|
|
75
|
+
return setInput("");
|
|
76
|
+
if (key.ctrl || key.meta || key.tab)
|
|
77
|
+
return;
|
|
78
|
+
if (typed)
|
|
79
|
+
setInput((v) => v + typed.replace(/[\r\n]+/g, " "));
|
|
80
|
+
}, { isActive: focused });
|
|
81
|
+
const color = focused ? C.accent : C.dim;
|
|
82
|
+
const title = ` ▸ Terminal${shell.running ? " ● running · Ctrl+C stops" : ""}${at ? ` ↑ ${at} lines` : ""}`;
|
|
83
|
+
return (_jsxs(Box, { width: width, height: height, flexDirection: "column", borderStyle: focused ? "round" : "single", borderColor: color, overflow: "hidden", children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: color, bold: true, children: title.slice(0, inner - 3).padEnd(inner - 3) }), _jsx(Text, { color: C.text, bold: true, children: " \u00D7 " })] }), _jsx(Box, { flexDirection: "column", height: outRows, flexShrink: 0, children: shown.map((l, i) => _jsx(Text, { color: l.color, wrap: "truncate-end", children: l.text }, i)) }), _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: shell.running ? C.yellow : C.green, children: promptText }), _jsx(Text, { color: C.text, children: input }), focused ? _jsx(Text, { inverse: true, children: " " }) : null] })] }));
|
|
84
|
+
});
|
package/dist/src/ui/clipboard.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
/** Native clipboard commands to try, in order, for this platform. */
|
|
3
3
|
function commands() {
|
|
4
|
+
// clip.exe is built in and instant; PowerShell can take seconds to start, so it's only the fallback.
|
|
4
5
|
if (process.platform === "win32")
|
|
5
|
-
return [["powershell", ["-NoProfile", "-Command", "Set-Clipboard -Value ([Console]::In.ReadToEnd())"]]];
|
|
6
|
+
return [["clip", [], "utf16"], ["powershell", ["-NoProfile", "-Command", "Set-Clipboard -Value ([Console]::In.ReadToEnd())"], "utf8"]];
|
|
6
7
|
if (process.platform === "darwin")
|
|
7
|
-
return [["pbcopy", []]];
|
|
8
|
-
return [["wl-copy", []], ["xclip", ["-selection", "clipboard"]], ["xsel", ["--clipboard", "--input"]]];
|
|
8
|
+
return [["pbcopy", [], "utf8"]];
|
|
9
|
+
return [["wl-copy", [], "utf8"], ["xclip", ["-selection", "clipboard"], "utf8"], ["xsel", ["--clipboard", "--input"], "utf8"]];
|
|
9
10
|
}
|
|
10
|
-
function run(cmd, args, text) {
|
|
11
|
+
function run(cmd, args, text, encoding) {
|
|
11
12
|
return new Promise((resolve) => {
|
|
12
13
|
const child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
|
|
13
14
|
child.on("error", () => resolve(false));
|
|
14
15
|
child.on("close", (code) => resolve(code === 0));
|
|
15
|
-
child.stdin.
|
|
16
|
+
child.stdin.on("error", () => { }); // the command is missing: "error" above answers
|
|
17
|
+
child.stdin.end(encoding === "utf16" ? Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(text, "utf16le")]) : text);
|
|
16
18
|
});
|
|
17
19
|
}
|
|
18
20
|
/**
|
|
@@ -22,8 +24,8 @@ function run(cmd, args, text) {
|
|
|
22
24
|
export async function copyToClipboard(text) {
|
|
23
25
|
if (process.stdout.isTTY)
|
|
24
26
|
process.stdout.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
|
|
25
|
-
for (const [cmd, args] of commands())
|
|
26
|
-
if (await run(cmd, args, text))
|
|
27
|
+
for (const [cmd, args, encoding] of commands())
|
|
28
|
+
if (await run(cmd, args, text, encoding))
|
|
27
29
|
return true;
|
|
28
30
|
return process.stdout.isTTY === true;
|
|
29
31
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const SLASH_COMMANDS = [
|
|
2
|
+
{ name: "model", args: "[provider:model]", description: "show or switch the model" },
|
|
3
|
+
{ name: "models", args: "[provider]", description: "list the models a provider offers" },
|
|
4
|
+
{ name: "connect", args: "<provider>", description: "enter an API key (and URL) for a provider" },
|
|
5
|
+
{ name: "open", args: "<file>[:line]", description: "open a file in the code view (part of its name is enough)" },
|
|
6
|
+
{ name: "settings", description: "models, engine, preferences and plugins" },
|
|
7
|
+
{ name: "auto", args: "[ask|auto-edit|auto]", description: "how much Nou does without asking" },
|
|
8
|
+
{ name: "engine", args: "[name]", description: "which agent answers: nou, claude-code, codex or pi" },
|
|
9
|
+
{ name: "cancel", description: "take back your last message (stops Nou; edit it and resend)" },
|
|
10
|
+
{ name: "undo", description: "undo Nou's last change" },
|
|
11
|
+
{ name: "rewind", description: "undo everything from Nou's last turn" },
|
|
12
|
+
{ name: "diff", args: "[file|n]", description: "everything changed this session (or session n)" },
|
|
13
|
+
{ name: "sessions", description: "earlier sessions with changes" },
|
|
14
|
+
{ name: "history", description: "recent changes, yours and Nou's" },
|
|
15
|
+
{ name: "problems", description: "every error and warning in the project" },
|
|
16
|
+
{ name: "copy", args: "[code|file]", description: "copy the last reply, its last code block, or the open file" },
|
|
17
|
+
{ name: "compact", description: "summarize the conversation to free up context" },
|
|
18
|
+
{ name: "clear", description: "start a fresh conversation" },
|
|
19
|
+
{ name: "terminal", description: "a terminal under the code, like VS Code's (or press `)" },
|
|
20
|
+
{ name: "resume", args: "[number|last]", description: "pick up an earlier conversation in this folder (any engine)" },
|
|
21
|
+
{ name: "pull", args: "<model>", description: "download an Ollama model" },
|
|
22
|
+
{ name: "plugins", args: "[add|new <name>]", description: "your plugins and the starter ones" },
|
|
23
|
+
{ name: "mcp", args: "[add|remove|import]", description: "MCP servers: tools from other programs (/mcp add browser for a browser)" },
|
|
24
|
+
{ name: "games", description: "open or close the Games panel (Pong, Snake, Breakout)" },
|
|
25
|
+
{ name: "version", description: "which version of Nou this is" },
|
|
26
|
+
{ name: "help", description: "all commands and keys" },
|
|
27
|
+
];
|
|
28
|
+
/** Commands whose name starts with what's typed after the slash (exact match first), plus plugin commands. */
|
|
29
|
+
export function matchCommands(typed, extra = []) {
|
|
30
|
+
const query = typed.replace(/^\//, "").toLowerCase();
|
|
31
|
+
const all = [...SLASH_COMMANDS, ...extra];
|
|
32
|
+
const starts = all.filter((c) => c.name.startsWith(query));
|
|
33
|
+
// Then ones that merely contain it, so "/conn" and "/key"-style guesses still find something.
|
|
34
|
+
const contains = query ? all.filter((c) => !c.name.startsWith(query) && (c.name.includes(query) || c.description.includes(query))) : [];
|
|
35
|
+
return [...starts.sort((a, b) => Number(b.name === query) - Number(a.name === query)), ...contains];
|
|
36
|
+
}
|