nou-cli 0.5.6 → 0.5.10
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 +12 -6
- package/dist/src/agent/index.js +1 -0
- package/dist/src/cli.js +36 -0
- package/dist/src/config/index.js +19 -0
- package/dist/src/conversations/external.js +35 -13
- package/dist/src/guide/index.js +75 -0
- package/dist/src/ui/App.js +220 -25
- package/dist/src/ui/Chat.js +59 -10
- package/dist/src/ui/CodeView.js +1 -1
- package/dist/src/ui/Editor.js +1 -1
- package/dist/src/ui/GamePanel.js +1 -1
- package/dist/src/ui/Header.js +28 -3
- package/dist/src/ui/Pane.js +2 -2
- package/dist/src/ui/QuickOpen.js +8 -3
- package/dist/src/ui/Settings.js +12 -5
- package/dist/src/ui/Terminal.js +7 -1
- package/dist/src/ui/background.js +58 -0
- package/dist/src/ui/commands.js +45 -6
- package/dist/src/ui/theme.js +59 -13
- package/dist/src/workspaces/client.js +69 -0
- package/dist/src/workspaces/host.js +144 -0
- package/package.json +2 -1
package/dist/src/ui/App.js
CHANGED
|
@@ -13,7 +13,7 @@ import { ENGINES, createEngine, engineInfo, installEngine, isEngineId, isInstall
|
|
|
13
13
|
import { harness, problemsIntroduced } from "../agent/harness.js";
|
|
14
14
|
import { describe, providers, resolveModel, saveModel, setupProblem } from "../providers/index.js";
|
|
15
15
|
import { sameModel } from "../providers/ollama.js";
|
|
16
|
-
import { FOLLOW_SPEEDS, isTrustedFolder, loadSettings, saveSettings } from "../config/index.js";
|
|
16
|
+
import { EFFORT_LABELS, EFFORTS, FOLLOW_SPEEDS, effortFrom, isTrustedFolder, loadSettings, saveSettings } from "../config/index.js";
|
|
17
17
|
import { branch as gitBranch, diffAgainstHead, isRepo, sameStatus, status as gitStatus, statusAsync as gitStatusAsync } from "../git/index.js";
|
|
18
18
|
import { BUILTIN_PLUGINS, addStarter, discoverPlugins, isEnabled, loadPlugin, scaffoldPlugin, starterPlugins, trustPatch, trustState } from "../plugins/index.js";
|
|
19
19
|
import { DiagnosticsManager, formatDiagnostic, newProblems } from "../diagnostics/index.js";
|
|
@@ -26,21 +26,23 @@ import { changedLines, lineCounts, loadFallbackHighlighter, splitLines } from ".
|
|
|
26
26
|
import { findDefinition, wordAt } from "./definition.js";
|
|
27
27
|
import { ProblemsView } from "./ProblemsView.js";
|
|
28
28
|
import { useFocusReporting, useTerminalFocused } from "./focus.js";
|
|
29
|
-
import { useMouse } from "./mouse.js";
|
|
30
|
-
import {
|
|
29
|
+
import { ENABLE as MOUSE_ON, useMouse } from "./mouse.js";
|
|
30
|
+
import { connectToHost, isHosted, reopenAsWorkspaces, sendToHost, useWorkspaceTabs, workspaceTabs } from "../workspaces/client.js";
|
|
31
|
+
import { PTY_PACKAGE, workspaceFolder } from "../workspaces/host.js";
|
|
32
|
+
import { C, palette } from "./theme.js";
|
|
31
33
|
import { FileTree } from "./FileTree.js";
|
|
32
34
|
import { CodeView } from "./CodeView.js";
|
|
33
35
|
import { Editor } from "./Editor.js";
|
|
34
|
-
import { SLASH_COMMANDS, runsWhileBusy } from "./commands.js";
|
|
36
|
+
import { SLASH_COMMANDS, closestCommand, onlyChangesScreen, runsWhileBusy } from "./commands.js";
|
|
35
37
|
import { WHEEL_STEP } from "./scroll.js";
|
|
36
38
|
import { Chat, chatTabZones } from "./Chat.js";
|
|
37
39
|
import { copyToClipboard, openInBrowser } from "./clipboard.js";
|
|
38
|
-
import { Header } from "./Header.js";
|
|
40
|
+
import { Header, headerZones } from "./Header.js";
|
|
39
41
|
import { Tabs, layoutTabs } from "./Tabs.js";
|
|
40
42
|
import { GAME_PANEL, GameGuard, GamePanel } from "./GamePanel.js";
|
|
41
43
|
import { Terminal } from "./Terminal.js";
|
|
42
44
|
import { forgetScreen } from "./redraw.js";
|
|
43
|
-
import { showMePrompt, showStepsTool, stepsFromReply } from "../guide/index.js";
|
|
45
|
+
import { SHOW_PLACES_NOTE, explainPrompt, showMePrompt, showPlacesTool, showStepsTool, stepsFromReply, stopsFromReply } from "../guide/index.js";
|
|
44
46
|
import { Shell } from "../terminal/index.js";
|
|
45
47
|
import { QuickOpen, listAllFiles, score } from "./QuickOpen.js";
|
|
46
48
|
import { Settings } from "./Settings.js";
|
|
@@ -86,7 +88,7 @@ Keys:
|
|
|
86
88
|
Ctrl+click, Option+click or right-click a name: go to its definition (an import's path opens the file)
|
|
87
89
|
p problems u undo Nou's edit s settings
|
|
88
90
|
c (in files) show only changed files Ctrl+C twice quit
|
|
89
|
-
Mouse: wheel scrolls (Shift+wheel sideways) · click/drag a scrollbar · double-click in the code to edit there (in the editor: select a word), drag to select
|
|
91
|
+
Mouse: wheel scrolls (Shift+wheel sideways) · click/drag a scrollbar · double-click in the code to edit there (in the editor: select a word), drag to select · in the chat: double-click copies a word, triple-click a paragraph
|
|
90
92
|
In the editor:
|
|
91
93
|
Shift+arrows / mouse drag select · Ctrl+A all · Ctrl+C/X/V copy/cut/paste · Esc clear selection or leave
|
|
92
94
|
Ctrl+←/→ word · Ctrl+Backspace or Ctrl+W delete word · Ctrl+Home/End file start/end
|
|
@@ -110,13 +112,17 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
110
112
|
const { exit } = useApp();
|
|
111
113
|
const { columns, rows } = useWindowSize();
|
|
112
114
|
const [settings, setSettings] = useState(loadSettings);
|
|
113
|
-
|
|
115
|
+
// Before anything below highlights code. One Dark's light text is unreadable on a light terminal: there the
|
|
116
|
+
// default switches to GitHub Light (any other theme you picked stays).
|
|
117
|
+
setCodeTheme(palette() === "light" && settings.codeTheme === "one-dark" ? "github-light" : settings.codeTheme);
|
|
114
118
|
const [view, setView] = useState("main");
|
|
115
119
|
const [connectTo, setConnectTo] = useState();
|
|
116
120
|
/** Why settings were opened for you (a request failed on the provider's setup). */
|
|
117
121
|
const [setupReason, setSetupReason] = useState(null);
|
|
118
122
|
/** The file finder / command palette: closed (null), or open with this query (">" for commands). */
|
|
119
123
|
const [quickOpen, setQuickOpen] = useState(null);
|
|
124
|
+
/** /resume's picker: the conversations, in the palette's place while it's open. */
|
|
125
|
+
const [resumePicker, setResumePicker] = useState(null);
|
|
120
126
|
const [focus, setFocus] = useState("chat");
|
|
121
127
|
/** The Games panel: whether it's open, the pane it's being dragged over, and the pane to go back to. */
|
|
122
128
|
const [gamesOpen, setGamesOpen] = useState(false);
|
|
@@ -136,7 +142,7 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
136
142
|
/** The tab strip, kept while nothing in it changes: a new one would redraw the whole code view on every scroll. */
|
|
137
143
|
const tabStripCache = useRef(null);
|
|
138
144
|
/**
|
|
139
|
-
* "
|
|
145
|
+
* "Teach me" (/teach): the steps Nou found, and the one you're on. While Nou looks for them,
|
|
140
146
|
* `guideAsk` holds what you asked (and its edits are turned down: you're doing the typing).
|
|
141
147
|
*/
|
|
142
148
|
const [guide, setGuide] = useState(null);
|
|
@@ -146,6 +152,12 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
146
152
|
/** /resume just listed the conversations: the next message, if it's a number, picks one. */
|
|
147
153
|
const pickingResume = useRef(false);
|
|
148
154
|
const guideFound = useRef(null);
|
|
155
|
+
/**
|
|
156
|
+
* "Show me" (/showme, or any explanation that points at code): the places the agent pointed at, and the
|
|
157
|
+
* one open in the code view. `tourFound` holds what Nou's engine passed to show_places during a turn.
|
|
158
|
+
*/
|
|
159
|
+
const [tour, setTour] = useState(null);
|
|
160
|
+
const tourFound = useRef(null);
|
|
149
161
|
const shellRef = useRef(null);
|
|
150
162
|
const beforeTerm = useRef("code");
|
|
151
163
|
const [follow, setFollow] = useState(settings.followMode);
|
|
@@ -545,7 +557,7 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
545
557
|
useEffect(() => {
|
|
546
558
|
const { extras, extraTools } = harness({ diagnostics, plugins: activePlugins.map((p) => p.plugin), git: settings.gitContext && repo });
|
|
547
559
|
// Other engines read AGENTS.md / CLAUDE.md themselves: they get only the NOU.md rules on top.
|
|
548
|
-
engineConfig.current = { system: buildSystemPrompt(permContext, extras), extras: [...extras, nouRulesOnly(permContext)].filter(Boolean), extraTools: [...extraTools, showStepsTool((steps) => (guideFound.current = steps)), ...mcpRef.current.tools()], effort: settings.effort };
|
|
560
|
+
engineConfig.current = { system: buildSystemPrompt(permContext, extras), extras: [...extras, nouRulesOnly(permContext), SHOW_PLACES_NOTE].filter(Boolean), extraTools: [...extraTools, showStepsTool((steps) => (guideFound.current = steps)), showPlacesTool((stops) => (tourFound.current = stops)), ...mcpRef.current.tools()], effort: settings.effort };
|
|
549
561
|
agentRef.current.configure(engineConfig.current);
|
|
550
562
|
}, [settings.gitContext, settings.effort, activePlugins, mcpVersion]);
|
|
551
563
|
// Pick up changes made outside Nou (other editors, git commands). Not while Nou's window is in the
|
|
@@ -581,6 +593,24 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
581
593
|
if (openFile && diffMode === "off" && !editing)
|
|
582
594
|
setContent(readText(openFile));
|
|
583
595
|
}, [busy]);
|
|
596
|
+
/**
|
|
597
|
+
* Workspaces: when this Nou is one tab of several, the host says when it's on screen again (redraw all of
|
|
598
|
+
* it, and turn the mouse and focus reports back on, which another workspace may have turned off on its way
|
|
599
|
+
* out), and hears when it's busy, for the tab's ● .
|
|
600
|
+
*/
|
|
601
|
+
const tabsOfWorkspaces = useWorkspaceTabs();
|
|
602
|
+
useEffect(() => {
|
|
603
|
+
connectToHost({
|
|
604
|
+
redraw: () => {
|
|
605
|
+
process.stdout.write(`\x1b[?1049h${settings.mouse ? MOUSE_ON : ""}\x1b[?1004h`);
|
|
606
|
+
redraw();
|
|
607
|
+
},
|
|
608
|
+
error: (text) => info(text, "error"),
|
|
609
|
+
});
|
|
610
|
+
}, []);
|
|
611
|
+
useEffect(() => {
|
|
612
|
+
sendToHost({ type: "state", busy: !!busy });
|
|
613
|
+
}, [!!busy]);
|
|
584
614
|
const updateSettings = (patch) => setSettings(saveSettings(patch));
|
|
585
615
|
const switchModel = (next) => {
|
|
586
616
|
const problem = next.provider.unavailable();
|
|
@@ -642,19 +672,31 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
642
672
|
const list = resumable();
|
|
643
673
|
if (!list.length)
|
|
644
674
|
return info("No earlier conversations in this folder yet: Nou's are saved after each reply, and Claude Code's and Codex's own sessions here show up too.");
|
|
675
|
+
const where = (c) => ("file" in c ? `${engineInfo(c.engine).label} CLI` : engineInfo(isEngineId(c.engine) ? c.engine : "nou").label);
|
|
645
676
|
if (!arg) {
|
|
646
|
-
const rows = list.slice(0, 10).map((c, i) => ` ${String(i + 1).padStart(2)}. ${c.title} (${c.messages} message${c.messages === 1 ? "" : "s"} · ${
|
|
677
|
+
const rows = list.slice(0, 10).map((c, i) => ` ${String(i + 1).padStart(2)}. ${c.title} (${c.messages} message${c.messages === 1 ? "" : "s"} · ${where(c)} · ${ago(c.updated)})`);
|
|
647
678
|
pickingResume.current = true; // a bare number, typed next, picks one
|
|
648
|
-
|
|
679
|
+
info(`Earlier conversations here (also in the picker: ↑↓ and Enter, or type a number here):\n${rows.join("\n")}`);
|
|
680
|
+
// A picker over the screen: ↑↓ and Enter, or type to filter by title.
|
|
681
|
+
setResumePicker(list.map((c, i) => ({ title: `${i + 1}. ${c.title}`, keys: `${where(c)} · ${ago(c.updated)}`, run: () => void resumePicked(c) })));
|
|
682
|
+
setQuickOpen(">");
|
|
683
|
+
return;
|
|
649
684
|
}
|
|
650
685
|
const pick = arg === "last" ? list[0] : list[Number(arg) - 1] ?? list.find((c) => c.id === arg);
|
|
651
686
|
if (!pick)
|
|
652
687
|
return info(`There's no conversation ${arg}: /resume lists them.`, "error");
|
|
688
|
+
return resumePicked(pick);
|
|
689
|
+
};
|
|
690
|
+
/** Picks a conversation back up: Nou's own, or a Claude Code or Codex CLI session. */
|
|
691
|
+
const resumePicked = async (pick) => {
|
|
692
|
+
pickingResume.current = false;
|
|
693
|
+
if (busy)
|
|
694
|
+
return info("Wait for the current reply to finish (or press Esc) before resuming another conversation.", "error");
|
|
653
695
|
if ("file" in pick)
|
|
654
696
|
return resumeExternal(pick);
|
|
655
697
|
const saved = loadConversation(pick.id);
|
|
656
698
|
if (!saved)
|
|
657
|
-
return info(`Couldn't read conversation ${
|
|
699
|
+
return info(`Couldn't read conversation "${pick.title}".`, "error");
|
|
658
700
|
const engine = engineFor(isEngineId(saved.engine) ? saved.engine : null);
|
|
659
701
|
const restored = saved.engine === engine.id && !!saved.state && !!engine.restore?.(saved.state);
|
|
660
702
|
if (!restored) {
|
|
@@ -1020,6 +1062,14 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1020
1062
|
}
|
|
1021
1063
|
if (event.kind === "click" && !approval && event.y === 0 && event.x >= clickable.current.modelFrom)
|
|
1022
1064
|
return setView("settings");
|
|
1065
|
+
// Workspace tabs in the header: click one to switch to it, + to open another.
|
|
1066
|
+
if (event.kind === "click" && event.y === 0) {
|
|
1067
|
+
const zone = headerZones(workspaceTabs()).find((z) => event.x >= z.from && event.x < z.to);
|
|
1068
|
+
if (zone?.tab === "new")
|
|
1069
|
+
return typeCommand("/workspace new ");
|
|
1070
|
+
if (typeof zone?.tab === "number")
|
|
1071
|
+
return void sendToHost({ type: "switch", index: zone.tab });
|
|
1072
|
+
}
|
|
1023
1073
|
if (event.kind === "click" && event.y === paneHeight + 1)
|
|
1024
1074
|
return clickable.current.status.find((z) => event.x >= z.from && event.x < z.to)?.run();
|
|
1025
1075
|
if (event.y < 1 || event.y > paneHeight)
|
|
@@ -1097,8 +1147,14 @@ export function App({ initialModel, permContext, resume }) {
|
|
|
1097
1147
|
}
|
|
1098
1148
|
// Two clicks in the same spot within half a second are a double-click.
|
|
1099
1149
|
const last = lastClick.current;
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1150
|
+
const again = !!last && Date.now() - last.time < DOUBLE_CLICK_MS && Math.abs(last.x - event.x) <= 1 && last.y === event.y;
|
|
1151
|
+
const count = again ? last.count + 1 : 1;
|
|
1152
|
+
const double = count === 2;
|
|
1153
|
+
// The chat counts a third click too (the whole paragraph); elsewhere a double-click starts over.
|
|
1154
|
+
lastClick.current = double && pane !== "chat" ? null : count >= 3 ? null : { x: event.x, y: event.y, time: Date.now(), count };
|
|
1155
|
+
// In the chat: a double-click selects and copies the word (or path, or link), a triple-click the paragraph.
|
|
1156
|
+
if (pane === "chat" && event.y >= 3 && count >= 2)
|
|
1157
|
+
chatAt(event.x, event.y, count === 2 ? "word" : "block");
|
|
1102
1158
|
if (pane === "code" && event.y >= 3 && codePanel === "code") {
|
|
1103
1159
|
// As the code view counts them (a last empty line after the final newline isn't shown).
|
|
1104
1160
|
const digits = String(splitLines(content).length).length;
|
|
@@ -1632,6 +1688,21 @@ ${snippet}
|
|
|
1632
1688
|
return info(`No file matches "${wanted}". To find where something is in the code, ask in the chat.`, "error");
|
|
1633
1689
|
return openRef({ path: file, ...(m[2] ? { line: Number(m[2]) } : {}), ...(m[3] ? { to: Number(m[3]) } : {}) });
|
|
1634
1690
|
}
|
|
1691
|
+
case "effort": {
|
|
1692
|
+
// Speed vs smarts, by name: /effort smarter (or the effort itself: /effort xhigh).
|
|
1693
|
+
const options = EFFORTS.map((e) => EFFORT_LABELS[e].name.toLowerCase()).join(", ");
|
|
1694
|
+
if (!arg)
|
|
1695
|
+
return info(`Now: ${EFFORT_LABELS[settings.effort].name}. ${EFFORT_LABELS[settings.effort].tradeoff} Change it with /effort ${options}.`);
|
|
1696
|
+
const next = effortFrom(arg);
|
|
1697
|
+
if (!next)
|
|
1698
|
+
return info(`No such level: "${arg}". Try one of: ${options}.`, "error");
|
|
1699
|
+
updateSettings({ effort: next });
|
|
1700
|
+
return info(`Now: ${EFFORT_LABELS[next].name}. ${EFFORT_LABELS[next].tradeoff}`);
|
|
1701
|
+
}
|
|
1702
|
+
case "now":
|
|
1703
|
+
if (!queued.length)
|
|
1704
|
+
return info(busy ? "Nothing is queued. Type a message and press Alt+Enter to stop Nou and send it now." : "Nothing is queued.");
|
|
1705
|
+
return sendNow();
|
|
1635
1706
|
case "games":
|
|
1636
1707
|
return toggleGames();
|
|
1637
1708
|
case "cancel":
|
|
@@ -1748,6 +1819,10 @@ ${snippet}
|
|
|
1748
1819
|
return toggleTerminal();
|
|
1749
1820
|
case "redraw":
|
|
1750
1821
|
return redraw();
|
|
1822
|
+
case "workspace":
|
|
1823
|
+
case "workspaces":
|
|
1824
|
+
case "ws":
|
|
1825
|
+
return void workspaceCommand(arg);
|
|
1751
1826
|
case "fold":
|
|
1752
1827
|
case "unfold":
|
|
1753
1828
|
if (!openFile)
|
|
@@ -1766,7 +1841,9 @@ ${snippet}
|
|
|
1766
1841
|
return switchChatTab(chatTabs[n - 1]);
|
|
1767
1842
|
}
|
|
1768
1843
|
case "showme":
|
|
1844
|
+
return showPlaces(rest.join(" "));
|
|
1769
1845
|
case "teach":
|
|
1846
|
+
case "teachme":
|
|
1770
1847
|
return showMe(rest.join(" "));
|
|
1771
1848
|
case "resume":
|
|
1772
1849
|
return resumeConversation(rest[0] ?? "");
|
|
@@ -1791,6 +1868,13 @@ ${snippet}
|
|
|
1791
1868
|
}
|
|
1792
1869
|
return;
|
|
1793
1870
|
}
|
|
1871
|
+
// A typo of a command ("/termnial"): one that only changes the screen just runs; others are suggested,
|
|
1872
|
+
// so a slip never clears or undoes anything.
|
|
1873
|
+
const meant = closestCommand(command ?? "", [...SLASH_COMMANDS.map((c) => c.name), ...activePlugins.flatMap(({ plugin }) => plugin.commands?.map((c) => c.name) ?? [])]);
|
|
1874
|
+
if (meant && onlyChangesScreen(meant))
|
|
1875
|
+
return runCommand(`/${meant}${arg ? ` ${arg}` : ""}`);
|
|
1876
|
+
if (meant)
|
|
1877
|
+
return info(`Unknown command /${command}. Did you mean /${meant}${arg ? ` ${arg}` : ""}?`, "error");
|
|
1794
1878
|
return info(`Unknown command /${command}. Type /help.`, "error");
|
|
1795
1879
|
}
|
|
1796
1880
|
}
|
|
@@ -1872,6 +1956,57 @@ ${snippet}
|
|
|
1872
1956
|
setQueued(queued.slice(take));
|
|
1873
1957
|
send(queued.slice(0, take).join("\n\n"));
|
|
1874
1958
|
}, [busy, approval, queued]);
|
|
1959
|
+
// When a reply points at places in the code (Nou's engine with show_places, the others with a ```nou-show
|
|
1960
|
+
// block, which is taken out of the reply), the first one opens highlighted and the rest are listed.
|
|
1961
|
+
useEffect(() => {
|
|
1962
|
+
if (busy || guideAsk.current)
|
|
1963
|
+
return;
|
|
1964
|
+
let stops = tourFound.current;
|
|
1965
|
+
tourFound.current = null;
|
|
1966
|
+
const reply = [...entries].reverse().find((e) => e.kind === "assistant");
|
|
1967
|
+
const found = reply && stopsFromReply(reply.text);
|
|
1968
|
+
if (found) {
|
|
1969
|
+
stops = stops?.length ? stops : found.stops;
|
|
1970
|
+
setEntries((prev) => prev.map((e) => (e === reply ? { ...e, text: found.rest || "Here's where." } : e)));
|
|
1971
|
+
}
|
|
1972
|
+
stops = (stops ?? []).filter((s) => existsSync(s.file));
|
|
1973
|
+
if (!stops.length)
|
|
1974
|
+
return;
|
|
1975
|
+
const list = stops.map((s, i) => `${i + 1}. ${s.file}:${s.line}${s.end > s.line ? `-${s.end}` : ""}${s.note ? ` · ${s.note}` : ""}`).join("\n");
|
|
1976
|
+
info(`📍 ${stops.length === 1 ? "Here it is" : `${stops.length} places`}, open in the code view (click one to jump there${stops.length > 1 ? "; /showme next and /showme back step through them" : ""}):\n${list}`);
|
|
1977
|
+
setTour({ stops, at: 0 });
|
|
1978
|
+
}, [busy]);
|
|
1979
|
+
// Each place: open it in the code view with its lines highlighted.
|
|
1980
|
+
useEffect(() => {
|
|
1981
|
+
const stop = tour?.stops[tour.at];
|
|
1982
|
+
if (!stop)
|
|
1983
|
+
return;
|
|
1984
|
+
openRef({ path: stop.file, line: stop.line, ...(stop.end > stop.line ? { to: stop.end } : {}) });
|
|
1985
|
+
}, [tour?.at, tour?.stops]);
|
|
1986
|
+
/** /showme <question>: explains it and opens the places in the code; /showme next|back|stop steps through them. */
|
|
1987
|
+
const showPlaces = (arg) => {
|
|
1988
|
+
const words = arg.trim();
|
|
1989
|
+
if (/^(stop|end|done)$/.test(words)) {
|
|
1990
|
+
setTour(null);
|
|
1991
|
+
return info(tour ? "Done showing." : "Nothing is being shown.");
|
|
1992
|
+
}
|
|
1993
|
+
if (/^(next|back|prev|previous)$/.test(words)) {
|
|
1994
|
+
if (!tour)
|
|
1995
|
+
return info("Nothing is being shown. Ask with /showme <question>, e.g. /showme where do we check the login?");
|
|
1996
|
+
const at = tour.at + (words === "next" ? 1 : -1);
|
|
1997
|
+
if (at < 0 || at >= tour.stops.length)
|
|
1998
|
+
return info(at < 0 ? "That's the first place." : "That was the last place.");
|
|
1999
|
+
const stop = tour.stops[at];
|
|
2000
|
+
info(`📍 ${at + 1} of ${tour.stops.length}: ${stop.file}:${stop.line}${stop.end > stop.line ? `-${stop.end}` : ""}${stop.note ? ` · ${stop.note}` : ""}`);
|
|
2001
|
+
return setTour({ ...tour, at });
|
|
2002
|
+
}
|
|
2003
|
+
if (!words)
|
|
2004
|
+
return info("Ask how something works or where it happens, and Nou explains it and opens the places in the code, highlighted: /showme where do we check the login? (To be walked through typing a change yourself: /teach.)");
|
|
2005
|
+
if (busy)
|
|
2006
|
+
return info("Wait for the current reply to finish (or press Esc) first.", "error");
|
|
2007
|
+
setTour(null);
|
|
2008
|
+
send(explainPrompt(words, agentRef.current.id === "nou"));
|
|
2009
|
+
};
|
|
1875
2010
|
// When Nou has finished looking, the guide starts at its first step.
|
|
1876
2011
|
useEffect(() => {
|
|
1877
2012
|
if (busy || !guideAsk.current)
|
|
@@ -1897,8 +2032,8 @@ ${snippet}
|
|
|
1897
2032
|
info("It edited files anyway, so those changes were put back: you're the one typing them.");
|
|
1898
2033
|
}
|
|
1899
2034
|
if (!steps?.length)
|
|
1900
|
-
return info("Nou didn't come back with steps to show. Try asking more specifically, e.g. /
|
|
1901
|
-
info(`Here's how, in ${steps.length} step${steps.length > 1 ? "s" : ""}. You type each one in the editor: the dim text ahead of the cursor is what goes next, and Tab fills in the rest of a line. /
|
|
2035
|
+
return info("Nou didn't come back with steps to show. Try asking more specifically, e.g. /teach add a --verbose flag to the CLI", "error");
|
|
2036
|
+
info(`Here's how, in ${steps.length} step${steps.length > 1 ? "s" : ""}. You type each one in the editor: the dim text ahead of the cursor is what goes next, and Tab fills in the rest of a line. /teach next skips a step, /teach stop ends it.`);
|
|
1902
2037
|
setGuide({ steps, at: 0, task });
|
|
1903
2038
|
}, [busy]);
|
|
1904
2039
|
// Each step: open its file in the editor where the code goes, and say what to type and why.
|
|
@@ -1934,7 +2069,7 @@ ${snippet}
|
|
|
1934
2069
|
setGuide(null);
|
|
1935
2070
|
}
|
|
1936
2071
|
});
|
|
1937
|
-
/** /
|
|
2072
|
+
/** /teach <what you want>: Nou finds where the change goes and walks you through typing it. */
|
|
1938
2073
|
const showMe = (arg) => {
|
|
1939
2074
|
const words = arg.trim();
|
|
1940
2075
|
if (words === "stop" || words === "end") {
|
|
@@ -1952,8 +2087,8 @@ ${snippet}
|
|
|
1952
2087
|
}
|
|
1953
2088
|
if (!words) {
|
|
1954
2089
|
if (guide)
|
|
1955
|
-
return info(`On step ${guide.at + 1} of ${guide.steps.length}: ${guideStep.file}:${guideStep.line}. /
|
|
1956
|
-
return info("Tell Nou what you want to do and it shows you how, step by step, for you to type: /
|
|
2090
|
+
return info(`On step ${guide.at + 1} of ${guide.steps.length}: ${guideStep.file}:${guideStep.line}. /teach next skips it, /teach stop ends it.`);
|
|
2091
|
+
return info("Tell Nou what you want to do and it shows you how, step by step, for you to type: /teach add a --verbose flag to the CLI");
|
|
1957
2092
|
}
|
|
1958
2093
|
if (busy)
|
|
1959
2094
|
return info("Wait for the current reply to finish (or press Esc) first.", "error");
|
|
@@ -1967,10 +2102,21 @@ ${snippet}
|
|
|
1967
2102
|
if (busy)
|
|
1968
2103
|
stopped.current = false;
|
|
1969
2104
|
}, [busy]);
|
|
1970
|
-
/**
|
|
1971
|
-
const
|
|
2105
|
+
/** Stops Nou and sends what's queued right away (a click on the queue, or /now). */
|
|
2106
|
+
const sendNow = () => {
|
|
2107
|
+
if (!queued.length)
|
|
2108
|
+
return;
|
|
2109
|
+
stop();
|
|
2110
|
+
stopped.current = false; // what's queued is meant to go
|
|
2111
|
+
};
|
|
2112
|
+
/**
|
|
2113
|
+
* From the chat: while Nou works, a message waits its turn, or (`now`: Alt+Enter) stops Nou and goes right
|
|
2114
|
+
* away. The "Send while Nou works" setting swaps the two, so Enter sends now and Alt+Enter queues.
|
|
2115
|
+
*/
|
|
2116
|
+
const submit = (text, alt = false) => {
|
|
1972
2117
|
if (!busy || (text.startsWith("/") && runsWhileBusy(text)))
|
|
1973
2118
|
return send(text);
|
|
2119
|
+
const now = settings.sendNowWhileBusy ? !alt : alt;
|
|
1974
2120
|
if (now) {
|
|
1975
2121
|
stop();
|
|
1976
2122
|
stopped.current = false; // this one is meant to go
|
|
@@ -2129,6 +2275,51 @@ ${snippet}
|
|
|
2129
2275
|
forgetScreen();
|
|
2130
2276
|
setRepaint((n) => n + 1);
|
|
2131
2277
|
}
|
|
2278
|
+
/** /workspace: list them, `new [folder]` opens one, a number switches. */
|
|
2279
|
+
async function workspaceCommand(arg) {
|
|
2280
|
+
const [sub, ...more] = arg.split(/\s+/);
|
|
2281
|
+
const tabs = workspaceTabs();
|
|
2282
|
+
if (!sub) {
|
|
2283
|
+
if (!isHosted())
|
|
2284
|
+
return info(`One workspace: ${path.basename(process.cwd())}. /workspace new <folder> opens another in this window, a tab each in the header (click + there too).`);
|
|
2285
|
+
return info(`Workspaces: ${tabs.map((t, i) => `${i + 1}. ${t.name}${t.busy ? " (working)" : ""}${t.active ? " ← here" : ""}`).join(" ")}. /workspace <n> switches, /workspace new <folder> opens one, /quit closes this one.`);
|
|
2286
|
+
}
|
|
2287
|
+
if (/^\d+$/.test(sub)) {
|
|
2288
|
+
const n = Number(sub);
|
|
2289
|
+
if (!isHosted() || !tabs[n - 1])
|
|
2290
|
+
return info(`There's no workspace ${n}. /workspace lists them.`, "error");
|
|
2291
|
+
return void sendToHost({ type: "switch", index: n - 1 });
|
|
2292
|
+
}
|
|
2293
|
+
if (sub !== "new")
|
|
2294
|
+
return info("Try /workspace new <folder>, /workspace <n> or /workspace.", "error");
|
|
2295
|
+
const wanted = more.join(" ");
|
|
2296
|
+
if (isHosted())
|
|
2297
|
+
return void sendToHost({ type: "new", folder: wanted });
|
|
2298
|
+
const folder = workspaceFolder(wanted, process.cwd());
|
|
2299
|
+
if (!folder)
|
|
2300
|
+
return info(`No such folder: ${wanted}`, "error");
|
|
2301
|
+
if (busy)
|
|
2302
|
+
return info("Wait for the current reply to finish (or press Esc) first: Nou reopens as a workspaces window.", "error");
|
|
2303
|
+
// The pseudo-terminals workspaces run in come from a small native package, installed once like an engine.
|
|
2304
|
+
const pty = { id: "nou", label: "workspaces", description: "", approvals: "before", model: () => "", package: PTY_PACKAGE };
|
|
2305
|
+
if (!isInstalled(pty)) {
|
|
2306
|
+
info(`Workspaces need ${PTY_PACKAGE} (pseudo-terminals, prebuilt for macOS, Linux and Windows). Installing it once into ~/.nou/engines...`);
|
|
2307
|
+
setBusy("Installing workspaces support (npm)...");
|
|
2308
|
+
try {
|
|
2309
|
+
await installEngine(pty, (line) => setBusy(`Installing workspaces support (npm): ${line.slice(0, 70)}`));
|
|
2310
|
+
}
|
|
2311
|
+
catch (error) {
|
|
2312
|
+
return info(`Couldn't install ${PTY_PACKAGE}: ${error.message}`, "error");
|
|
2313
|
+
}
|
|
2314
|
+
finally {
|
|
2315
|
+
setBusy(null);
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
if (draft.current && settings.autoSave !== "off")
|
|
2319
|
+
saveFromEditor(draft.current.text, draft.current.path);
|
|
2320
|
+
reopenAsWorkspaces([process.cwd(), folder]);
|
|
2321
|
+
exit();
|
|
2322
|
+
}
|
|
2132
2323
|
/** Opens the terminal panel (starting its shell the first time) and gives it the keyboard, or closes it. */
|
|
2133
2324
|
function toggleTerminal(open = !termOpen) {
|
|
2134
2325
|
shellRef.current ??= new Shell(() => setTermVersion((v) => v + 1));
|
|
@@ -2302,9 +2493,9 @@ ${snippet}
|
|
|
2302
2493
|
}
|
|
2303
2494
|
const modelLabel = agentRef.current.id === "nou" ? describe(choice) : `${engineInfo(agentRef.current.id).label} · ${engineInfo(agentRef.current.id).model(choice)}`;
|
|
2304
2495
|
clickable.current = { status: zones, modelFrom: columns - (" ⌂ LOCAL ".length + ` ${modelLabel} `.length + 1) };
|
|
2305
|
-
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: modelLabel, local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: heights.tree, focused: focus === "tree", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, working: working, git: git, problemCounts: problemCounts, wheel: wheelFor("tree"), scrollTo: scrollFor("tree"), click: treeClick, onOpen: onTreeOpen, onHover: onTreeHover }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: heights.code, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: onJump, onClose: onCloseProblems })) : showEditor ? (_jsx(Editor, { ...(tabStrip ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, ...(editorStart.col ? { startCol: editorStart.col } : {}), problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), scrollTo: scrollFor("code"), mouse: editorMouse, onHover: onEditorHover, onDefinition: onEditorDefinition, onSave: onEditorSave, onExit: onEditorExit, onState: onEditorState, ...(guideStep && openFile === guideStep.file ? { guide: { row: guideStep.line - 1, code: guideStep.code }, onGuideDone } : {}) }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { ...(tabStrip && !diff ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange && diff === pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange && diff === pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), scrollTo: scrollFor("code"), onViewport: onViewport, defineAt: codeDefine, onDefine: onCodeDefine, onFinding: onCodeFinding, findRequest: findRequest, foldClick: foldClick, foldAll: foldAll })), _jsx(Chat, { width: chatWidth, height: heights.chat, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheel: wheelFor("chat"), scrollTo: scrollFor("chat"), prefill: chatPrefill, extraCommands: pluginCommands, onMenu: onChatMenu, onSubmit: onSend, queued: queued, onAnswer: onAnswer, click: chatClick, select: chatSelect, tabs: chatTabsProp(), onOpenRef: openRef })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color:
|
|
2496
|
+
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: modelLabel, local: choice.provider.local, busy: busy, update: update, tabs: tabsOfWorkspaces }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: heights.tree, focused: focus === "tree", openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, working: working, git: git, problemCounts: problemCounts, wheel: wheelFor("tree"), scrollTo: scrollFor("tree"), click: treeClick, onOpen: onTreeOpen, onHover: onTreeHover }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: heights.code, focused: focus === "code", problems: diagnostics.all(), checking: checking, wheel: wheelFor("code"), onJump: onJump, onClose: onCloseProblems })) : showEditor ? (_jsx(Editor, { ...(tabStrip ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, ...(editorStart.col ? { startCol: editorStart.col } : {}), problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, wheel: wheelFor("code"), scrollTo: scrollFor("code"), mouse: editorMouse, onHover: onEditorHover, onDefinition: onEditorDefinition, onSave: onEditorSave, onExit: onEditorExit, onState: onEditorState, ...(guideStep && openFile === guideStep.file ? { guide: { row: guideStep.line - 1, code: guideStep.code }, onGuideDone } : {}) }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { ...(tabStrip && !diff ? { tabs: tabStrip } : {}), width: codeWidth, height: heights.code, focused: focus === "code", filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : diffMode === "session" ? sessionView?.label ?? "this session" : "Nou's edit", pending: !!pendingChange && diff === pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange && diff === pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, wheel: wheelFor("code"), scrollTo: scrollFor("code"), onViewport: onViewport, defineAt: codeDefine, onDefine: onCodeDefine, onFinding: onCodeFinding, findRequest: findRequest, foldClick: foldClick, foldAll: foldAll })), _jsx(Chat, { width: chatWidth, height: heights.chat, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, wheel: wheelFor("chat"), scrollTo: scrollFor("chat"), prefill: chatPrefill, extraCommands: pluginCommands, onMenu: onChatMenu, onSubmit: onSend, queued: queued, sendNowFirst: settings.sendNowWhileBusy, onSendNow: sendNow, onAnswer: onAnswer, click: chatClick, select: chatSelect, tabs: chatTabsProp(), onOpenRef: openRef })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: C.onBadge, bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: autoMode === "ask" ? C.dim : C.orange, bold: autoMode !== "ask", children: ` ${autoMode === "ask" ? "ask" : autoMode} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: followChip }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
|
|
2306
2497
|
? _jsxs(Text, { color: C.yellow, bold: true, children: [" ", "Press again to quit", draft.current && settings.autoSave === "off" ? ` (unsaved changes in ${draft.current.path} will be lost)` : ""] })
|
|
2307
|
-
: shownHints.map(([key, action], i) => (_jsxs(Text, { children: [i === 0 ? " " : _jsx(Text, { color: C.dim, children: " \u00B7 " }), _jsx(Text, { color: C.text, children: key }), _jsxs(Text, { color: C.dim, children: [" ", action] })] }, key)))] }) }), layout.current.terminal && shellRef.current && (_jsx(Box, { position: "absolute", marginLeft: layout.current.terminal.x, marginTop: layout.current.terminal.y, children: _jsx(Terminal, { shell: shellRef.current, version: termVersion, width: layout.current.terminal.width, height: layout.current.terminal.height, focused: focus === "terminal", wheel: wheelFor("terminal"), onLeave: leaveTerminal, onResize: resizeTerminal }) })), layout.current.game && (_jsx(Box, { position: "absolute", marginLeft: layout.current.game.x, marginTop: layout.current.game.y, children: _jsx(GameGuard, { children: _jsx(GamePanel, { width: layout.current.game.width, height: layout.current.game.height, focused: focus === "game", moving: !!gameDrag, installed: settings.games, best: settings.gameBest ?? {}, onScore: onGameScore, onLeave: leaveGames, onOpenSettings: gamesSettings }) }) })), quickOpen !== null && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, recent: recent, actions: paletteActions, initialQuery: quickOpen, onPick: (file) => {
|
|
2498
|
+
: shownHints.map(([key, action], i) => (_jsxs(Text, { children: [i === 0 ? " " : _jsx(Text, { color: C.dim, children: " \u00B7 " }), _jsx(Text, { color: C.text, children: key }), _jsxs(Text, { color: C.dim, children: [" ", action] })] }, key)))] }) }), layout.current.terminal && shellRef.current && (_jsx(Box, { position: "absolute", marginLeft: layout.current.terminal.x, marginTop: layout.current.terminal.y, children: _jsx(Terminal, { shell: shellRef.current, version: termVersion, width: layout.current.terminal.width, height: layout.current.terminal.height, focused: focus === "terminal", wheel: wheelFor("terminal"), onLeave: leaveTerminal, onClose: () => toggleTerminal(false), onResize: resizeTerminal }) })), layout.current.game && (_jsx(Box, { position: "absolute", marginLeft: layout.current.game.x, marginTop: layout.current.game.y, children: _jsx(GameGuard, { children: _jsx(GamePanel, { width: layout.current.game.width, height: layout.current.game.height, focused: focus === "game", moving: !!gameDrag, installed: settings.games, best: settings.gameBest ?? {}, onScore: onGameScore, onLeave: leaveGames, onOpenSettings: gamesSettings }) }) })), quickOpen !== null && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, recent: recent, actions: resumePicker ?? paletteActions, initialQuery: quickOpen, ...(resumePicker ? { placeholder: "Pick a conversation to resume (type to filter)" } : {}), onPick: (file) => {
|
|
2308
2499
|
setQuickOpen(null);
|
|
2309
2500
|
clearFollow();
|
|
2310
2501
|
open(file);
|
|
@@ -2312,6 +2503,10 @@ ${snippet}
|
|
|
2312
2503
|
setFocus("code");
|
|
2313
2504
|
}, onRun: (action) => {
|
|
2314
2505
|
setQuickOpen(null);
|
|
2506
|
+
setResumePicker(null);
|
|
2315
2507
|
action.run();
|
|
2316
|
-
}, onClose: () =>
|
|
2508
|
+
}, onClose: () => {
|
|
2509
|
+
setQuickOpen(null);
|
|
2510
|
+
setResumePicker(null);
|
|
2511
|
+
} }))] }));
|
|
2317
2512
|
}
|
package/dist/src/ui/Chat.js
CHANGED
|
@@ -100,8 +100,10 @@ export function chatTabZones(tabs) {
|
|
|
100
100
|
return { zones, plus: x + 1, width: x + 3 };
|
|
101
101
|
}
|
|
102
102
|
const tabText = (t) => ` ${t.state === "busy" ? "● " : t.state === "waiting" ? "! " : ""}${t.label} `;
|
|
103
|
+
/** Characters of a word for a double-click: paths and web addresses come whole (src/a.ts, https://x.io/y). */
|
|
104
|
+
const WORD_CHAR = /[\p{L}\p{N}_.\-/@:~#%?=&+]/u;
|
|
103
105
|
const lineText = (line) => (line.spans ? line.spans.map((s) => s.text).join("") : line.text ?? "");
|
|
104
|
-
export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [] }) {
|
|
106
|
+
export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, select, tabs, queued = [], sendNowFirst = false, onSendNow }) {
|
|
105
107
|
const [input, setInputText] = useState("");
|
|
106
108
|
// Whether the chat has the keyboard right now: a key handler from a render before focus moved away can
|
|
107
109
|
// still be attached for a moment (on a busy machine), and its keys belong to the pane that has focus.
|
|
@@ -192,9 +194,11 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
192
194
|
else if (busy) {
|
|
193
195
|
footer.push({ text: `${SPINNER[frame % SPINNER.length]} ${busy}`, color: C.accent2 });
|
|
194
196
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
// Click what's queued (or /now) to stop Nou and send it right away.
|
|
198
|
+
for (const text of queued)
|
|
199
|
+
footer.push({ text: `⏳ next: ${text.replace(/\s+/g, " ")}`, color: C.dim, sendNow: true });
|
|
200
|
+
if (queued.length && busy)
|
|
201
|
+
footer.push({ text: " ↳ click to send now, or /now", color: C.accent2, sendNow: true });
|
|
198
202
|
// Typing "/" opens a menu of commands with what each does; ↑↓ pick one, Tab completes, Enter runs it.
|
|
199
203
|
const menuOpen = focused && !approval && input.startsWith("/") && !input.includes(" ") && !input.includes("\n");
|
|
200
204
|
const matches = useMemo(() => (menuOpen ? matchCommands(input, extraCommands) : []), [menuOpen, input, extraCommands]);
|
|
@@ -224,6 +228,12 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
224
228
|
useEffect(() => {
|
|
225
229
|
if (!click)
|
|
226
230
|
return;
|
|
231
|
+
if (click.row >= rows) {
|
|
232
|
+
// Under the messages: a click on what's queued sends it now.
|
|
233
|
+
if (footer[click.row - rows]?.sendNow)
|
|
234
|
+
onSendNow?.();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
227
237
|
const line = visible[click.row];
|
|
228
238
|
const run = line?.toggle;
|
|
229
239
|
if (run) {
|
|
@@ -252,6 +262,40 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
252
262
|
if (!select)
|
|
253
263
|
return;
|
|
254
264
|
const at = { line: Math.max(0, Math.min(lines.length - 1, firstShown + select.row)), col: Math.max(0, select.col) };
|
|
265
|
+
if (select.kind === "word" || select.kind === "block") {
|
|
266
|
+
anchor.current = null; // the button coming up doesn't change it
|
|
267
|
+
let range;
|
|
268
|
+
if (select.kind === "word") {
|
|
269
|
+
const chars = [...lineText(lines[at.line] ?? { text: "" })];
|
|
270
|
+
if (!WORD_CHAR.test(chars[at.col] ?? ""))
|
|
271
|
+
return;
|
|
272
|
+
let start = at.col;
|
|
273
|
+
let end = at.col;
|
|
274
|
+
while (start > 0 && WORD_CHAR.test(chars[start - 1]))
|
|
275
|
+
start--;
|
|
276
|
+
while (end < chars.length - 1 && WORD_CHAR.test(chars[end + 1]))
|
|
277
|
+
end++;
|
|
278
|
+
// Punctuation that ends a sentence isn't part of the word.
|
|
279
|
+
while (end > start && /[.,:;?!]/.test(chars[end]))
|
|
280
|
+
end--;
|
|
281
|
+
range = { from: { line: at.line, col: start }, to: { line: at.line, col: end } };
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
const blank = (i) => !lineText(lines[i] ?? { text: "" }).trim();
|
|
285
|
+
if (blank(at.line))
|
|
286
|
+
return;
|
|
287
|
+
let first = at.line;
|
|
288
|
+
let last = at.line;
|
|
289
|
+
while (first > 0 && !blank(first - 1))
|
|
290
|
+
first--;
|
|
291
|
+
while (last < lines.length - 1 && !blank(last + 1))
|
|
292
|
+
last++;
|
|
293
|
+
range = { from: { line: first, col: 0 }, to: { line: last, col: Infinity } };
|
|
294
|
+
}
|
|
295
|
+
setSelection(range);
|
|
296
|
+
copySelection(range);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
255
299
|
if (select.kind === "press") {
|
|
256
300
|
anchor.current = at;
|
|
257
301
|
setSelection(null);
|
|
@@ -271,17 +315,22 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
271
315
|
if (select.kind !== "release")
|
|
272
316
|
return;
|
|
273
317
|
anchor.current = null;
|
|
274
|
-
if (
|
|
275
|
-
|
|
318
|
+
if (range)
|
|
319
|
+
copySelection(range);
|
|
320
|
+
}, [select?.nonce]);
|
|
321
|
+
/** Copies the selected text (and says so for a moment). Columns count characters, as the screen shows them. */
|
|
322
|
+
const copySelection = (range) => {
|
|
276
323
|
const text = lines.slice(range.from.line, range.to.line + 1).map((line, i, all) => {
|
|
277
|
-
const t = lineText(line);
|
|
278
|
-
return t.slice(i === 0 ? range.from.col : 0, i === all.length - 1 ? range.to.col + 1 : undefined).trimEnd();
|
|
324
|
+
const t = [...lineText(line)];
|
|
325
|
+
return t.slice(i === 0 ? range.from.col : 0, i === all.length - 1 && Number.isFinite(range.to.col) ? range.to.col + 1 : undefined).join("").trimEnd();
|
|
279
326
|
}).join("\n");
|
|
327
|
+
if (!text)
|
|
328
|
+
return;
|
|
280
329
|
void copyToClipboard(text).then((ok) => {
|
|
281
330
|
setFlash(ok ? `Copied ${text.split("\n").length > 1 ? `${text.split("\n").length} lines` : "it"} ✓` : "Can't copy: press m, then select");
|
|
282
331
|
setTimeout(() => setFlash(null), 2000);
|
|
283
332
|
});
|
|
284
|
-
}
|
|
333
|
+
};
|
|
285
334
|
// Typing or new messages leave the selection be; a new conversation clears it.
|
|
286
335
|
useEffect(() => {
|
|
287
336
|
if (!entries.length)
|
|
@@ -360,5 +409,5 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
|
|
|
360
409
|
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) => {
|
|
361
410
|
const selected = menuStart + i === menuRow;
|
|
362
411
|
return (_jsxs(Text, { wrap: "truncate-end", ...(selected ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: selected ? C.accent2 : C.dim, children: selected ? "▸" : " " }), _jsxs(Text, { color: selected ? C.accent : C.text, bold: selected, children: ["/", c.name] }), c.args && inner >= 56 ? _jsxs(Text, { color: C.dim, children: [" ", c.args] }) : null /* narrow: the description matters more */, _jsxs(Text, { color: C.dim, children: [" ", c.description] })] }, c.name));
|
|
363
|
-
}) })) : (_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, placeholder: busy ? (inner >= 44 ? "Enter queues · Alt+Enter sends now · Esc stops" : "Enter queues · Esc stops") : showMenu ? "↑↓ choose · Tab complete · Enter run" : inner >= 44 ? "Ask anything · / for commands · ↑ history" : "Ask anything · / commands" })] }));
|
|
412
|
+
}) })) : (_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, 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" })] }));
|
|
364
413
|
});
|
package/dist/src/ui/CodeView.js
CHANGED
|
@@ -216,7 +216,7 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
|
|
|
216
216
|
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) || " "] }) }));
|
|
217
217
|
};
|
|
218
218
|
const { icon, color } = fileIcon(diff.path);
|
|
219
|
-
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", diff.path, " ", _jsx(Text, { backgroundColor: pending ? C.yellow : C.accent2, color:
|
|
219
|
+
const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: color, children: icon }), " ", diff.path, " ", _jsx(Text, { backgroundColor: pending ? C.yellow : C.accent2, color: C.onBadge, 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] })] }));
|
|
220
220
|
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 })] }) }));
|
|
221
221
|
}
|
|
222
222
|
if (!filePath) {
|
package/dist/src/ui/Editor.js
CHANGED
|
@@ -635,7 +635,7 @@ export const Editor = memo(function Editor({ width, height, focused, filePath, i
|
|
|
635
635
|
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;
|
|
636
636
|
const hoverLines = hover ? hover.split("\n").filter((l) => l.trim()).slice(0, 6) : [];
|
|
637
637
|
const cursorVisible = buf.row >= v.top && buf.row < v.top + rows;
|
|
638
|
-
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:
|
|
638
|
+
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: " \u270E EDITING " }), !cursorVisible ? _jsxs(Text, { color: C.dim, children: [" cursor on line ", buf.row + 1, " \u00B7 \u2191\u2193 to go back"] }) : null] }), width: width, height: height, focused: focused, accent: C.green, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: width - 3, flexShrink: 0, children: visible.map((line, i) => {
|
|
639
639
|
const n = v.top + i;
|
|
640
640
|
const isCursorLine = n === buf.row;
|
|
641
641
|
const problem = byLine.get(n + 1)?.[0];
|
package/dist/src/ui/GamePanel.js
CHANGED
|
@@ -119,7 +119,7 @@ export const GamePanel = memo(function GamePanel({ width, height, focused, movin
|
|
|
119
119
|
if (view.over && i === Math.floor(FIELD.height / 2)) {
|
|
120
120
|
const msg = ` ${view.over}${newBest ? " New best!" : ""} `;
|
|
121
121
|
const left = Math.max(0, Math.floor((FIELD.width - msg.length) / 2));
|
|
122
|
-
return _jsxs(Text, { children: [line.slice(0, left), _jsx(Text, { backgroundColor: C.yellow, color:
|
|
122
|
+
return _jsxs(Text, { children: [line.slice(0, left), _jsx(Text, { backgroundColor: C.yellow, color: C.onBadge, bold: true, children: msg }), line.slice(left + msg.length)] }, i);
|
|
123
123
|
}
|
|
124
124
|
return _jsx(Row, { line: line, colors: playing.game.colors }, i);
|
|
125
125
|
}), _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)" : "", ": ", GAMES.map((g) => g.title).join(", "), "."] })] })) : (_jsxs(_Fragment, { children: [games.map((g, i) => (_jsxs(Text, { wrap: "truncate-end", ...(i === row && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { color: i === row ? C.accent2 : C.dim, children: i === row ? "▸ " : " " }), _jsx(Text, { color: fits(g) ? C.text : C.dim, bold: i === row, children: g.title }), best[g.id] ? _jsxs(Text, { color: C.yellow, children: [" best ", best[g.id]] }) : null, fits(g) ? null : _jsx(Text, { color: C.dim, children: " (needs more room)" })] }, g.id))), games[row] ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: C.dim, wrap: "wrap", children: games[row].blurb }), _jsx(Text, { color: C.accent2, wrap: "truncate-end", children: games[row].controls })] })) : null] })) })), _jsxs(Text, { color: C.dim, wrap: "truncate-end", children: [" ", hint] })] }));
|