nou-cli 0.5.6 → 0.5.11
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 +14 -7
- 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 +282 -39
- package/dist/src/ui/Chat.js +60 -11
- package/dist/src/ui/CodeView.js +7 -6
- package/dist/src/ui/Editor.js +5 -4
- package/dist/src/ui/FileTree.js +6 -5
- package/dist/src/ui/GamePanel.js +1 -1
- package/dist/src/ui/Header.js +39 -3
- package/dist/src/ui/Pane.js +2 -2
- package/dist/src/ui/ProblemsView.js +3 -3
- package/dist/src/ui/QuickOpen.js +8 -3
- package/dist/src/ui/Settings.js +14 -7
- package/dist/src/ui/Terminal.js +7 -1
- package/dist/src/ui/background.js +58 -0
- package/dist/src/ui/commands.js +46 -6
- package/dist/src/ui/keys.js +8 -0
- 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/commands.js
CHANGED
|
@@ -2,19 +2,59 @@
|
|
|
2
2
|
* Commands that only change what's on screen (or handle a busy Nou themselves): they run right away
|
|
3
3
|
* while Nou works instead of waiting in the queue behind it.
|
|
4
4
|
*/
|
|
5
|
-
const INSTANT = new Set(["fold", "unfold", "redraw", "new", "tab", "close", "terminal", "games", "open", "settings", "auto", "diff", "sessions", "history", "problems", "copy", "help", "version", "cancel"]);
|
|
5
|
+
const INSTANT = new Set(["zoom", "now", "fold", "unfold", "redraw", "new", "tab", "close", "terminal", "games", "open", "settings", "auto", "diff", "sessions", "history", "problems", "copy", "help", "version", "cancel"]);
|
|
6
6
|
/** Whether a typed command runs right away while Nou works (see INSTANT); `/showme` counts only to look or step. */
|
|
7
|
+
/**
|
|
8
|
+
* The command a mistyped name means: the same letters in another order ("/termnial"), or one letter off
|
|
9
|
+
* ("/termial", "/terminall"). Only when exactly one command fits, so a typo never runs the wrong one.
|
|
10
|
+
*/
|
|
11
|
+
export function closestCommand(name, names) {
|
|
12
|
+
const word = name.toLowerCase();
|
|
13
|
+
if (word.length < 3)
|
|
14
|
+
return null;
|
|
15
|
+
const sorted = (s) => [...s].sort().join("");
|
|
16
|
+
const anagrams = names.filter((n) => n.length === word.length && sorted(n) === sorted(word));
|
|
17
|
+
if (anagrams.length === 1)
|
|
18
|
+
return anagrams[0];
|
|
19
|
+
if (anagrams.length > 1)
|
|
20
|
+
return null;
|
|
21
|
+
const near = names.filter((n) => editDistance(word, n) <= 1);
|
|
22
|
+
return near.length === 1 ? near[0] : null;
|
|
23
|
+
}
|
|
24
|
+
/** Levenshtein distance, stopping early once it's over 1 (all closestCommand needs). */
|
|
25
|
+
function editDistance(a, b) {
|
|
26
|
+
if (Math.abs(a.length - b.length) > 1)
|
|
27
|
+
return 2;
|
|
28
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
29
|
+
for (let i = 1; i <= a.length; i++) {
|
|
30
|
+
const row = [i];
|
|
31
|
+
for (let j = 1; j <= b.length; j++)
|
|
32
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
33
|
+
if (Math.min(...row) > 1)
|
|
34
|
+
return 2;
|
|
35
|
+
prev = row;
|
|
36
|
+
}
|
|
37
|
+
return prev[b.length];
|
|
38
|
+
}
|
|
7
39
|
export function runsWhileBusy(text) {
|
|
8
40
|
const [name, ...rest] = text.slice(1).trim().split(/\s+/);
|
|
9
|
-
if (name === "showme" || name === "teach")
|
|
10
|
-
return !rest.length || /^(next|skip|stop|end)$/.test(rest.join(" "));
|
|
11
|
-
|
|
41
|
+
if (name === "showme" || name === "teach" || name === "teachme")
|
|
42
|
+
return !rest.length || /^(next|skip|back|prev|previous|stop|end|done|clear)$/.test(rest.join(" "));
|
|
43
|
+
if (!text.startsWith("/") || !name)
|
|
44
|
+
return false;
|
|
45
|
+
return INSTANT.has(name) || INSTANT.has(closestCommand(name, SLASH_COMMANDS.map((c) => c.name)) ?? "");
|
|
12
46
|
}
|
|
47
|
+
/** Whether a command only changes what's on screen, so running it for a typo can't do harm. */
|
|
48
|
+
export const onlyChangesScreen = (name) => INSTANT.has(name);
|
|
13
49
|
export const SLASH_COMMANDS = [
|
|
14
50
|
{ name: "model", args: "[provider:model]", description: "show or switch the model" },
|
|
15
51
|
{ name: "models", args: "[provider]", description: "list the models a provider offers" },
|
|
16
52
|
{ name: "connect", args: "<provider>", description: "enter an API key (and URL) for a provider" },
|
|
17
53
|
{ name: "open", args: "<file>[:line]", description: "open a file in the code view (part of its name is enough)" },
|
|
54
|
+
{ name: "effort", args: "[fastest|fast|balanced|smarter|smartest]", description: "speed vs smarts: how hard the model thinks (cheaper + faster, or smarter)" },
|
|
55
|
+
{ name: "workspace", args: "[new <folder> | n]", description: "several projects in this window, a tab each in the header: open one, or switch" },
|
|
56
|
+
{ name: "zoom", args: "in|out|reset", description: "make the code panel bigger or smaller (+ and - with the code focused, = resets)" },
|
|
57
|
+
{ name: "now", description: "stop Nou and send your queued messages right away (or click them)" },
|
|
18
58
|
{ name: "settings", description: "models, engine, preferences and plugins" },
|
|
19
59
|
{ name: "auto", args: "[ask|auto-edit|auto]", description: "how much Nou does without asking" },
|
|
20
60
|
{ name: "engine", args: "[name]", description: "which agent answers: nou, claude-code, codex or pi" },
|
|
@@ -28,8 +68,8 @@ export const SLASH_COMMANDS = [
|
|
|
28
68
|
{ name: "copy", args: "[code|file]", description: "copy the last reply, its last code block, or the open file" },
|
|
29
69
|
{ name: "compact", description: "summarize the conversation to free up context" },
|
|
30
70
|
{ name: "clear", description: "start a fresh conversation" },
|
|
31
|
-
{ name: "showme", args: "<
|
|
32
|
-
{ name: "teach", args: "<what you want>", description: "
|
|
71
|
+
{ name: "showme", args: "<question>", description: "Nou explains it and opens the places in the code, highlighted (next/back step through them); any engine" },
|
|
72
|
+
{ name: "teach", args: "<what you want>", description: "Nou walks you through making the change yourself, step by step, with the code shown ahead of your cursor" },
|
|
33
73
|
{ name: "new", description: "a new conversation tab: another agent, working alongside this one" },
|
|
34
74
|
{ name: "tab", args: "[n]", description: "list the conversation tabs, or switch to tab n" },
|
|
35
75
|
{ name: "close", description: "close this conversation tab (it stays saved for /resume)" },
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keys named the way the platform names them. On a Mac, Find is Cmd+F: terminals that report the Cmd key
|
|
3
|
+
* (kitty's keyboard protocol, which Nou asks for: Ghostty, kitty, WezTerm, iTerm2) send it as "super". Ctrl+F
|
|
4
|
+
* works everywhere too, for terminals that keep Cmd for themselves.
|
|
5
|
+
*/
|
|
6
|
+
export const FIND_KEY = process.platform === "darwin" ? "Cmd+F" : "Ctrl+F";
|
|
7
|
+
/** Ctrl+F, or Cmd+F where the terminal reports Cmd. */
|
|
8
|
+
export const isFind = (input, key) => input === "f" && (!!key.ctrl || !!key.super);
|
package/dist/src/ui/theme.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import path from "path";
|
|
2
|
-
/**
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Tokyo Night inspired palette (truecolor; Windows Terminal supports it). Secondary text ("dim") is still at
|
|
4
|
+
* least 4.5:1 against common dark backgrounds, so hints and help lines stay easy to read.
|
|
5
|
+
*/
|
|
6
|
+
const DARK = {
|
|
4
7
|
accent: "#7aa2f7",
|
|
5
8
|
accent2: "#bb9af7",
|
|
6
9
|
cyan: "#7dcfff",
|
|
@@ -9,27 +12,70 @@ export const C = {
|
|
|
9
12
|
orange: "#ff9e64",
|
|
10
13
|
red: "#f7768e",
|
|
11
14
|
text: "#c0caf5",
|
|
12
|
-
dim: "#
|
|
15
|
+
dim: "#8b93c0",
|
|
16
|
+
/** Lines around panes that aren't focused: quieter than text, since they aren't read. */
|
|
17
|
+
border: "#565f89",
|
|
13
18
|
panel: "#1f2335",
|
|
19
|
+
/** Text on a colored badge (CHAT, CLOUD, EDITING...). */
|
|
20
|
+
onBadge: "#1a1b26",
|
|
14
21
|
};
|
|
22
|
+
/** For terminals with a light background: the same roles, dark enough to read on white (4.5:1 or more). */
|
|
23
|
+
const LIGHT = {
|
|
24
|
+
accent: "#2e59c9",
|
|
25
|
+
accent2: "#7341c2",
|
|
26
|
+
cyan: "#007197",
|
|
27
|
+
green: "#3b7a1e",
|
|
28
|
+
yellow: "#8f5e00",
|
|
29
|
+
orange: "#b4501b",
|
|
30
|
+
red: "#c2264b",
|
|
31
|
+
text: "#1f2335",
|
|
32
|
+
dim: "#5a607a",
|
|
33
|
+
border: "#a8aecb",
|
|
34
|
+
panel: "#e6e7ed",
|
|
35
|
+
onBadge: "#ffffff",
|
|
36
|
+
};
|
|
37
|
+
/** The colors in use: dark unless the terminal is light (see setPalette). Read at render time, so set it first. */
|
|
38
|
+
export const C = { ...DARK };
|
|
39
|
+
let current = "dark";
|
|
40
|
+
export const palette = () => current;
|
|
41
|
+
/** Switches every color to the dark or light set. Call before the first render. */
|
|
42
|
+
export function setPalette(which) {
|
|
43
|
+
current = which;
|
|
44
|
+
Object.assign(C, which === "light" ? LIGHT : DARK);
|
|
45
|
+
Object.assign(GIT_COLORS, gitColors());
|
|
46
|
+
for (const [ext, color] of Object.entries(iconColors()))
|
|
47
|
+
ICONS[ext][1] = color;
|
|
48
|
+
}
|
|
15
49
|
/** Every spinner and shimmer ticks on this one interval, so they redraw together in a single frame. */
|
|
16
50
|
export const ANIMATION_MS = 100;
|
|
17
51
|
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
18
|
-
const
|
|
52
|
+
const gradientColors = () => current === "light"
|
|
53
|
+
? [C.accent, "#4b4fc4", C.accent2, "#8a3fb0", "#a8327f", C.red, C.orange, C.yellow, C.green, C.cyan]
|
|
54
|
+
: [C.accent, "#8c9cf7", C.accent2, "#c39af2", "#e08ad6", C.red, C.orange, C.yellow, C.green, C.cyan];
|
|
19
55
|
/** Colors for each character of a label, shifted by `frame` so it shimmers when animated. */
|
|
20
56
|
export function gradient(text, frame = 0) {
|
|
21
|
-
|
|
57
|
+
const colors = gradientColors();
|
|
58
|
+
return [...text].map((ch, i) => ({ ch, color: colors[(i + frame) % colors.length] }));
|
|
22
59
|
}
|
|
23
|
-
|
|
24
|
-
|
|
60
|
+
const gitColors = () => ({ M: C.yellow, A: C.green, "?": C.green, D: C.red, R: C.cyan, U: C.orange });
|
|
61
|
+
export const GIT_COLORS = gitColors();
|
|
62
|
+
/** Each file type's icon color: language colors, with JavaScript's yellow darkened on a light background. */
|
|
63
|
+
const iconColors = () => {
|
|
64
|
+
const js = current === "light" ? "#8a7400" : "#f1e05a";
|
|
65
|
+
return {
|
|
66
|
+
".ts": "#3178c6", ".tsx": C.cyan, ".mts": "#3178c6", ".cts": "#3178c6",
|
|
67
|
+
".js": js, ".jsx": C.cyan, ".mjs": js, ".cjs": js,
|
|
68
|
+
".py": "#3572a5", ".json": C.yellow, ".md": C.text, ".css": C.accent2,
|
|
69
|
+
".html": C.orange, ".yml": C.red, ".yaml": C.red, ".go": C.cyan,
|
|
70
|
+
".rs": C.orange, ".sh": C.green, ".ps1": C.accent, ".lock": C.dim,
|
|
71
|
+
};
|
|
25
72
|
};
|
|
26
|
-
const
|
|
27
|
-
".ts":
|
|
28
|
-
".
|
|
29
|
-
".
|
|
30
|
-
".html": ["◆", C.orange], ".yml": ["◇", C.red], ".yaml": ["◇", C.red], ".go": ["◆", C.cyan],
|
|
31
|
-
".rs": ["◆", C.orange], ".sh": ["$", C.green], ".ps1": ["$", C.accent], ".lock": ["◌", C.dim],
|
|
73
|
+
const ICON_SHAPES = {
|
|
74
|
+
".ts": "◆", ".tsx": "◆", ".mts": "◆", ".cts": "◆", ".js": "◆", ".jsx": "◆", ".mjs": "◆", ".cjs": "◆",
|
|
75
|
+
".py": "◆", ".json": "◇", ".md": "≡", ".css": "◆", ".html": "◆", ".yml": "◇", ".yaml": "◇", ".go": "◆",
|
|
76
|
+
".rs": "◆", ".sh": "$", ".ps1": "$", ".lock": "◌",
|
|
32
77
|
};
|
|
78
|
+
const ICONS = Object.fromEntries(Object.entries(iconColors()).map(([ext, color]) => [ext, [ICON_SHAPES[ext], color]]));
|
|
33
79
|
export function fileIcon(file) {
|
|
34
80
|
const base = path.basename(file).toLowerCase();
|
|
35
81
|
if (base.startsWith(".env"))
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import net from "net";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { useSyncExternalStore } from "react";
|
|
4
|
+
let tabs = [];
|
|
5
|
+
const listeners = new Set();
|
|
6
|
+
let socket = null;
|
|
7
|
+
/** Whether this Nou is one workspace of several (run by the host). */
|
|
8
|
+
export const isHosted = () => !!process.env.NOU_HOST_PORT && !!process.env.NOU_WORKSPACE_ID;
|
|
9
|
+
export function workspaceTabs() {
|
|
10
|
+
return tabs;
|
|
11
|
+
}
|
|
12
|
+
export function useWorkspaceTabs() {
|
|
13
|
+
return useSyncExternalStore((fn) => {
|
|
14
|
+
listeners.add(fn);
|
|
15
|
+
return () => listeners.delete(fn);
|
|
16
|
+
}, workspaceTabs, workspaceTabs);
|
|
17
|
+
}
|
|
18
|
+
/** One JSON message a line. */
|
|
19
|
+
export function lineReader(onMessage) {
|
|
20
|
+
let carry = "";
|
|
21
|
+
return (chunk) => {
|
|
22
|
+
carry += String(chunk);
|
|
23
|
+
let at;
|
|
24
|
+
while ((at = carry.indexOf("\n")) >= 0) {
|
|
25
|
+
const line = carry.slice(0, at);
|
|
26
|
+
carry = carry.slice(at + 1);
|
|
27
|
+
try {
|
|
28
|
+
onMessage(JSON.parse(line));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// not ours
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Connects to the host (when hosted) and passes on what it says. */
|
|
37
|
+
export function connectToHost(on) {
|
|
38
|
+
if (!isHosted() || socket)
|
|
39
|
+
return;
|
|
40
|
+
const s = net.connect(Number(process.env.NOU_HOST_PORT), "127.0.0.1");
|
|
41
|
+
s.unref(); // never what keeps Nou running once you quit it
|
|
42
|
+
socket = s;
|
|
43
|
+
s.on("connect", () => sendToHost({ type: "hello", id: process.env.NOU_WORKSPACE_ID, token: process.env.NOU_HOST_TOKEN ?? "" }));
|
|
44
|
+
s.on("data", lineReader((msg) => {
|
|
45
|
+
if (msg.type === "tabs") {
|
|
46
|
+
tabs = msg.tabs;
|
|
47
|
+
for (const fn of listeners)
|
|
48
|
+
fn();
|
|
49
|
+
}
|
|
50
|
+
else if (msg.type === "redraw")
|
|
51
|
+
on.redraw();
|
|
52
|
+
else if (msg.type === "error")
|
|
53
|
+
on.error(msg.text);
|
|
54
|
+
}));
|
|
55
|
+
s.on("error", () => { });
|
|
56
|
+
s.on("close", () => (socket = null));
|
|
57
|
+
}
|
|
58
|
+
export function sendToHost(msg) {
|
|
59
|
+
if (!socket || socket.destroyed)
|
|
60
|
+
return false;
|
|
61
|
+
socket.write(`${JSON.stringify(msg)}\n`);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
/** What a tab is called: the folder's name. */
|
|
65
|
+
export const tabName = (folder) => path.basename(path.resolve(folder)) || folder;
|
|
66
|
+
/** Set when this Nou should reopen as a workspaces window as it quits (see cli.ts). */
|
|
67
|
+
let reopenAs = null;
|
|
68
|
+
export const reopenAsWorkspaces = (folders) => (reopenAs = folders);
|
|
69
|
+
export const pendingWorkspaces = () => reopenAs;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
2
|
+
import { existsSync, statSync } from "fs";
|
|
3
|
+
import net from "net";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { loadPackage } from "../engines/index.js";
|
|
7
|
+
import { lineReader, tabName } from "./client.js";
|
|
8
|
+
/** The pseudo-terminal package workspaces run in, installed on first use into ~/.nou/engines like an engine. */
|
|
9
|
+
export const PTY_PACKAGE = "@lydell/node-pty";
|
|
10
|
+
/** A folder for a new workspace: `~` and relative paths (from `from`) resolved; null when it isn't a folder. */
|
|
11
|
+
export function workspaceFolder(folder, from) {
|
|
12
|
+
const raw = folder.trim().replace(/^["']|["']$/g, "") || from;
|
|
13
|
+
const full = path.resolve(from, raw.startsWith("~") ? path.join(os.homedir(), raw.slice(1)) : raw);
|
|
14
|
+
try {
|
|
15
|
+
return existsSync(full) && statSync(full).isDirectory() ? full : null;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Runs the workspaces window: each folder becomes a workspace, a whole Nou in its own pseudo-terminal. Only
|
|
23
|
+
* the active one is drawn; the others keep working (their agents too) and draw everything again when you
|
|
24
|
+
* switch to them. `firstArgs` go to the first workspace (e.g. --continue, to pick its conversation back up).
|
|
25
|
+
* Resolves with the exit code once the last workspace is closed.
|
|
26
|
+
*/
|
|
27
|
+
export async function runHost(folders, firstArgs, command = [process.execPath, process.argv[1]]) {
|
|
28
|
+
const mod = await loadPackage(PTY_PACKAGE);
|
|
29
|
+
const spawnPty = mod.spawn ?? mod.default?.spawn;
|
|
30
|
+
const token = randomBytes(16).toString("hex");
|
|
31
|
+
const workspaces = [];
|
|
32
|
+
let active = null;
|
|
33
|
+
let nextId = 1;
|
|
34
|
+
const size = () => ({ cols: process.stdout.columns || 120, rows: process.stdout.rows || 40 });
|
|
35
|
+
const send = (w, msg) => w.socket?.write(`${JSON.stringify(msg)}\n`);
|
|
36
|
+
const tabs = () => workspaces.map((w) => ({ id: w.id, name: tabName(w.folder), folder: w.folder, busy: w.busy, active: w === active }));
|
|
37
|
+
const broadcast = () => {
|
|
38
|
+
const list = tabs();
|
|
39
|
+
for (const w of workspaces)
|
|
40
|
+
send(w, { type: "tabs", tabs: list });
|
|
41
|
+
};
|
|
42
|
+
const activate = (w) => {
|
|
43
|
+
active = w;
|
|
44
|
+
broadcast();
|
|
45
|
+
send(w, { type: "redraw" });
|
|
46
|
+
};
|
|
47
|
+
const server = net.createServer((socket) => {
|
|
48
|
+
socket.on("data", lineReader((msg) => {
|
|
49
|
+
const from = workspaces.find((w) => w.socket === socket);
|
|
50
|
+
if (msg.type === "hello") {
|
|
51
|
+
const w = workspaces.find((x) => x.id === msg.id);
|
|
52
|
+
if (!w || msg.token !== token)
|
|
53
|
+
return socket.destroy(); // only our own workspaces
|
|
54
|
+
w.socket = socket;
|
|
55
|
+
send(w, { type: "tabs", tabs: tabs() });
|
|
56
|
+
if (w === active)
|
|
57
|
+
send(w, { type: "redraw" });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!from)
|
|
61
|
+
return;
|
|
62
|
+
if (msg.type === "state") {
|
|
63
|
+
from.busy = msg.busy;
|
|
64
|
+
broadcast();
|
|
65
|
+
}
|
|
66
|
+
else if (msg.type === "switch") {
|
|
67
|
+
const to = workspaces[msg.index];
|
|
68
|
+
if (to && to !== active)
|
|
69
|
+
activate(to);
|
|
70
|
+
}
|
|
71
|
+
else if (msg.type === "new") {
|
|
72
|
+
const folder = workspaceFolder(msg.folder, from.folder);
|
|
73
|
+
if (!folder)
|
|
74
|
+
return send(from, { type: "error", text: `No such folder: ${msg.folder}` });
|
|
75
|
+
activate(open(folder, []));
|
|
76
|
+
}
|
|
77
|
+
}));
|
|
78
|
+
socket.on("error", () => { });
|
|
79
|
+
});
|
|
80
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
81
|
+
const port = server.address().port;
|
|
82
|
+
let finish = () => { };
|
|
83
|
+
const done = new Promise((resolve) => (finish = resolve));
|
|
84
|
+
function open(folder, args) {
|
|
85
|
+
const id = String(nextId++);
|
|
86
|
+
const { cols, rows } = size();
|
|
87
|
+
const pty = spawnPty(command[0], [...command.slice(1), ...args], {
|
|
88
|
+
name: "xterm-256color",
|
|
89
|
+
cols,
|
|
90
|
+
rows,
|
|
91
|
+
cwd: folder,
|
|
92
|
+
env: { ...process.env, NOU_HOST_PORT: String(port), NOU_HOST_TOKEN: token, NOU_WORKSPACE_ID: id },
|
|
93
|
+
});
|
|
94
|
+
const w = { id, folder, pty, busy: false, socket: null };
|
|
95
|
+
workspaces.push(w);
|
|
96
|
+
pty.onData((data) => {
|
|
97
|
+
if (w === active)
|
|
98
|
+
process.stdout.write(data);
|
|
99
|
+
});
|
|
100
|
+
pty.onExit(({ exitCode }) => {
|
|
101
|
+
const at = workspaces.indexOf(w);
|
|
102
|
+
if (at >= 0)
|
|
103
|
+
workspaces.splice(at, 1);
|
|
104
|
+
w.socket?.destroy();
|
|
105
|
+
if (!workspaces.length)
|
|
106
|
+
return finish(exitCode);
|
|
107
|
+
if (w === active)
|
|
108
|
+
activate(workspaces[Math.min(at, workspaces.length - 1)]);
|
|
109
|
+
else
|
|
110
|
+
broadcast();
|
|
111
|
+
});
|
|
112
|
+
return w;
|
|
113
|
+
}
|
|
114
|
+
// Keys and mouse go to the workspace on screen; a resize goes to all of them.
|
|
115
|
+
const stdin = process.stdin;
|
|
116
|
+
if (stdin.isTTY)
|
|
117
|
+
stdin.setRawMode(true);
|
|
118
|
+
stdin.resume();
|
|
119
|
+
const onInput = (data) => active?.pty.write(data.toString("utf-8"));
|
|
120
|
+
stdin.on("data", onInput);
|
|
121
|
+
const onResize = () => {
|
|
122
|
+
const { cols, rows } = size();
|
|
123
|
+
for (const w of workspaces)
|
|
124
|
+
w.pty.resize(cols, rows);
|
|
125
|
+
};
|
|
126
|
+
process.stdout.on("resize", onResize);
|
|
127
|
+
const killAll = () => {
|
|
128
|
+
for (const w of workspaces)
|
|
129
|
+
w.pty.kill();
|
|
130
|
+
};
|
|
131
|
+
process.on("exit", killAll);
|
|
132
|
+
folders.forEach((folder, i) => open(folder, i === 0 ? firstArgs : []));
|
|
133
|
+
active = workspaces[workspaces.length - 1] ?? null; // the one just opened is the one you asked for
|
|
134
|
+
broadcast();
|
|
135
|
+
const code = await done;
|
|
136
|
+
stdin.off("data", onInput);
|
|
137
|
+
if (stdin.isTTY)
|
|
138
|
+
stdin.setRawMode(false);
|
|
139
|
+
stdin.pause();
|
|
140
|
+
process.stdout.off("resize", onResize);
|
|
141
|
+
process.off("exit", killAll);
|
|
142
|
+
server.close();
|
|
143
|
+
return code;
|
|
144
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nou-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.11",
|
|
4
4
|
"description": "Nou: an IDE-style AI coding assistant for your terminal. Local (Ollama, LM Studio) and cloud (Claude, OpenAI, Gemini...) models, live diffs, git-aware, built-in editor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@eslint/js": "^10.0.1",
|
|
64
|
+
"@lydell/node-pty": "1.2.0-beta.18",
|
|
64
65
|
"@types/node": "^22.20.5",
|
|
65
66
|
"@types/react": "^19.3.0",
|
|
66
67
|
"eslint": "^10.2.0",
|