nou-cli 0.5.2 → 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.
@@ -3,8 +3,9 @@ import { memo, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Box, Text, useAnimation, useInput } from "ink";
4
4
  import { statSync } from "fs";
5
5
  import path from "path";
6
- import { linkAt, linkFileRefs, renderMarkdown, wrap } from "./text.js";
6
+ import { closeOpenMarkdown, linkAt, linkFileRefs, renderMarkdown, wrap } from "./text.js";
7
7
  import { ChatInput, inputHeight } from "./ChatInput.js";
8
+ import { copyToClipboard } from "./clipboard.js";
8
9
  import { C, SPINNER } from "./theme.js";
9
10
  import { Pane } from "./Pane.js";
10
11
  import { matchCommands } from "./commands.js";
@@ -70,7 +71,8 @@ function cachedLines(entry, inner) {
70
71
  laidOut.set(entry, { inner, lines });
71
72
  return lines;
72
73
  }
73
- export const Chat = memo(function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, wheel, scrollTo, prefill, extraCommands, onMenu, onSubmit, onAnswer, click, onOpenRef, queued = [] }) {
74
+ const lineText = (line) => (line.spans ? line.spans.map((s) => s.text).join("") : line.text ?? "");
75
+ 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, queued = [] }) {
74
76
  const [input, setInput] = useState("");
75
77
  useEffect(() => {
76
78
  if (prefill)
@@ -80,7 +82,7 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
80
82
  const { frame } = useAnimation({ interval: 80, isActive: !!busy });
81
83
  const inner = width - 3; // minus the borders and the scrollbar
82
84
  const history = useMemo(() => entries.flatMap((e) => cachedLines(e, inner)), [entries, inner]);
83
- const lines = useMemo(() => (streaming ? [...history, ...entryLines({ kind: "assistant", text: streaming }, inner, false)] : history), [history, streaming, inner]);
85
+ const lines = useMemo(() => (streaming ? [...history, ...entryLines({ kind: "assistant", text: closeOpenMarkdown(streaming) }, inner, false)] : history), [history, streaming, inner]);
84
86
  // At the bottom, new output scrolls into view. Scrolled up to read, the view stays where it is
85
87
  // (counted from the bottom, so it moves up by however many lines arrived) until you scroll back down.
86
88
  const lastCount = useRef(lines.length);
@@ -166,6 +168,56 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
166
168
  if (ref)
167
169
  onOpenRef?.(ref);
168
170
  }, [click?.nonce]);
171
+ // Drag over the messages to select them (in lines of the conversation, so it holds while it scrolls);
172
+ // letting go copies them. Dragging past the top or bottom scrolls.
173
+ const [selection, setSelection] = useState(null);
174
+ const [flash, setFlash] = useState(null);
175
+ const anchor = useRef(null);
176
+ useEffect(() => {
177
+ if (!select)
178
+ return;
179
+ const at = { line: Math.max(0, Math.min(lines.length - 1, firstShown + select.row)), col: Math.max(0, select.col) };
180
+ if (select.kind === "press") {
181
+ anchor.current = at;
182
+ setSelection(null);
183
+ return;
184
+ }
185
+ if (!anchor.current)
186
+ return;
187
+ if (select.kind === "drag") {
188
+ if (select.row < 0)
189
+ setScrollBack((b) => Math.min(maxBack, b + 1));
190
+ else if (select.row >= rows)
191
+ setScrollBack((b) => Math.max(0, b - 1));
192
+ }
193
+ const moved = at.line !== anchor.current.line || at.col !== anchor.current.col;
194
+ const range = moved ? (at.line < anchor.current.line || (at.line === anchor.current.line && at.col < anchor.current.col) ? { from: at, to: anchor.current } : { from: anchor.current, to: at }) : null;
195
+ setSelection(range);
196
+ if (select.kind !== "release")
197
+ return;
198
+ anchor.current = null;
199
+ if (!range)
200
+ return;
201
+ const text = lines.slice(range.from.line, range.to.line + 1).map((line, i, all) => {
202
+ const t = lineText(line);
203
+ return t.slice(i === 0 ? range.from.col : 0, i === all.length - 1 ? range.to.col + 1 : undefined).trimEnd();
204
+ }).join("\n");
205
+ void copyToClipboard(text).then((ok) => {
206
+ setFlash(ok ? `Copied ${text.split("\n").length > 1 ? `${text.split("\n").length} lines` : "it"} ✓` : "Can't copy: press m, then select");
207
+ setTimeout(() => setFlash(null), 2000);
208
+ });
209
+ }, [select?.nonce]);
210
+ // Typing or new messages leave the selection be; a new conversation clears it.
211
+ useEffect(() => {
212
+ if (!entries.length)
213
+ setSelection(null);
214
+ }, [entries.length]);
215
+ /** The selected columns of a line, if any. */
216
+ const selectedIn = (index) => {
217
+ if (!selection || index < selection.from.line || index > selection.to.line)
218
+ return null;
219
+ return [index === selection.from.line ? selection.from.col : 0, index === selection.to.line ? selection.to.col + 1 : Infinity];
220
+ };
169
221
  // While something waits for approval, y/n into an empty input answer it; any other key starts a message.
170
222
  useInput((typed) => {
171
223
  if (/^[yYnN]$/.test(typed))
@@ -210,12 +262,18 @@ export const Chat = memo(function Chat({ width, height, focused, entries, stream
210
262
  ? line.spans.map((span, i) => (_jsx(Text, { bold: !!span.bold || !!line.bold, italic: !!span.italic, dimColor: !!span.dim && !span.link, underline: !!span.link, ...(span.link ? { color: C.cyan } : span.code ? { color: C.yellow } : line.color ? { color: line.color } : { color: C.text }), children: span.text }, i)))
211
263
  : _jsx(Text, { bold: !!line.bold, dimColor: !!line.dim, ...(line.color ? { color: line.color } : {}), children: line.text || " " })] }, key));
212
264
  const empty = entries.length === 0 && !streaming;
213
- const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back
265
+ const title = (_jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", flash ? _jsxs(Text, { color: C.green, bold: true, children: [" ", flash] }) : back
214
266
  ? unread
215
267
  ? _jsxs(Text, { color: C.green, bold: true, children: [" \u2193 ", unread, " new lines below", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
216
268
  : _jsxs(Text, { color: C.yellow, children: [" \u2191 scrolled ", back, " lines", _jsx(Text, { color: C.dim, children: " \u00B7 PgDn / wheel / Ctrl+End" })] })
217
269
  : maxBack > 0 ? _jsx(Text, { color: C.dim, children: " PgUp / wheel to scroll" }) : null] }));
218
- return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: empty ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Try:" }), _jsx(Text, { color: C.text, children: " \u201Cexplain this file\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cwhat changed since my last commit?\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cadd error handling here\u201D" }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "I can see the file you have open." })] })) : (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { children: visible.map((line, i) => _jsx(Text, { color: line.leadColor ?? C.dim, children: `${line.lead ?? " "}${i < visible.length - 1 ? "\n" : ""}` }, i)) }) }), _jsx(Box, { flexDirection: "column", width: inner - 2, flexShrink: 0, children: visible.map((line, i) => render(line, firstShown + i, false)) })] })) }), _jsx(Scrollbar, { total: lines.length, top: firstShown, rows: rows, focused: focused })] }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), showMenu ? (_jsx(Box, { flexDirection: "column", children: matches.slice(menuStart, menuStart + menuRows).map((c, i) => {
270
+ return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsxs(Box, { flexDirection: "row", height: rows, children: [_jsx(Box, { flexDirection: "column", width: inner, flexShrink: 0, children: empty ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Try:" }), _jsx(Text, { color: C.text, children: " \u201Cexplain this file\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cwhat changed since my last commit?\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cadd error handling here\u201D" }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "I can see the file you have open." })] })) : (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { children: visible.map((line, i) => _jsx(Text, { color: line.leadColor ?? C.dim, children: `${line.lead ?? " "}${i < visible.length - 1 ? "\n" : ""}` }, i)) }) }), _jsx(Box, { flexDirection: "column", width: inner - 2, flexShrink: 0, children: visible.map((line, i) => {
271
+ const range = selectedIn(firstShown + i);
272
+ if (!range)
273
+ return render(line, firstShown + i, false);
274
+ const t = lineText(line) || " ";
275
+ return (_jsxs(Text, { wrap: "truncate-end", color: C.text, children: [t.slice(0, range[0]), _jsx(Text, { inverse: true, children: t.slice(range[0], range[1]) || " " }), t.slice(range[1])] }, firstShown + i));
276
+ }) })] })) }), _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) => {
219
277
  const selected = menuStart + i === menuRow;
220
278
  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));
221
279
  }) })) : (_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: setInput, 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" })] }));
@@ -78,6 +78,11 @@ export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder
78
78
  if (arrowsTaken && (key.upArrow || key.downArrow))
79
79
  return;
80
80
  if (key.return) {
81
+ // Ctrl/Cmd/Shift+Enter add a line (in terminals that report them); so does \ then Enter, everywhere.
82
+ if (key.ctrl || key.super || key.shift)
83
+ return insert("\n");
84
+ if (value[pos - 1] === "\\")
85
+ return set(`${value.slice(0, pos - 1)}\n${value.slice(pos)}`, pos);
81
86
  setRecall(null);
82
87
  onSubmit(value, key.meta);
83
88
  return;
@@ -181,7 +181,7 @@ export const CodeView = memo(function CodeView({ width, height, focused, filePat
181
181
  ["drag", "a pane's border to resize it"],
182
182
  ["s", "settings: models, keys, plugins"],
183
183
  ];
184
- return (_jsx(Pane, { title: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u2726" }), " Welcome"] }), width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [inner > 62 && artWork.split("\n").filter((l) => l.trim()).map((line, i) => (_jsx(Text, { children: gradient(line, i).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) }, i))), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: C.text, children: "Hello, I am Nou! \uD83C\uDF1E Your terminal coding companion." }), _jsx(Text, { color: C.dim, children: "Ask in the chat on the right. Files I read or edit open here as I work." }), _jsx(Text, { children: " " }), tips.map(([k, d]) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: C.accent, bold: true, children: k.padEnd(10) }), _jsx(Text, { color: C.dim, children: d })] }, k)))] }) }));
184
+ return (_jsx(Pane, { title: _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u2726" }), " Welcome"] }), width: width, height: height, focused: focused, children: _jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, flexShrink: 0, children: [inner > 62 && artWork.split("\n").filter((l) => l.trim()).map((line, i) => (_jsx(Text, { children: gradient(line, i).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) }, i))), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: C.text, children: "Hello, I am Nou! \uD83C\uDF1E Your terminal coding companion." }), _jsx(Text, { color: C.dim, children: "Ask in the chat on the right. Files I read or edit open here as I work." }), _jsx(Text, { children: " " }), tips.map(([k, d]) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: C.accent, bold: true, children: k.padEnd(10) }), _jsx(Text, { color: C.dim, children: d })] }, k)))] }) }));
185
185
  }
186
186
  const gutter = String(lines.length).length;
187
187
  const { icon, color } = fileIcon(filePath);
@@ -2,9 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { memo, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { readdirSync } from "fs";
4
4
  import path from "path";
5
- import { Box, Text, useInput } from "ink";
5
+ import { Box, Text, useAnimation, useInput } from "ink";
6
6
  import { IGNORED } from "../commands/index.js";
7
- import { C, GIT_COLORS, fileIcon } from "./theme.js";
7
+ import { C, GIT_COLORS, fileIcon, gradient } from "./theme.js";
8
8
  import { Pane } from "./Pane.js";
9
9
  import { Scrollbar, topFor } from "./scroll.js";
10
10
  function listDir(dir) {
@@ -31,7 +31,7 @@ function flatten(expanded) {
31
31
  walk(".", 0);
32
32
  return nodes;
33
33
  }
34
- export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, git, problemCounts, wheel, scrollTo, click, onOpen, onHover }) {
34
+ export const FileTree = memo(function FileTree({ width, height, focused, openFile, reveal, version, changed, working, git, problemCounts, wheel, scrollTo, click, onOpen, onHover }) {
35
35
  const [expanded, setExpanded] = useState(() => new Set(["src"]));
36
36
  const [cursor, setCursor] = useState(0);
37
37
  // The first row shown. The wheel moves it on its own; the keyboard moves the cursor and the view follows.
@@ -167,6 +167,7 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
167
167
  else
168
168
  onOpen(node.path);
169
169
  }, [click?.nonce]);
170
+ const { frame } = useAnimation({ interval: 120, isActive: !!working?.size });
170
171
  const inner = width - 3; // minus the borders and the scrollbar
171
172
  const title = changesOnly
172
173
  ? _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.yellow, children: "\u00B1" }), " Changes ", _jsxs(Text, { color: C.dim, children: ["(", nodes.length, ") c: all files"] })] })
@@ -187,6 +188,6 @@ export const FileTree = memo(function FileTree({ width, height, focused, openFil
187
188
  const room = Math.max(1, inner - marker.length - 1);
188
189
  const shown = label.length > room ? `${label.slice(0, room - 1)}…` : label.padEnd(room);
189
190
  const iconAt = shown.indexOf(icon);
190
- return (_jsxs(Text, { wrap: "truncate-end", ...(selected && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, children: shown.slice(0, iconAt) }), _jsx(Text, { color: color, children: shown.slice(iconAt, iconAt + icon.length) }), _jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, inverse: selected && focused, children: shown.slice(iconAt + icon.length) }), _jsxs(Text, { color: markerColor, children: [" ", marker] })] }, node.path));
191
+ return (_jsxs(Text, { wrap: "truncate-end", ...(selected && focused ? { backgroundColor: C.panel } : {}), children: [_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, children: shown.slice(0, iconAt) }), _jsx(Text, { color: color, children: shown.slice(iconAt, iconAt + icon.length) }), working?.has(node.path) ? (_jsx(Text, { bold: true, inverse: selected && focused, children: gradient(shown.slice(iconAt + icon.length), frame).map((g, j) => _jsx(Text, { color: g.color, children: g.ch }, j)) })) : (_jsx(Text, { ...(nameColor ? { color: nameColor } : {}), bold: isOpen, inverse: selected && focused, children: shown.slice(iconAt + icon.length) })), _jsxs(Text, { color: markerColor, children: [" ", marker] })] }, node.path));
191
192
  })] }), _jsx(Scrollbar, { total: nodes.length, top: start, rows: rows, focused: focused })] }) }));
192
193
  });
@@ -3,9 +3,8 @@ import { memo, useEffect, useRef, useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
4
  import { GAMES, gameById } from "../games/index.js";
5
5
  import { C } from "./theme.js";
6
- /** The panel's size on screen, borders included. */
7
- export const GAME_PANEL = { width: 40, height: 19 };
8
- const FIELD = { width: GAME_PANEL.width - 4, height: GAME_PANEL.height - 6 };
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 };
9
8
  const KEYS = { w: "up", s: "down", a: "left", d: "right", k: "up", j: "down", h: "left", l: "right" };
10
9
  /** A row of the playfield, colored per character in runs so it stays a handful of Text nodes. */
11
10
  function Row({ line, colors }) {
@@ -21,10 +20,11 @@ function Row({ line, colors }) {
21
20
  return _jsx(Text, { children: runs.map((r, i) => _jsx(Text, { ...(r.color ? { color: r.color } : {}), children: r.text }, i)) });
22
21
  }
23
22
  /**
24
- * A floating panel for little games. The app places it (it can be dragged by its title bar) and gives it
23
+ * A panel for little games, docked under a pane (drag its title to another pane; × closes it). The app gives it
25
24
  * the keyboard while it's focused; a game only runs while it has the keyboard, so it pauses as you leave.
26
25
  */
27
- export const GamePanel = memo(function GamePanel({ x, y, focused, installed, onLeave, onOpenSettings }) {
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
28
  const games = installed.map(gameById).filter((g) => !!g);
29
29
  const [row, setRow] = useState(0);
30
30
  const [playing, setPlaying] = useState(null);
@@ -42,6 +42,11 @@ export const GamePanel = memo(function GamePanel({ x, y, focused, installed, onL
42
42
  }, playing.game.tickMs);
43
43
  return () => clearInterval(timer);
44
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]);
45
50
  const start = (game) => {
46
51
  inputs.current = [];
47
52
  setPaused(false);
@@ -74,12 +79,14 @@ export const GamePanel = memo(function GamePanel({ x, y, focused, installed, onL
74
79
  if (pressed)
75
80
  inputs.current.push(pressed);
76
81
  }, { isActive: focused });
77
- const color = focused ? C.accent2 : C.dim;
78
- const inner = GAME_PANEL.width - 2;
79
- const pad = (s) => (s.length >= inner ? s.slice(0, inner) : s + " ".repeat(inner - s.length));
80
- const title = playing ? ` ✦ ${playing.game.title}` : " ✦ Games";
81
- const hint = !focused ? "click to play · drag the title to move" : playing ? (view?.over ? "Enter again · q games · Esc leave" : `${paused ? "PAUSED · " : ""}Space pause · q games · Esc leave`) : "↑↓ choose · Enter play · Esc leave";
82
- return (_jsxs(Box, { position: "absolute", top: y, left: x, width: GAME_PANEL.width, height: GAME_PANEL.height, flexDirection: "column", borderStyle: "round", borderColor: color, children: [_jsx(Text, { backgroundColor: C.panel, color: color, bold: true, wrap: "truncate-end", children: pad(`${title} ⠿ drag`) }), playing && view ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, height: FIELD.height + 2, children: [view.lines.map((line, i) => {
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) => {
83
90
  // The end-of-game message goes over the middle of the field.
84
91
  if (view.over && i === Math.floor(FIELD.height / 2)) {
85
92
  const msg = ` ${view.over} `;
@@ -16,8 +16,8 @@ function labels(tabs) {
16
16
  * are left out at the ends, marked with ‹ ›.
17
17
  */
18
18
  export function layoutTabs(tabs, active, width) {
19
- const all = labels(tabs).map((label, i) => ({ file: tabs[i], label }));
20
- const size = (t) => t.label.length + 4 + (t.file === active ? 2 : 0) + 1; // " ◆ name × " and "│"
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
21
  const at = Math.max(0, all.findIndex((t) => t.file === active));
22
22
  let from = at;
23
23
  let to = at + 1;
@@ -39,10 +39,11 @@ export function layoutTabs(tabs, active, width) {
39
39
  let x = from > 0 ? 1 : 0;
40
40
  for (const t of shown) {
41
41
  const w = size(t) - 1;
42
- zones.push({ file: t.file, from: x, to: x + w, close: t.file === active ? x + w - 2 : null });
42
+ zones.push({ file: t.file, from: x, to: x + w, close: x + w - 2 });
43
43
  x += w + 1;
44
44
  }
45
- return { shown, before: from > 0, after: to < all.length, zones };
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 };
46
47
  }
47
48
  /** The open files as tabs, for a pane's title row: the active one highlighted, with × to close it. */
48
49
  export function Tabs({ tabs, active, width, dirty }) {
@@ -50,6 +51,6 @@ export function Tabs({ tabs, active, width, dirty }) {
50
51
  return (_jsxs(_Fragment, { children: [before ? _jsx(Text, { color: C.dim, children: "\u2039" }) : null, shown.map((t) => {
51
52
  const on = t.file === active;
52
53
  const { icon, color } = fileIcon(t.file);
53
- 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] }), on ? _jsxs(Text, { color: dirty ? C.yellow : C.dim, children: [" ", dirty ? "●" : "×"] }) : null, _jsx(Text, { children: " " })] }), _jsx(Text, { color: C.dim, children: "\u2502" })] }, 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));
54
55
  }), after ? _jsx(Text, { color: C.dim, children: "\u203A" }) : null] }));
55
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
+ });
@@ -6,6 +6,7 @@ export const SLASH_COMMANDS = [
6
6
  { name: "settings", description: "models, engine, preferences and plugins" },
7
7
  { name: "auto", args: "[ask|auto-edit|auto]", description: "how much Nou does without asking" },
8
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)" },
9
10
  { name: "undo", description: "undo Nou's last change" },
10
11
  { name: "rewind", description: "undo everything from Nou's last turn" },
11
12
  { name: "diff", args: "[file|n]", description: "everything changed this session (or session n)" },
@@ -15,6 +16,8 @@ export const SLASH_COMMANDS = [
15
16
  { name: "copy", args: "[code|file]", description: "copy the last reply, its last code block, or the open file" },
16
17
  { name: "compact", description: "summarize the conversation to free up context" },
17
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)" },
18
21
  { name: "pull", args: "<model>", description: "download an Ollama model" },
19
22
  { name: "plugins", args: "[add|new <name>]", description: "your plugins and the starter ones" },
20
23
  { name: "mcp", args: "[add|remove|import]", description: "MCP servers: tools from other programs (/mcp add browser for a browser)" },
@@ -8,4 +8,8 @@ export const RENDER_OPTIONS = {
8
8
  incrementalRendering: true,
9
9
  // Up to 60 frames a second, so scrolling and typing keep up with the keyboard and mouse.
10
10
  maxFps: 60,
11
+ // Terminals that speak the kitty keyboard protocol (kitty, Ghostty, WezTerm, iTerm2, foot...) then tell
12
+ // Enter from Ctrl+Enter, Cmd+Enter and Shift+Enter, so those can add a line in the chat. Others just
13
+ // don't answer the query and keys work as before.
14
+ kittyKeyboard: { mode: "auto", flags: ["disambiguateEscapeCodes"] },
11
15
  };
@@ -369,3 +369,19 @@ export function markAnsi(line, from, to, open = "\x1b[7m", close = "\x1b[27m") {
369
369
  }
370
370
  return out;
371
371
  }
372
+ /**
373
+ * A reply still streaming, with its open markdown closed for now (a code block, **bold**, `code`), so it
374
+ * renders the way it will once finished instead of reflowing when the closing marker arrives.
375
+ */
376
+ export function closeOpenMarkdown(text) {
377
+ const fences = text.match(/^\s*(`{3,}|~{3,})/gm)?.length ?? 0;
378
+ if (fences % 2)
379
+ return `${text}\n\`\`\``;
380
+ const last = text.slice(text.lastIndexOf("\n") + 1);
381
+ let out = text;
382
+ if ((last.match(/`/g)?.length ?? 0) % 2)
383
+ out += "`";
384
+ else if ((last.match(/\*\*/g)?.length ?? 0) % 2)
385
+ out += "**";
386
+ return out;
387
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nou-cli",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
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": {