From 0c12f1673a151ebc93c13146edfe9b8a3578fce5 Mon Sep 17 00:00:00 2001 From: Uka Osim Date: Sat, 3 Oct 2026 16:19:42 +0100 Subject: [PATCH 1/6] feat(cli): add OpenTUI chat interface Add streaming terminal chat with multiline input, cancellation, tool activity, and a plain-chat fallback. Fix renderer mode compatibility and document runtime and FFI requirements. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 79 ++++++++ api/core/library/cliChatTurn.ts | 138 +++++++++++++ api/core/library/cliChatUi.ts | 205 +++++++++++++++++++ api/core/library/cliOpenTui.ts | 340 ++++++++++++++++++++++++++++++++ cli.ts | 223 ++++----------------- package-lock.json | 222 ++++++++++++++++++++- package.json | 4 + scripts/test-cli-opentui.mjs | 81 ++++++++ tests/cliChat.test.ts | 263 ++++++++++++++++++++++++ yarn.lock | 68 ++++++- 10 files changed, 1430 insertions(+), 193 deletions(-) create mode 100644 api/core/library/cliChatTurn.ts create mode 100644 api/core/library/cliChatUi.ts create mode 100644 api/core/library/cliOpenTui.ts create mode 100644 scripts/test-cli-opentui.mjs create mode 100644 tests/cliChat.test.ts diff --git a/README.md b/README.md index f92fb51..bb89419 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,84 @@ OPENROUTER_API_KEY=... npm run cli -- --workspace /path/to/project --chat --mode ``` This starts an interactive streaming chat session in your terminal with access to the workspace and tools. +OpenTUI is selected automatically on supported interactive terminals. With +Node.js >=26.4.0, enable FFI to use it with the same npm launcher: + +```sh +# With OPENROUTER_API_KEY already configured in your terminal +NODE_OPTIONS="--experimental-ffi" npm run cli -- --workspace . --chat + +# Require OpenTUI rather than allowing a fallback to plain chat +NODE_OPTIONS="--experimental-ffi" npm run cli -- --workspace . --chat --chat-ui opentui +``` + +Keep `NODE_OPTIONS="--experimental-ffi"` scoped to the Node.js OpenTUI launch +command rather than exporting it globally. It is not needed for Bun, plain chat, +the HTTP service or ACP, and older Node.js versions may reject the flag. + +Node.js and npm must be on your `PATH`. For a Homebrew Node installation on +Apple Silicon macOS, use `export PATH="/opt/homebrew/bin:$PATH"` if needed. + +#### OpenTUI terminal chat + +Interactive chat uses `@opentui/core` directly, without a React renderer. On a +supported runtime, `--chat` opens a scrollable transcript with streaming +Markdown, tool activity and a multiline message editor. The existing memory +thread, prompt budgeting and workspace tools are reused. + +OpenTUI 0.5.14 requires **Bun >=1.3.0** or **Node.js >=26.4.0 with +`--experimental-ffi`**. The HTTP service, SDK, ACP server and plain chat retain +their Node.js >=22.13.0 requirement. npm may report an engine warning when +installing on an older Node.js; Yarn 1 users on older Node.js must use +`yarn install --ignore-engines`. Keep optional dependencies enabled, because +OpenTUI installs a native library for the host platform. + +```sh +# Source checkout, using Bun +OPENROUTER_API_KEY=... npm run cli:tui -- --workspace /path/to/project + +# Source checkout, using Node.js >=26.4.0 +OPENROUTER_API_KEY=... node --experimental-ffi --import tsx cli.ts --chat --workspace /path/to/project + +# Compiled CLI, using Bun +OPENROUTER_API_KEY=... bun dist/cli.js --chat --workspace /path/to/project + +# Compiled CLI, using Node.js >=26.4.0 +OPENROUTER_API_KEY=... node --experimental-ffi dist/cli.js --chat --workspace /path/to/project + +# Explicitly request OpenTUI, or use the plain interface +bun dist/cli.js --chat --chat-ui opentui +iris-agent --chat --chat-ui plain +``` + +`--chat-ui auto` is the default: it prefers OpenTUI on supported interactive +terminals and selects plain chat for redirected input or output, CI, dumb +terminals or unsupported runtimes. If native UI initialization +fails, auto restores the terminal and falls back to plain chat. Explicit +`--chat-ui opentui` reports the initialization error instead. + +| Key | Action | +| --- | --- | +| Enter | Send a message | +| Shift+Enter, Alt+Enter or Ctrl+J | Insert a newline; Ctrl+J works in terminals that cannot distinguish Shift+Enter | +| Esc | Cancel the current request | +| Ctrl+C | Cancel a running request, or quit while idle | +| Ctrl+D | Quit while idle with an empty editor | +| Page Up / Page Down or mouse wheel | Scroll the transcript | +| Ctrl+L | Toggle captured agent logs | +| `exit` or `quit` | End the session | + +The editor remains available for drafting during a response; sending is disabled +until that response finishes or cancellation settles. Cancellation passes an +abort signal to the agent and cancels the active stream; a tool that does not +honor cancellation may still complete its work. Tool results requiring approval +are labeled accordingly; this UI preserves the existing CLI execution policy. + +Validate the native editor and rendering without provider credentials: + +```sh +npm run test:cli-opentui +``` ### ACP Server @@ -229,6 +307,7 @@ the repository's **Actions** tab. It requires a repository secret named - `--workspace` (`-w`) - Path to the workspace/project root; defaults to the current working directory - `--acp` (`-a`) - Start ACP protocol server (stdio-based) - `--chat` (`-c`) - Start interactive chat mode +- `--chat-ui` - Select `auto` (default), `opentui`, or `plain` - `--modelId` - Model identifier used for chat/ACP sessions (default: `openrouter/openai/gpt-5.3-codex` or `MODEL_ID` / `OPENROUTER_MODEL` env vars) Running the CLI without `--chat` or `--acp` prints help. diff --git a/api/core/library/cliChatTurn.ts b/api/core/library/cliChatTurn.ts new file mode 100644 index 0000000..a83d454 --- /dev/null +++ b/api/core/library/cliChatTurn.ts @@ -0,0 +1,138 @@ +import { resolveToolExecutionStatus } from "../agent/utils/toolLifecycle"; +import type { CliChatUi } from "./cliChatUi"; + +type CliAgentChunk = { type: string; payload?: Record }; +export interface CliChatAgent { + stream?: ( + prompt: string, + options: Record, + ) => Promise<{ + fullStream: ReadableStream; + text?: Promise | string; + }>; + generate?: ( + prompt: string, + options: Record, + ) => Promise; +} + +/** Consume the existing agent protocol; the UI never owns agent execution. */ +export const runCliChatTurn = async ( + agent: CliChatAgent, + prompt: string, + options: Record, + ui: CliChatUi, + signal: AbortSignal, +): Promise => { + signal.throwIfAborted(); + ui.setStatus("Thinking..."); + const turnOptions = { ...options, abortSignal: signal }; + if (typeof agent.stream !== "function") { + if (typeof agent.generate !== "function") { + throw new Error("Agent does not support streaming or text generation"); + } + const result = await agent.generate(prompt, turnOptions); + signal.throwIfAborted(); + const text = + typeof result === "string" + ? result + : result && typeof result === "object" && "text" in result + ? String(result.text || "") + : JSON.stringify(result, null, 2) || ""; + ui.appendText(text); + return; + } + + const streamResult = await agent.stream(prompt, turnOptions); + const reader = streamResult.fullStream.getReader(); + const cancelReader = () => { + void reader.cancel().catch(() => {}); + }; + signal.addEventListener("abort", cancelReader, { once: true }); + let drained = false; + let hasOutput = false; + const pendingIds = new Map(); + let anonymousCalls = 0; + const pendingCount = () => + [...pendingIds.values()].reduce((total, count) => total + count, 0) + + anonymousCalls; + + try { + if (signal.aborted) cancelReader(); + signal.throwIfAborted(); + while (true) { + const { done, value } = await reader.read(); + signal.throwIfAborted(); + if (done) { + drained = true; + break; + } + const payload = value?.payload || {}; + if (value?.type === "text-delta") { + const text = typeof payload.text === "string" ? payload.text : ""; + if (text) { + hasOutput = true; + ui.appendText(text); + if (!pendingCount()) ui.setStatus("Responding..."); + } + } else if (value?.type === "tool-call") { + const name = + typeof payload.toolName === "string" ? payload.toolName : "tool"; + const id = + typeof payload.toolCallId === "string" + ? payload.toolCallId + : undefined; + if (id) pendingIds.set(id, (pendingIds.get(id) || 0) + 1); + else anonymousCalls++; + ui.toolCall(name, id); + ui.setStatus( + pendingCount() > 1 ? "Running tools..." : `Running ${name}...`, + ); + } else if ( + value?.type === "tool-result" || + value?.type === "tool-error" + ) { + const id = + typeof payload.toolCallId === "string" + ? payload.toolCallId + : undefined; + const name = + typeof payload.toolName === "string" ? payload.toolName : undefined; + const status = + value.type === "tool-error" + ? "failed" + : resolveToolExecutionStatus( + payload.result ?? + payload.output ?? + payload.content ?? + payload.data, + ); + ui.toolResult(status, id, name); + if (status !== "pending" && status !== "in_progress") { + if (id) { + const count = pendingIds.get(id) || 0; + if (count > 1) pendingIds.set(id, count - 1); + else pendingIds.delete(id); + } else if (anonymousCalls) anonymousCalls--; + if (!pendingCount()) ui.setStatus("Thinking..."); + } + } else if (value?.type === "error") { + const error = payload.error ?? payload.message; + throw error && typeof error === "object" + ? error + : new Error( + typeof error === "string" ? error : "Agent stream failed", + ); + } + } + if (!hasOutput && streamResult.text) { + const text = await streamResult.text; + signal.throwIfAborted(); + if (text) ui.appendText(text); + } + } finally { + signal.removeEventListener("abort", cancelReader); + if (!drained) await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +}; diff --git a/api/core/library/cliChatUi.ts b/api/core/library/cliChatUi.ts new file mode 100644 index 0000000..2077640 --- /dev/null +++ b/api/core/library/cliChatUi.ts @@ -0,0 +1,205 @@ +import { createInterface } from "node:readline"; +import { isCliSpinnerEnabled, startCliSpinner } from "./cliSpinner"; +import { renderCliMarkdown } from "./cliMarkdown"; +import type { ToolExecutionStatus } from "../agent/utils/toolLifecycle"; + +export type CliChatUiMode = "auto" | "opentui" | "plain"; +export type CliChatTurnOutcome = "completed" | "cancelled" | "failed"; + +export interface CliChatUi { + readInput(): Promise; + beginTurn(input: string, cancel: () => void): void; + appendText(text: string): void; + toolCall(name: string, id?: string): void; + toolResult(status: ToolExecutionStatus, id?: string, name?: string): void; + setStatus(label: string): void; + showError(message: string, hint?: string): void; + finishTurn(outcome: CliChatTurnOutcome): void; + dispose(): void; +} + +export interface CliChatUiOptions { + mode?: CliChatUiMode; + workspaceRoot?: string; + modelId?: string; +} + +export const OPENTUI_RUNTIME_HINT = + "OpenTUI chat needs Bun >=1.3.0, or Node.js >=26.4.0 with --experimental-ffi. " + + "Run bun dist/cli.js --chat, or node --experimental-ffi dist/cli.js --chat. " + + "Use --chat-ui plain for the readline interface."; + +export const getOpenTuiRuntimeError = ( + versions: { node?: string; bun?: string } = process.versions, + execArgv: readonly string[] = process.execArgv, + nodeOptions = process.env.NODE_OPTIONS || "", +): string | undefined => { + const atLeast = ( + version: string | undefined, + major: number, + minor: number, + ) => { + const parts = /^(\d+)\.(\d+)/.exec(version || ""); + return Boolean( + parts && + (+parts[1] > major || (+parts[1] === major && +parts[2] >= minor)), + ); + }; + if (versions.bun) { + return atLeast(versions.bun, 1, 3) ? undefined : OPENTUI_RUNTIME_HINT; + } + const flags = [...execArgv, ...nodeOptions.split(/\s+/)]; + const ffiEnabled = + flags.includes("--experimental-ffi") && + !flags.includes("--no-experimental-ffi"); + return atLeast(versions.node, 26, 4) && ffiEnabled + ? undefined + : OPENTUI_RUNTIME_HINT; +}; + +export const createCliChatUi = async ( + options: CliChatUiOptions, +): Promise => { + const mode = options.mode || "auto"; + const terminal = Boolean(process.stdin.isTTY && process.stdout.isTTY); + if ( + mode === "plain" || + (mode === "auto" && + (!terminal || process.env.CI || process.env.TERM === "dumb")) + ) { + return createPlainChatUi(); + } + if (!terminal) { + throw new Error( + "OpenTUI chat requires an interactive terminal. Use --chat-ui plain for piped input/output.", + ); + } + const runtimeError = getOpenTuiRuntimeError(); + if (runtimeError) { + if (mode === "opentui") throw new Error(runtimeError); + console.warn(`Using plain chat. ${runtimeError}`); + return createPlainChatUi(); + } + try { + // The HTTP service, SDK, ACP transport and plain chat never load native UI code. + const { createOpenTuiChatUi } = await import("./cliOpenTui"); + return await createOpenTuiChatUi(options); + } catch (error) { + if (mode === "opentui") throw error; + const message = error instanceof Error ? error.message : String(error); + console.warn(`Unable to start OpenTUI; using plain chat: ${message}`); + return createPlainChatUi(); + } +}; + +export const createPlainChatUi = ( + input: NodeJS.ReadableStream = process.stdin, + output: NodeJS.WritableStream = process.stdout, +): CliChatUi => { + const rl = createInterface({ input, output }); + const queued: string[] = []; + let pendingInput: ((value: string | null) => void) | undefined; + let ended = false; + let stopped = false; + let markdown = ""; + let cancel: (() => void) | undefined; + let stopSpinner = () => {}; + + const resolveInput = (value: string | null) => { + const resolve = pendingInput; + pendingInput = undefined; + resolve?.(value); + }; + const close = () => { + stopped = true; + queued.length = 0; + cancel?.(); + resolveInput(null); + rl.close(); + }; + const interrupt = () => { + if (cancel) { + cancel(); + } else { + close(); + } + }; + const terminate = () => { + process.exitCode = 143; + close(); + }; + const disconnect = () => close(); + rl.on("line", (line) => { + if (pendingInput) resolveInput(line); + else queued.push(line); + }); + rl.on("close", () => { + ended = true; + resolveInput(null); + }); + rl.on("SIGINT", interrupt); + process.on("SIGINT", interrupt); + process.on("SIGTERM", terminate); + process.on("SIGHUP", disconnect); + + return { + readInput: async () => { + if (stopped) return null; + if (queued.length) return queued.shift()!; + if (ended) return null; + return new Promise((resolve) => { + pendingInput = resolve; + rl.setPrompt("\n> "); + rl.prompt(); + }); + }, + beginTurn: (_input, onCancel) => { + markdown = ""; + cancel = onCancel; + }, + appendText: (text) => { + markdown += text; + }, + toolCall: (name) => { + stopSpinner(); + output.write(`\n⚙️ [Calling tool: ${name}]...\n`); + }, + toolResult: (status) => { + if (status === "pending_confirmation") { + output.write("approval required.\n"); + } else if (status === "failed") { + output.write("failed.\n"); + } else if ( + status !== "pending" && + status !== "in_progress" && + !isCliSpinnerEnabled() + ) { + output.write("done.\n"); + } + }, + setStatus: (label) => { + stopSpinner(); + stopSpinner = startCliSpinner(label); + }, + showError: (message, hint) => { + stopSpinner(); + console.error(`❌ Error: ${message}`); + if (hint) console.error(hint); + }, + finishTurn: (outcome) => { + stopSpinner(); + stopSpinner = () => {}; + cancel = undefined; + if (markdown) output.write(renderCliMarkdown(markdown)); + if (outcome === "cancelled") output.write("\nCancelled.\n"); + output.write("\n"); + }, + dispose: () => { + stopSpinner(); + process.off("SIGINT", interrupt); + process.off("SIGTERM", terminate); + process.off("SIGHUP", disconnect); + close(); + }, + }; +}; diff --git a/api/core/library/cliOpenTui.ts b/api/core/library/cliOpenTui.ts new file mode 100644 index 0000000..9155b9e --- /dev/null +++ b/api/core/library/cliOpenTui.ts @@ -0,0 +1,340 @@ +import { + BoxRenderable, + CliRenderEvents, + MarkdownRenderable, + ScrollBoxRenderable, + SyntaxStyle, + TextareaRenderable, + TextRenderable, + createCliRenderer, + type CliRenderer, + type CliRendererConfig, + type KeyEvent, +} from "@opentui/core"; +import type { CliChatUi, CliChatUiOptions } from "./cliChatUi"; +import type { ToolExecutionStatus } from "../agent/utils/toolLifecycle"; + +type ToolRow = { + id?: string; + name: string; + status: ToolExecutionStatus; + view: TextRenderable; +}; +const theme = { + bg: "#111820", + panel: "#19232e", + fg: "#dde7f1", + muted: "#8c9cae", + accent: "#73c7ec", + error: "#ff8e8e", +}; + +export const chatRendererConfig = { + screenMode: "alternate-screen", + externalOutputMode: "passthrough", + consoleMode: "console-overlay", + openConsoleOnError: false, + exitOnCtrlC: false, + exitSignals: [], + backgroundColor: theme.bg, + targetFps: 30, +} satisfies CliRendererConfig; + +export const createOpenTuiChatUi = async ( + options: CliChatUiOptions, +): Promise => { + const renderer = await createCliRenderer(chatRendererConfig); + try { + return mountOpenTuiChatUi(renderer, options); + } catch (error) { + renderer.destroy(); + throw error; + } +}; + +/** Separate mounting also permits tests with OpenTUI's real in-memory renderer. */ +export const mountOpenTuiChatUi = ( + renderer: CliRenderer, + options: CliChatUiOptions, +): CliChatUi => { + const syntaxStyle = SyntaxStyle.fromStyles({ + default: { fg: theme.fg }, + "markup.heading": { fg: theme.accent, bold: true }, + "markup.strong": { bold: true }, + "markup.italic": { italic: true }, + keyword: { fg: "#b59bea" }, + string: { fg: "#9fd7a3" }, + comment: { fg: theme.muted, italic: true }, + number: { fg: "#edc48e" }, + }); + // DESTROY is emitted before child teardown; release their shared style afterwards. + renderer.once(CliRenderEvents.DESTROY, () => + queueMicrotask(() => syntaxStyle.destroy()), + ); + let closed = false; + let pendingInput: ((input: string | null) => void) | undefined; + let cancel: (() => void) | undefined; + let assistant: MarkdownRenderable | undefined; + let sequence = 0; + let toolRows: ToolRow[] = []; + let busy = false; + + const layout = new BoxRenderable(renderer, { + id: "chat", + width: "100%", + height: "100%", + flexDirection: "column", + paddingX: 1, + }); + const header = new TextRenderable(renderer, { + id: "chat-header", + content: `Iris Agent • ${options.modelId || "default model"}\n${options.workspaceRoot || process.cwd()}`, + fg: theme.accent, + flexShrink: 0, + height: 2, + wrapMode: "none", + }); + const transcript = new ScrollBoxRenderable(renderer, { + id: "chat-transcript", + flexGrow: 1, + flexShrink: 1, + minHeight: 1, + scrollY: true, + scrollX: false, + stickyScroll: true, + stickyStart: "bottom", + contentOptions: { flexDirection: "column", gap: 1, paddingRight: 1 }, + }); + const status = new TextRenderable(renderer, { + id: "chat-status", + content: "Ready", + fg: theme.muted, + height: 1, + flexShrink: 0, + }); + const composerBox = new BoxRenderable(renderer, { + id: "chat-composer-box", + border: true, + borderColor: theme.accent, + backgroundColor: theme.panel, + flexShrink: 0, + paddingX: 1, + }); + const composer = new TextareaRenderable(renderer, { + id: "chat-composer", + minHeight: Math.min(3, Math.max(1, renderer.height - 7)), + maxHeight: Math.max(1, Math.min(8, renderer.height - 7)), + flexGrow: 1, + wrapMode: "word", + textColor: theme.fg, + backgroundColor: theme.panel, + focusedBackgroundColor: theme.panel, + placeholder: "Message Iris…", + cursorStyle: { style: "line", blinking: true }, + selectionOccupancy: "boundary", + keyBindings: [ + { name: "return", action: "submit" }, + { name: "return", shift: true, action: "newline" }, + { name: "return", meta: true, action: "newline" }, + { name: "j", ctrl: true, action: "newline" }, + { name: "linefeed", action: "newline" }, + ], + onSubmit: () => { + if (closed || busy || !pendingInput || !composer.plainText.trim()) return; + const input = composer.plainText; + composer.setText(""); + const resolve = pendingInput; + pendingInput = undefined; + resolve(input); + }, + }); + const help = new TextRenderable(renderer, { + id: "chat-help", + content: + "Enter send · Ctrl+J / Shift+Enter newline · Esc cancel · Ctrl+C cancel/quit · PgUp/PgDn scroll · Ctrl+L logs", + fg: theme.muted, + wrapMode: "none", + height: 1, + flexShrink: 0, + }); + layout.add(header); + layout.add(transcript); + layout.add(status); + composerBox.add(composer); + layout.add(composerBox); + layout.add(help); + renderer.root.add(layout); + composer.focus(); + + const finishAssistant = () => { + if (assistant) assistant.streaming = false; + assistant = undefined; + }; + const addText = (content: string, fg = theme.fg) => { + transcript.add( + new TextRenderable(renderer, { + id: `chat-entry-${++sequence}`, + content, + fg, + wrapMode: "word", + flexShrink: 0, + }), + ); + }; + const close = () => renderer.destroy(); + const interrupt = () => { + if (cancel) { + status.content = "Cancelling..."; + cancel(); + } else close(); + }; + const terminate = () => { + process.exitCode = 143; + close(); + }; + const resize = (_width: number, height: number) => { + composer.minHeight = Math.min(3, Math.max(1, height - 7)); + composer.maxHeight = Math.max(1, Math.min(8, height - 7)); + }; + const cleanup = () => { + if (closed) return; + closed = true; + cancel?.(); + pendingInput?.(null); + pendingInput = undefined; + renderer.keyInput.off("keypress", onKey); + renderer.off(CliRenderEvents.RESIZE, resize); + process.off("SIGINT", interrupt); + process.off("SIGTERM", terminate); + process.off("SIGHUP", close); + }; + const onKey = (key: KeyEvent) => { + if (key.eventType === "release") return; + if (key.name === "escape" && busy) { + key.preventDefault(); + interrupt(); + } else if (key.ctrl && key.name === "c") { + key.preventDefault(); + interrupt(); + } else if (key.ctrl && key.name === "d" && !composer.plainText && !busy) { + key.preventDefault(); + close(); + } else if (key.name === "pageup" || key.name === "pagedown") { + key.preventDefault(); + transcript.scrollBy(key.name === "pageup" ? -1 : 1, "viewport"); + } else if (key.ctrl && key.name === "l") { + key.preventDefault(); + renderer.console.toggle(); + composer.focus(); + } + }; + renderer.keyInput.on("keypress", onKey); + renderer.on(CliRenderEvents.RESIZE, resize); + renderer.once(CliRenderEvents.DESTROY, cleanup); + process.on("SIGINT", interrupt); + process.on("SIGTERM", terminate); + process.on("SIGHUP", close); + + const updateToolRow = (row: ToolRow) => { + const label = + row.status === "pending_confirmation" + ? "approval required" + : row.status === "in_progress" || row.status === "pending" + ? "running" + : row.status; + row.view.content = `⚙ ${row.name} · ${label}`; + row.view.fg = row.status === "failed" ? theme.error : theme.muted; + }; + return { + readInput: () => + closed + ? Promise.resolve(null) + : new Promise((resolve) => { + pendingInput = resolve; + composer.focus(); + }), + beginTurn: (input, onCancel) => { + if (closed) return; + busy = true; + cancel = onCancel; + toolRows = []; + finishAssistant(); + addText(`You\n${input}`, theme.accent); + status.content = "Thinking..."; + transcript.scrollTo(transcript.scrollHeight); + }, + appendText: (text) => { + if (closed) return; + if (!assistant) { + addText("Iris", theme.muted); + assistant = new MarkdownRenderable(renderer, { + id: `chat-entry-${++sequence}`, + content: "", + syntaxStyle, + fg: theme.fg, + streaming: true, + conceal: true, + flexShrink: 0, + }); + transcript.add(assistant); + } + assistant.content += text; + }, + toolCall: (name, id) => { + if (closed) return; + finishAssistant(); + const view = new TextRenderable(renderer, { + id: `chat-entry-${++sequence}`, + fg: theme.muted, + flexShrink: 0, + wrapMode: "word", + }); + const row: ToolRow = { id, name, status: "in_progress", view }; + toolRows.push(row); + transcript.add(view); + updateToolRow(row); + }, + toolResult: (executionStatus, id, name) => { + if (closed) return; + const row = toolRows.find( + (item) => + (id + ? item.id === id + : item.id === undefined && (!name || item.name === name)) && + (item.status === "pending" || item.status === "in_progress"), + ); + if (row) { + row.status = executionStatus; + updateToolRow(row); + } else { + addText( + `⚙ ${name || "tool"} · ${executionStatus}`, + executionStatus === "failed" ? theme.error : theme.muted, + ); + } + }, + setStatus: (label) => { + if (!closed) status.content = label; + }, + showError: (message, hint) => { + if (closed) return; + finishAssistant(); + addText(`Error: ${message}${hint ? `\n${hint}` : ""}`, theme.error); + }, + finishTurn: (outcome) => { + if (closed) return; + finishAssistant(); + for (const row of toolRows) { + if (row.status === "in_progress" || row.status === "pending") { + row.view.content = `⚙ ${row.name} · ${outcome === "cancelled" ? "cancelled" : "no final result"}`; + } + } + if (outcome === "cancelled") addText("Cancelled.", theme.muted); + busy = false; + cancel = undefined; + status.content = "Ready"; + composer.focus(); + }, + dispose: close, + }; +}; diff --git a/cli.ts b/cli.ts index 379e98d..d71d6b0 100644 --- a/cli.ts +++ b/cli.ts @@ -12,12 +12,12 @@ import { hideBin } from "yargs/helpers"; import { createCodingAgent } from "./api/core/agent/index.js"; import { startAcpServer } from "./api/acp/acpServer.js"; import { getMissingProviderSetup } from "./api/core/library/providerSetup.js"; -import { resolveToolExecutionStatus } from "./api/core/agent/utils/toolLifecycle.js"; import { - isCliSpinnerEnabled, - startCliSpinner, -} from "./api/core/library/cliSpinner.js"; -import { renderCliMarkdown } from "./api/core/library/cliMarkdown.js"; + createCliChatUi, + type CliChatUiMode, + type CliChatTurnOutcome, +} from "./api/core/library/cliChatUi.js"; +import { runCliChatTurn } from "./api/core/library/cliChatTurn.js"; import { buildConversationTurn, resolveModelInputTokenLimitAsync, @@ -48,6 +48,11 @@ const argv = yargs(hideBin(process.argv)) description: "Interactive chat mode", default: false, }) + .option("chat-ui", { + choices: ["auto", "opentui", "plain"] as const, + default: "auto", + description: "Chat interface (auto uses OpenTUI on supported interactive terminals)", + }) .option("modelId", { type: "string", description: "Language model identifier", @@ -105,7 +110,7 @@ async function main() { } const agent = await createCodingAgent(modelId, workspaceRoot); console.log(`💬 Entering chat mode (type "exit" to quit)`); - await startChatMode(agent, workspaceRoot, modelId); + await startChatMode(agent, workspaceRoot, modelId, argv["chat-ui"] as CliChatUiMode); } else { // Default: show help yargs(hideBin(process.argv)) @@ -250,209 +255,57 @@ function getCliErrorHint(error: unknown, modelId?: string): string | undefined { return undefined; } -async function startChatMode(agent: any, workspaceRoot?: string, modelId?: string) { - const readline = await import("readline"); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - // Track whether the underlying input stream has ended (e.g. Ctrl+D, piped - // input reaching EOF, or the terminal disconnecting) so we never call - // `rl.question()` again once readline is closed — doing so throws a - // synchronous `ERR_USE_AFTER_CLOSE` ("readline was closed") that would - // otherwise crash the process as an uncaught error. - let rlClosed = false; - rl.on("close", () => { - rlClosed = true; - }); - - const question = (prompt: string) => - new Promise((resolve) => { - if (rlClosed) { - resolve(null); - return; - } - try { - rl.question(prompt, resolve); - } catch { - // Defensive: guards against a race where `rlClosed` hasn't been set - // yet but the interface was closed between the check above and the - // call to `rl.question()`. - resolve(null); - } - }); - +async function startChatMode( + agent: any, + workspaceRoot?: string, + modelId?: string, + mode: CliChatUiMode = "auto", +) { + const ui = await createCliChatUi({ mode, workspaceRoot, modelId }); const threadId = `cli-chat-${Date.now()}`; - const resourceId = `cli-session`; + const resourceId = "cli-session"; try { while (true) { - const input = await question("\n> "); - - if (input === null) { - console.log("\n👋 Goodbye!"); - break; - } - + const input = await ui.readInput(); + if (input === null) break; const trimmedInput = input.trim(); + if (!trimmedInput) continue; + if (["exit", "quit"].includes(trimmedInput.toLowerCase())) break; - if (!trimmedInput) { - continue; - } - - if (trimmedInput.toLowerCase() === "exit" || trimmedInput.toLowerCase() === "quit") { - console.log("👋 Goodbye!"); - break; - } - - let stopSpinner = (): void => {}; + const controller = new AbortController(); + let outcome: CliChatTurnOutcome = "completed"; + ui.beginTurn(input, () => controller.abort()); try { const turn = await buildConversationTurn({ - currentMessage: trimmedInput, + // Preserve indentation and newlines in pasted code and multiline messages. + currentMessage: input, maxPromptTokens: resolvePromptTokenBudget( await resolveModelInputTokenLimitAsync(modelId || defaultModelId), ), maxConversationMessages: 12, maxConversationMessageTokens: 1_200, }); - const options: Record = { - // Use the preferred nested `memory` scope (flat threadId/resourceId - // is deprecated) so conversation history is reliably threaded - // across turns within this chat session. + await runCliChatTurn(agent, turn.prompt, { memory: { thread: threadId, resource: resourceId }, maxSteps: 50, workspaceRoot, - }; - - stopSpinner = startCliSpinner("Thinking..."); - - if (typeof agent.stream === "function") { - const streamResult = await agent.stream(turn.prompt, options); - const reader = streamResult.fullStream.getReader(); - const pendingToolCallIds = new Map(); - let anonymousToolCalls = 0; - let hasOutput = false; - let markdownOutput = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - if (value?.type === "text-delta") { - const text = String(value.payload?.text || ""); - if (text) { - markdownOutput += text; - hasOutput = true; - } - } else if (value?.type === "tool-call") { - stopSpinner(); - process.stdout.write("\n"); - const toolName = - typeof value.payload?.toolName === "string" - ? value.payload.toolName - : "tool"; - const toolCallId = - typeof value.payload?.toolCallId === "string" - ? value.payload.toolCallId - : undefined; - if (toolCallId) { - pendingToolCallIds.set( - toolCallId, - (pendingToolCallIds.get(toolCallId) || 0) + 1, - ); - } else { - anonymousToolCalls += 1; - } - process.stdout.write(`⚙️ [Calling tool: ${toolName}]...\n`); - stopSpinner = startCliSpinner( - pendingToolCallIds.size + anonymousToolCalls > 1 - ? "Running tools..." - : `Running ${toolName}...`, - ); - } else if (value?.type === "tool-result") { - const toolCallId = - typeof value.payload?.toolCallId === "string" - ? value.payload.toolCallId - : undefined; - const toolResultPayload = - value.payload?.result ?? - value.payload?.output ?? - value.payload?.content ?? - value.payload?.data; - const executionStatus = resolveToolExecutionStatus( - toolResultPayload, - ); - const isSettled = - executionStatus !== "pending" && - executionStatus !== "in_progress"; - - if (isSettled) { - if (toolCallId) { - const pendingCount = pendingToolCallIds.get(toolCallId) || 0; - if (pendingCount > 1) { - pendingToolCallIds.set(toolCallId, pendingCount - 1); - } else if (pendingCount === 1) { - pendingToolCallIds.delete(toolCallId); - } - } else if (anonymousToolCalls > 0) { - anonymousToolCalls -= 1; - } - if (!isCliSpinnerEnabled()) { - // Preserve a completion marker for redirected/CI output - // where the animated spinner itself never renders anything. - process.stdout.write("done.\n"); - } - } - const pendingToolCount = - [...pendingToolCallIds.values()].reduce( - (total, count) => total + count, - 0, - ) + anonymousToolCalls; - if (pendingToolCount === 0) { - stopSpinner(); - stopSpinner = startCliSpinner("Thinking..."); - } - } - } - - stopSpinner(); - if (hasOutput) { - process.stdout.write(renderCliMarkdown(markdownOutput)); - } else if (streamResult.text) { - const final = await streamResult.text; - if (final) { - process.stdout.write(renderCliMarkdown(final)); - } - } - console.log(); - } else if (typeof agent.generate === "function") { - const result = await agent.generate(turn.prompt, options); - stopSpinner(); - const text = - typeof result === "string" - ? result - : result?.text || JSON.stringify(result, null, 2); - console.log("\n✅ Agent Response:"); - process.stdout.write(renderCliMarkdown(text)); - } else { - throw new Error("Agent does not support streaming or text generation"); - } + }, ui, controller.signal); } catch (error) { - stopSpinner(); - stopSpinner = () => {}; - console.error(`❌ Error: ${formatCliError(error)}`); - const hint = getCliErrorHint(error, modelId); - if (hint) { - console.error(hint); + if (controller.signal.aborted) { + outcome = "cancelled"; + } else { + outcome = "failed"; + ui.showError(formatCliError(error), getCliErrorHint(error, modelId)); } } finally { - stopSpinner(); + ui.finishTurn(outcome); } } } finally { - rl.close(); + ui.dispose(); } + console.log("👋 Goodbye!"); } /** diff --git a/package-lock.json b/package-lock.json index 7d26230..703b517 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@mastra/memory": "^1.28.1", "@modelcontextprotocol/sdk": "^1.29.0", "@openrouter/ai-sdk-provider": "^2.10.0", + "@opentui/core": "0.5.14", "@tanstack/ai": "^0.58.0", "@tanstack/ai-compaction": "^0.1.6", "ajv": "^6.15.0", @@ -33,6 +34,7 @@ "puppeteer": "^25.9.0", "sqlite": "^5.1.1", "sqlite3": "^6.0.1", + "web-tree-sitter": "0.25.10", "yargs": "^18.0.0", "zod": "^3.25.76" }, @@ -4139,6 +4141,202 @@ "node": ">=8.0.0" } }, + "node_modules/@opentui/core": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core/-/core-0.5.14.tgz", + "integrity": "sha512-tfQ+PWQyeBnYloB3diEcPqbILv14xemH5jjAEICfPuyNDtGBqrjhUtThrhbvFRuPMcj6IEeXrAk6VE8e91h0kg==", + "license": "MIT", + "dependencies": { + "bun-ffi-structs": "0.3.1", + "diff": "9.0.0", + "marked": "17.0.1", + "string-width": "7.2.0", + "strip-ansi": "7.1.2" + }, + "engines": { + "bun": ">=1.3.0", + "node": ">=26.4.0" + }, + "optionalDependencies": { + "@opentui/core-darwin-arm64": "0.5.14", + "@opentui/core-darwin-x64": "0.5.14", + "@opentui/core-linux-arm64": "0.5.14", + "@opentui/core-linux-arm64-musl": "0.5.14", + "@opentui/core-linux-x64": "0.5.14", + "@opentui/core-linux-x64-musl": "0.5.14", + "@opentui/core-win32-arm64": "0.5.14", + "@opentui/core-win32-x64": "0.5.14" + }, + "peerDependencies": { + "web-tree-sitter": "0.25.10" + } + }, + "node_modules/@opentui/core-darwin-arm64": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core-darwin-arm64/-/core-darwin-arm64-0.5.14.tgz", + "integrity": "sha512-wWmw41wRMBoI0lN9mgyzGRwYujwwfNkBP6jYM99k4Bx2XkeSZ3sCeu5bwX9vEJPYqjiBShbDhc0NnVSP7NwzVg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opentui/core-darwin-x64": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core-darwin-x64/-/core-darwin-x64-0.5.14.tgz", + "integrity": "sha512-7smHKDH8IhUaBsgYuAClMl2mHWu+yjpMrtdw3wEvBVtCzKMrHi/TJ5PtydO+IfUzR3BXpVubdbR1irD8BTcR/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@opentui/core-linux-arm64": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64/-/core-linux-arm64-0.5.14.tgz", + "integrity": "sha512-xH1hP+NaLySEJeZkl21NlkZBMddMfQ1jU8NeX1AEBc2GNBOvDXU4Ud/xw87SrAvU1xG9K7/9C4oy4AmIMEpVGg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-linux-arm64-musl": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-arm64-musl/-/core-linux-arm64-musl-0.5.14.tgz", + "integrity": "sha512-mnBBAuTb92NiRLAjOD755tS8/tNQemDztbg9tMvoCT90G52FtVrRb31Ge6OrYqfm0c9DkZGhEBOhunsId/4zSA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-linux-x64": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-x64/-/core-linux-x64-0.5.14.tgz", + "integrity": "sha512-Hkk4kaDGMcn9bmJFJPW3/QOGGbPuWe3sCFV/CkiVb4+bCvcTm7EXQr4QTAA63BYy2dKE5bUFUi1zZlyeMkWpnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-linux-x64-musl": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core-linux-x64-musl/-/core-linux-x64-musl-0.5.14.tgz", + "integrity": "sha512-ngJ+U2grOGEteeQvZdAJNqn09M+At5mfWInauK5aS427bea1yLo+e6hor/CRmbn9SxEEF+SwoyekaOrLPWyU7w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@opentui/core-win32-arm64": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core-win32-arm64/-/core-win32-arm64-0.5.14.tgz", + "integrity": "sha512-T9kNqKXg2jysmTsyyZ1A8LBQotFBM+iPjzyRslxyexqrs8a1UcmxbApEo+COtxQuqukMTbvwyqEdAT8vcmxEkQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opentui/core-win32-x64": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@opentui/core-win32-x64/-/core-win32-x64-0.5.14.tgz", + "integrity": "sha512-mqKSkab8VdMLSmMdocna7+BTyMoIutkVXOV9lfmPrBO2g7Np5c6c4SJ4QIVZqPvast11XyT0/7FfXOYLKAS72w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opentui/core/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@opentui/core/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/@opentui/core/node_modules/marked": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", + "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@opentui/core/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@opentui/core/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/@parcel/watcher": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", @@ -5910,6 +6108,15 @@ "ieee754": "^1.2.1" } }, + "node_modules/bun-ffi-structs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bun-ffi-structs/-/bun-ffi-structs-0.3.1.tgz", + "integrity": "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ==", + "license": "MIT", + "peerDependencies": { + "typescript": "^5" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -12087,7 +12294,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12409,6 +12615,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/web-tree-sitter": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", + "integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==", + "license": "MIT", + "peerDependencies": { + "@types/emscripten": "^1.40.0" + }, + "peerDependenciesMeta": { + "@types/emscripten": { + "optional": true + } + } + }, "node_modules/webdriver-bidi-protocol": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz", diff --git a/package.json b/package.json index 472955c..5a4d138 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,13 @@ "dev": "tsx watch server.ts", "cli": "tsx cli.ts", "cli:dev": "tsx watch cli.ts", + "cli:tui": "bun cli.ts --chat --chat-ui opentui", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "build": "npm run clean && tsc -p tsconfig.build.json && node scripts/fix-esm-import-specifiers.mjs && node scripts/copy-skill-assets.mjs", "prepare": "npm run build", "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.cjs", "test:package-consumer": "npm run build && node scripts/test-package-consumer.mjs", + "test:cli-opentui": "npm run build && bun scripts/test-cli-opentui.mjs", "typecheck": "tsc --noEmit", "homebrew:update": "node scripts/update-homebrew-formula.mjs Formula/iris-agent.rb", "release": "node scripts/release.mjs", @@ -71,6 +73,7 @@ "@mastra/memory": "^1.28.1", "@modelcontextprotocol/sdk": "^1.29.0", "@openrouter/ai-sdk-provider": "^2.10.0", + "@opentui/core": "0.5.14", "@tanstack/ai": "^0.58.0", "@tanstack/ai-compaction": "^0.1.6", "ajv": "^6.15.0", @@ -86,6 +89,7 @@ "puppeteer": "^25.9.0", "sqlite": "^5.1.1", "sqlite3": "^6.0.1", + "web-tree-sitter": "0.25.10", "yargs": "^18.0.0", "zod": "^3.25.76" }, diff --git a/scripts/test-cli-opentui.mjs b/scripts/test-cli-opentui.mjs new file mode 100644 index 0000000..048fddc --- /dev/null +++ b/scripts/test-cli-opentui.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { createTestRenderer } from "@opentui/core/testing"; +import { + chatRendererConfig, + mountOpenTuiChatUi, +} from "../dist/api/core/library/cliOpenTui.js"; + +const signalListeners = process.listenerCount("SIGINT"); +const test = await createTestRenderer({ + ...chatRendererConfig, + width: 100, + height: 28, + kittyKeyboard: true, + exitOnCtrlC: false, + consoleMode: "disabled", +}); +assert.equal(test.renderer.screenMode, "alternate-screen"); +assert.equal(test.renderer.externalOutputMode, "passthrough"); +const ui = mountOpenTuiChatUi(test.renderer, { + modelId: "test/model", + workspaceRoot: "/workspace/test", +}); +try { + const input = ui.readInput(); + await test.mockInput.typeText("first"); + test.mockInput.pressEnter({ shift: true }); + await test.mockInput.typeText(" second"); + test.mockInput.pressKey("j", { ctrl: true }); + await test.mockInput.typeText("third"); + test.mockInput.pressEnter(); + assert.equal(await input, "first\n second\nthird"); + + let cancelled = 0; + ui.beginTurn("first\n second\nthird", () => cancelled++); + ui.appendText("# Streaming response\n\nHello **world**."); + ui.toolCall("readFile", "tool-1"); + ui.toolResult("in_progress", "tool-1"); + ui.toolResult("completed", "tool-1"); + ui.toolCall("executeCommand", "tool-2"); + ui.toolResult("pending_confirmation", "tool-2"); + ui.appendText("\nReady to continue."); + // The real parser worker needs event-loop time, unlike a mocked Tree-sitter client. + let frame = ""; + const deadline = Date.now() + 5_000; + do { + await test.renderOnce(); + frame = test.captureCharFrame(); + if (frame.includes("Streaming response") && frame.includes("Hello world")) + break; + assert.ok( + Date.now() < deadline, + "Markdown did not render before the deadline", + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + } while (true); + assert.match(frame, /Streaming response/); + assert.match(frame, /Hello world/); + assert.match(frame, /readFile.*completed/); + assert.match(frame, /executeCommand.*approval required/); + + // Editing remains possible during a response, but Enter cannot start a second turn. + await test.mockInput.pasteBracketedText("draft\n 😀 next"); + test.mockInput.pressEnter(); + test.mockInput.pressEscape(); + assert.equal(cancelled, 1); + ui.finishTurn("cancelled"); + const draft = ui.readInput(); + test.mockInput.pressEnter(); + assert.equal(await draft, "draft\n 😀 next"); + + test.resize(40, 12); + await test.renderOnce(); + assert.match(test.captureCharFrame(), /Enter send/); + const waiting = ui.readInput(); + ui.dispose(); + assert.equal(await waiting, null); + assert.equal(process.listenerCount("SIGINT"), signalListeners); +} finally { + ui.dispose(); +} +console.log("OpenTUI native chat checks passed."); diff --git a/tests/cliChat.test.ts b/tests/cliChat.test.ts new file mode 100644 index 0000000..fc47b80 --- /dev/null +++ b/tests/cliChat.test.ts @@ -0,0 +1,263 @@ +import { PassThrough } from "node:stream"; +import { + createCliChatUi, + createPlainChatUi, + getOpenTuiRuntimeError, + type CliChatUi, +} from "../api/core/library/cliChatUi"; +import { runCliChatTurn } from "../api/core/library/cliChatTurn"; + +const mockOpenTui = jest.fn(); +jest.mock("../api/core/library/cliOpenTui", () => ({ + createOpenTuiChatUi: (...args: unknown[]) => mockOpenTui(...args), +})); + +const makeUi = (): jest.Mocked => ({ + readInput: jest.fn(), + beginTurn: jest.fn(), + appendText: jest.fn(), + toolCall: jest.fn(), + toolResult: jest.fn(), + setStatus: jest.fn(), + showError: jest.fn(), + finishTurn: jest.fn(), + dispose: jest.fn(), +}); +const chunks = ( + ...values: Array<{ type: string; payload?: Record }> +) => + new ReadableStream({ + start(controller) { + for (const value of values) controller.enqueue(value); + controller.close(); + }, + }); + +describe("CLI chat runtime selection", () => { + it("requires a compatible runtime and Node FFI", () => { + expect(getOpenTuiRuntimeError({ node: "22.13.0" }, [], "")).toContain( + "Bun", + ); + expect( + getOpenTuiRuntimeError({ node: "26.3.0" }, ["--experimental-ffi"], ""), + ).toBeDefined(); + expect(getOpenTuiRuntimeError({ node: "26.4.0" }, [], "")).toBeDefined(); + expect( + getOpenTuiRuntimeError({ node: "26.4.0" }, ["--experimental-ffi"], ""), + ).toBeUndefined(); + expect( + getOpenTuiRuntimeError({ node: "27.0.0" }, [], "--experimental-ffi"), + ).toBeUndefined(); + expect( + getOpenTuiRuntimeError( + { node: "26.4.0" }, + ["--no-experimental-ffi"], + "--experimental-ffi", + ), + ).toBeDefined(); + expect( + getOpenTuiRuntimeError({ node: "24.0.0", bun: "1.3.0" }, [], ""), + ).toBeUndefined(); + expect(getOpenTuiRuntimeError({ bun: "1.2.9" }, [], "")).toBeDefined(); + }); + + it("does not load OpenTUI in plain mode", async () => { + const ui = await createCliChatUi({ mode: "plain" }); + try { + expect(mockOpenTui).not.toHaveBeenCalled(); + } finally { + ui.dispose(); + } + }); +}); + +describe("plain chat input", () => { + it("retains piped messages through EOF", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const ui = createPlainChatUi(input, output); + try { + const first = ui.readInput(); + input.end("first\nsecond\n"); + expect(await first).toBe("first"); + expect(await ui.readInput()).toBe("second"); + expect(await ui.readInput()).toBeNull(); + } finally { + ui.dispose(); + } + }); + + it("settles a waiting prompt when input closes and removes signal listeners", async () => { + const before = process.listenerCount("SIGINT"); + const input = new PassThrough(); + const ui = createPlainChatUi(input, new PassThrough()); + const waiting = ui.readInput(); + input.end(); + expect(await waiting).toBeNull(); + ui.dispose(); + expect(process.listenerCount("SIGINT")).toBe(before); + }); +}); + +describe("CLI agent stream", () => { + it("updates the UI before the stream completes and keeps the memory scope", async () => { + const ui = makeUi(); + const controller = new AbortController(); + let source: ReadableStreamDefaultController; + let resolveDelta: () => void; + const deltaSeen = new Promise((resolve) => { + resolveDelta = resolve; + }); + ui.appendText.mockImplementation(() => resolveDelta()); + const stream = new ReadableStream({ + start(value) { + source = value; + }, + }); + const agent = { stream: jest.fn(async () => ({ fullStream: stream })) }; + const memory = { thread: "same-thread", resource: "cli-session" }; + const turn = runCliChatTurn( + agent, + " code\n next line", + { memory, maxSteps: 50 }, + ui, + controller.signal, + ); + source.enqueue({ + type: "text-delta", + payload: { text: "Partial response" }, + }); + await deltaSeen; + expect(ui.appendText).toHaveBeenCalledWith("Partial response"); + expect(agent.stream).toHaveBeenCalledWith(" code\n next line", { + memory, + maxSteps: 50, + abortSignal: controller.signal, + }); + source.close(); + await turn; + expect(stream.locked).toBe(false); + }); + + it("keeps concurrent calls running through progress results and labels failures/approval", async () => { + const ui = makeUi(); + const stream = chunks( + { + type: "tool-call", + payload: { toolCallId: "same", toolName: "readFile" }, + }, + { + type: "tool-call", + payload: { toolCallId: "same", toolName: "readFile" }, + }, + { + type: "tool-result", + payload: { toolCallId: "same", result: { status: "in_progress" } }, + }, + { + type: "tool-result", + payload: { toolCallId: "same", result: { status: "completed" } }, + }, + { + type: "tool-error", + payload: { toolCallId: "same", toolName: "readFile" }, + }, + { type: "tool-call", payload: { toolName: "executeCommand" } }, + { + type: "tool-result", + payload: { + toolName: "executeCommand", + result: { status: "pending_confirmation" }, + }, + }, + { type: "text-delta", payload: { text: "Done" } }, + ); + await runCliChatTurn( + { stream: async () => ({ fullStream: stream }) }, + "hello", + {}, + ui, + new AbortController().signal, + ); + expect(ui.toolResult.mock.calls.map(([status]) => status)).toEqual([ + "in_progress", + "completed", + "failed", + "pending_confirmation", + ]); + expect(ui.setStatus.mock.calls.map(([status]) => status)).toEqual([ + "Thinking...", + "Running readFile...", + "Running tools...", + "Thinking...", + "Running executeCommand...", + "Thinking...", + "Responding...", + ]); + }); + + it("cancels a blocked read, propagates the abort signal, and releases the reader", async () => { + const cancelStream = jest.fn(); + const stream = new ReadableStream({ cancel: cancelStream }); + const controller = new AbortController(); + const ui = makeUi(); + const turn = runCliChatTurn( + { + stream: async (_prompt, options) => { + expect(options.abortSignal).toBe(controller.signal); + return { fullStream: stream }; + }, + }, + "hello", + {}, + ui, + controller.signal, + ); + await Promise.resolve(); + controller.abort(); + await expect(turn).rejects.toMatchObject({ name: "AbortError" }); + expect(cancelStream).toHaveBeenCalledTimes(1); + expect(ui.appendText).not.toHaveBeenCalled(); + expect(stream.locked).toBe(false); + }); + + it("surfaces provider error chunks and releases the reader on failure", async () => { + const error = { message: "Invalid key", statusCode: 401 }; + const stream = chunks({ type: "error", payload: { error } }); + await expect( + runCliChatTurn( + { stream: async () => ({ fullStream: stream }) }, + "hello", + {}, + makeUi(), + new AbortController().signal, + ), + ).rejects.toBe(error); + expect(stream.locked).toBe(false); + }); + + it("uses final text when there are no deltas and retains generate-only agents", async () => { + const ui = makeUi(); + await runCliChatTurn( + { + stream: async () => ({ + fullStream: chunks(), + text: Promise.resolve("Final text"), + }), + }, + "hello", + {}, + ui, + new AbortController().signal, + ); + expect(ui.appendText).toHaveBeenLastCalledWith("Final text"); + await runCliChatTurn( + { generate: async () => ({ text: "Generated text" }) }, + "hello", + {}, + ui, + new AbortController().signal, + ); + expect(ui.appendText).toHaveBeenLastCalledWith("Generated text"); + }); +}); diff --git a/yarn.lock b/yarn.lock index 8fbd337..bffa362 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1664,6 +1664,31 @@ resolved "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz" integrity sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q== +"@opentui/core-darwin-arm64@0.5.14": + version "0.5.14" + resolved "https://registry.npmjs.org/@opentui/core-darwin-arm64/-/core-darwin-arm64-0.5.14.tgz" + integrity sha512-wWmw41wRMBoI0lN9mgyzGRwYujwwfNkBP6jYM99k4Bx2XkeSZ3sCeu5bwX9vEJPYqjiBShbDhc0NnVSP7NwzVg== + +"@opentui/core@0.5.14": + version "0.5.14" + resolved "https://registry.npmjs.org/@opentui/core/-/core-0.5.14.tgz" + integrity sha512-tfQ+PWQyeBnYloB3diEcPqbILv14xemH5jjAEICfPuyNDtGBqrjhUtThrhbvFRuPMcj6IEeXrAk6VE8e91h0kg== + dependencies: + bun-ffi-structs "0.3.1" + diff "9.0.0" + marked "17.0.1" + string-width "7.2.0" + strip-ansi "7.1.2" + optionalDependencies: + "@opentui/core-darwin-arm64" "0.5.14" + "@opentui/core-darwin-x64" "0.5.14" + "@opentui/core-linux-arm64" "0.5.14" + "@opentui/core-linux-arm64-musl" "0.5.14" + "@opentui/core-linux-x64" "0.5.14" + "@opentui/core-linux-x64-musl" "0.5.14" + "@opentui/core-win32-arm64" "0.5.14" + "@opentui/core-win32-x64" "0.5.14" + "@parcel/watcher-darwin-arm64@2.6.0": version "2.6.0" resolved "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz" @@ -2143,6 +2168,11 @@ ansi-regex@^5.0.1: resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== +ansi-regex@^6.0.1: + version "6.4.0" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz" + integrity sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw== + ansi-regex@^6.1.0: version "6.3.0" resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz" @@ -2410,6 +2440,11 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" +bun-ffi-structs@0.3.1: + version "0.3.1" + resolved "https://registry.npmjs.org/bun-ffi-structs/-/bun-ffi-structs-0.3.1.tgz" + integrity sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ== + bytes@^3.1.2, bytes@~3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz" @@ -2785,7 +2820,7 @@ diff@^8.0.3: resolved "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz" integrity sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw== -diff@^9.0.0: +diff@^9.0.0, diff@9.0.0: version "9.0.0" resolved "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz" integrity sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw== @@ -4146,6 +4181,11 @@ marked@^15.0.12, "marked@>=1 <16": resolved "https://registry.npmjs.org/marked/-/marked-11.2.0.tgz" integrity sha512-HR0m3bvu0jAPYiIvLUUQtdg1g6D247//lvcekpHO1WMvbwDlwSkZAX9Lw4F4YHE1T0HaaNve0tuAWuV1UJ6vtw== +marked@17.0.1: + version "17.0.1" + resolved "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz" + integrity sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg== + math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz" @@ -5584,6 +5624,15 @@ string-width@^8.2.1: get-east-asian-width "^1.5.0" strip-ansi "^7.1.2" +string-width@7.2.0: + version "7.2.0" + resolved "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz" + integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== + dependencies: + emoji-regex "^10.3.0" + get-east-asian-width "^1.0.0" + strip-ansi "^7.1.0" + "strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" @@ -5605,12 +5654,12 @@ strip-ansi@^7.0.1: dependencies: ansi-regex "^6.2.2" -strip-ansi@^7.1.0: - version "7.2.0" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz" - integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== +strip-ansi@^7.1.0, strip-ansi@7.1.2: + version "7.1.2" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz" + integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== dependencies: - ansi-regex "^6.2.2" + ansi-regex "^6.0.1" strip-ansi@^7.1.2: version "7.2.0" @@ -5829,7 +5878,7 @@ typed-query-selector@^2.12.2: resolved "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz" integrity sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ== -typescript@^5.9.3: +typescript@^5, typescript@^5.9.3: version "5.9.3" resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== @@ -6021,6 +6070,11 @@ vfile@^6.0.0: "@types/unist" "^3.0.0" vfile-message "^4.0.0" +web-tree-sitter@0.25.10: + version "0.25.10" + resolved "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz" + integrity sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA== + webdriver-bidi-protocol@0.4.3: version "0.4.3" resolved "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz" From f4ca077761a66b4d3f0248e91c54e95324d1e7ab Mon Sep 17 00:00:00 2001 From: Uka Osim Date: Sun, 4 Oct 2026 06:30:53 +0100 Subject: [PATCH 2/6] fix(cli): preserve optional UI installs and Node FFI precedence Make the OpenTUI stack optional and compile non-TUI features when it is absent. Match Node's last-flag-wins ordering across NODE_OPTIONS and CLI arguments, with regression coverage for both review findings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 19 ++++++++-- api/core/library/cliChatUi.ts | 16 +++++--- package-lock.json | 17 +++++++-- package.json | 11 ++++-- scripts/compile.mjs | 61 ++++++++++++++++++++++++++++++ scripts/test-cli-optional.mjs | 71 +++++++++++++++++++++++++++++++++++ tests/cliChat.test.ts | 13 +++++++ 7 files changed, 192 insertions(+), 16 deletions(-) create mode 100644 scripts/compile.mjs create mode 100644 scripts/test-cli-optional.mjs diff --git a/README.md b/README.md index bb89419..10940bf 100644 --- a/README.md +++ b/README.md @@ -164,10 +164,13 @@ thread, prompt budgeting and workspace tools are reused. OpenTUI 0.5.14 requires **Bun >=1.3.0** or **Node.js >=26.4.0 with `--experimental-ffi`**. The HTTP service, SDK, ACP server and plain chat retain -their Node.js >=22.13.0 requirement. npm may report an engine warning when -installing on an older Node.js; Yarn 1 users on older Node.js must use -`yarn install --ignore-engines`. Keep optional dependencies enabled, because -OpenTUI installs a native library for the host platform. +their Node.js >=22.13.0 requirement. The OpenTUI stack is optional, so unsupported +runtimes can install and use the package without bypassing engine checks. +Keep optional dependencies enabled to use OpenTUI, including its native library +for the host platform. Build release packages with Node.js >=26.4.0 and the +optional dependencies installed so the compiled OpenTUI interface is included. +Source builds without those dependencies explicitly skip the native UI while +still compiling the SDK, service, ACP and plain chat. ```sh # Source checkout, using Bun @@ -216,6 +219,14 @@ Validate the native editor and rendering without provider credentials: npm run test:cli-opentui ``` +Validate compilation and plain chat without the optional UI dependencies: + +```sh +npm run test:cli-optional +# On Node.js 22 with Yarn 1 available, also verify optional engine handling +npm run test:cli-optional -- --check-yarn-install +``` + ### ACP Server ```sh diff --git a/api/core/library/cliChatUi.ts b/api/core/library/cliChatUi.ts index 2077640..62e5930 100644 --- a/api/core/library/cliChatUi.ts +++ b/api/core/library/cliChatUi.ts @@ -48,10 +48,12 @@ export const getOpenTuiRuntimeError = ( if (versions.bun) { return atLeast(versions.bun, 1, 3) ? undefined : OPENTUI_RUNTIME_HINT; } - const flags = [...execArgv, ...nodeOptions.split(/\s+/)]; - const ffiEnabled = - flags.includes("--experimental-ffi") && - !flags.includes("--no-experimental-ffi"); + const flags = [...nodeOptions.split(/\s+/), ...execArgv]; + let ffiEnabled = false; + for (const flag of flags) { + if (flag === "--experimental-ffi") ffiEnabled = true; + else if (flag === "--no-experimental-ffi") ffiEnabled = false; + } return atLeast(versions.node, 26, 4) && ffiEnabled ? undefined : OPENTUI_RUNTIME_HINT; @@ -82,7 +84,11 @@ export const createCliChatUi = async ( } try { // The HTTP service, SDK, ACP transport and plain chat never load native UI code. - const { createOpenTuiChatUi } = await import("./cliOpenTui"); + // Keep optional native code outside the compile-time dependency graph. + const modulePath = "./cliOpenTui.js"; + const { createOpenTuiChatUi }: { + createOpenTuiChatUi(options: CliChatUiOptions): Promise; + } = await import(modulePath); return await createOpenTuiChatUi(options); } catch (error) { if (mode === "opentui") throw error; diff --git a/package-lock.json b/package-lock.json index 703b517..d9455c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "@mastra/memory": "^1.28.1", "@modelcontextprotocol/sdk": "^1.29.0", "@openrouter/ai-sdk-provider": "^2.10.0", - "@opentui/core": "0.5.14", "@tanstack/ai": "^0.58.0", "@tanstack/ai-compaction": "^0.1.6", "ajv": "^6.15.0", @@ -34,7 +33,6 @@ "puppeteer": "^25.9.0", "sqlite": "^5.1.1", "sqlite3": "^6.0.1", - "web-tree-sitter": "0.25.10", "yargs": "^18.0.0", "zod": "^3.25.76" }, @@ -62,6 +60,10 @@ }, "engines": { "node": ">=22.13.0" + }, + "optionalDependencies": { + "@opentui/core": "0.5.14", + "web-tree-sitter": "0.25.10" } }, "node_modules/@a2a-js/sdk-v0_3": { @@ -4146,6 +4148,7 @@ "resolved": "https://registry.npmjs.org/@opentui/core/-/core-0.5.14.tgz", "integrity": "sha512-tfQ+PWQyeBnYloB3diEcPqbILv14xemH5jjAEICfPuyNDtGBqrjhUtThrhbvFRuPMcj6IEeXrAk6VE8e91h0kg==", "license": "MIT", + "optional": true, "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", @@ -4280,6 +4283,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "license": "MIT", + "optional": true, "engines": { "node": ">=12" }, @@ -4291,13 +4295,15 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/@opentui/core/node_modules/marked": { "version": "17.0.1", "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", "license": "MIT", + "optional": true, "bin": { "marked": "bin/marked.js" }, @@ -4310,6 +4316,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", + "optional": true, "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", @@ -4327,6 +4334,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", + "optional": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -6113,6 +6121,7 @@ "resolved": "https://registry.npmjs.org/bun-ffi-structs/-/bun-ffi-structs-0.3.1.tgz", "integrity": "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ==", "license": "MIT", + "optional": true, "peerDependencies": { "typescript": "^5" } @@ -12294,6 +12303,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12620,6 +12630,7 @@ "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", "integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==", "license": "MIT", + "optional": true, "peerDependencies": { "@types/emscripten": "^1.40.0" }, diff --git a/package.json b/package.json index 5a4d138..ec928bc 100644 --- a/package.json +++ b/package.json @@ -41,12 +41,13 @@ "cli:dev": "tsx watch cli.ts", "cli:tui": "bun cli.ts --chat --chat-ui opentui", "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", - "build": "npm run clean && tsc -p tsconfig.build.json && node scripts/fix-esm-import-specifiers.mjs && node scripts/copy-skill-assets.mjs", + "build": "npm run clean && node scripts/compile.mjs --project tsconfig.build.json && node scripts/fix-esm-import-specifiers.mjs && node scripts/copy-skill-assets.mjs", "prepare": "npm run build", "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.cjs", "test:package-consumer": "npm run build && node scripts/test-package-consumer.mjs", "test:cli-opentui": "npm run build && bun scripts/test-cli-opentui.mjs", - "typecheck": "tsc --noEmit", + "test:cli-optional": "node scripts/test-cli-optional.mjs", + "typecheck": "node scripts/compile.mjs --project tsconfig.json --noEmit", "homebrew:update": "node scripts/update-homebrew-formula.mjs Formula/iris-agent.rb", "release": "node scripts/release.mjs", "release:publish": "node scripts/release.mjs --publish" @@ -73,7 +74,6 @@ "@mastra/memory": "^1.28.1", "@modelcontextprotocol/sdk": "^1.29.0", "@openrouter/ai-sdk-provider": "^2.10.0", - "@opentui/core": "0.5.14", "@tanstack/ai": "^0.58.0", "@tanstack/ai-compaction": "^0.1.6", "ajv": "^6.15.0", @@ -89,10 +89,13 @@ "puppeteer": "^25.9.0", "sqlite": "^5.1.1", "sqlite3": "^6.0.1", - "web-tree-sitter": "0.25.10", "yargs": "^18.0.0", "zod": "^3.25.76" }, + "optionalDependencies": { + "@opentui/core": "0.5.14", + "web-tree-sitter": "0.25.10" + }, "devDependencies": { "@babel/core": "^7.29.6", "@babel/plugin-transform-runtime": "^7.29.7", diff --git a/scripts/compile.mjs b/scripts/compile.mjs new file mode 100644 index 0000000..b8ef9f1 --- /dev/null +++ b/scripts/compile.mjs @@ -0,0 +1,61 @@ +import { createRequire } from "node:module"; +import path from "node:path"; +import ts from "typescript"; + +const require = createRequire(import.meta.url); +const args = ts.parseCommandLine(process.argv.slice(2)); +const formatHost = { + getCanonicalFileName: (file) => file, + getCurrentDirectory: ts.sys.getCurrentDirectory, + getNewLine: () => ts.sys.newLine, +}; +const report = (diagnostics) => { + if (diagnostics.length) { + console.error(ts.formatDiagnosticsWithColorAndContext(diagnostics, formatHost)); + } +}; +const configPath = path.resolve(args.options.project || "tsconfig.json"); +const config = ts.readConfigFile(configPath, ts.sys.readFile); +if (config.error) { + report([config.error]); + process.exit(1); +} +const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + path.dirname(configPath), + args.options.noEmit ? { noEmit: true } : {}, + configPath, +); +const configErrors = [...args.errors, ...parsed.errors]; +if (configErrors.length) { + report(configErrors); + process.exitCode = 1; +} else { + let nativeUiAvailable = true; + for (const dependency of ["@opentui/core", "web-tree-sitter"]) { + try { + require.resolve(dependency); + } catch (error) { + if (error.code !== "MODULE_NOT_FOUND") throw error; + nativeUiAvailable = false; + } + } + if (!nativeUiAvailable) { + console.warn( + "Optional OpenTUI dependencies are absent; compiling SDK, service, ACP and plain chat only. " + + "Install optional dependencies with Node.js >=26.4.0 to build OpenTUI.", + ); + } + const nativeUiPath = path.resolve("api/core/library/cliOpenTui.ts"); + const files = parsed.fileNames.filter( + (file) => nativeUiAvailable || path.resolve(file) !== nativeUiPath, + ); + const program = ts.createProgram(files, parsed.options); + const result = program.emit(); + const diagnostics = [...ts.getPreEmitDiagnostics(program), ...result.diagnostics]; + report(diagnostics); + if (result.emitSkipped || diagnostics.some((item) => item.category === ts.DiagnosticCategory.Error)) { + process.exitCode = 1; + } +} diff --git a/scripts/test-cli-optional.mjs b/scripts/test-cli-optional.mjs new file mode 100644 index 0000000..8c1cc00 --- /dev/null +++ b/scripts/test-cli-optional.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const root = await fs.mkdtemp(path.join(os.tmpdir(), "iris-agent-optional-")); +try { + if (process.argv.includes("--check-yarn-install")) { + const installRoot = path.join(root, "yarn-install"); + await fs.mkdir(installRoot); + const manifest = JSON.parse(await fs.readFile(path.join(packageRoot, "package.json"), "utf8")); + await fs.writeFile(path.join(installRoot, "package.json"), JSON.stringify({ + private: true, + license: manifest.license, + engines: manifest.engines, + optionalDependencies: manifest.optionalDependencies, + })); + execFileSync("yarn", ["install", "--ignore-scripts", "--non-interactive", "--no-lockfile"], { + cwd: installRoot, + stdio: "inherit", + }); + const [major, minor] = process.versions.node.split(".").map(Number); + if (major < 26 || (major === 26 && minor < 4)) { + await assert.rejects( + fs.stat(path.join(installRoot, "node_modules/@opentui/core")), + { code: "ENOENT" }, + ); + } + } + for (const entry of [ + "api", "scripts", "tests", "index.ts", "server.ts", "cli.ts", + "package.json", "tsconfig.json", "tsconfig.build.json", + ]) { + await fs.cp(path.join(packageRoot, entry), path.join(root, entry), { recursive: true }); + } + const modules = path.join(root, "node_modules"); + await fs.mkdir(modules); + for (const entry of await fs.readdir(path.join(packageRoot, "node_modules"))) { + if (entry === "@opentui" || entry === "web-tree-sitter") continue; + await fs.symlink(path.join(packageRoot, "node_modules", entry), path.join(modules, entry)); + } + for (const project of ["tsconfig.json", "tsconfig.build.json"]) { + const output = execFileSync(process.execPath, [ + "scripts/compile.mjs", "--project", project, + ...(project === "tsconfig.json" ? ["--noEmit"] : []), + ], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + assert.equal(output, ""); + } + assert.equal( + await fs.stat(path.join(root, "dist/api/core/library/cliOpenTui.js")).then( + () => true, + (error) => { + if (error.code !== "ENOENT") throw error; + return false; + }, + ), + false, + ); + execFileSync(process.execPath, ["scripts/fix-esm-import-specifiers.mjs"], { cwd: root }); + execFileSync(process.execPath, ["--input-type=module", "-e", [ + 'import { createCliChatUi } from "./dist/api/core/library/cliChatUi.js";', + 'const ui = await createCliChatUi({ mode: "plain" });', + "ui.dispose();", + ].join("\n")], { cwd: root, stdio: "inherit" }); + console.log("Build, typecheck and plain chat pass without optional OpenTUI dependencies."); +} finally { + await fs.rm(root, { recursive: true, force: true }); +} diff --git a/tests/cliChat.test.ts b/tests/cliChat.test.ts index fc47b80..5e74141 100644 --- a/tests/cliChat.test.ts +++ b/tests/cliChat.test.ts @@ -69,6 +69,19 @@ describe("CLI chat runtime selection", () => { ui.dispose(); } }); + + it.each([ + ["--no-experimental-ffi", ["--experimental-ffi"], true], + ["--experimental-ffi", ["--no-experimental-ffi"], false], + ["--experimental-ffi --no-experimental-ffi", [], false], + ["--no-experimental-ffi --experimental-ffi", [], true], + ["", ["--no-experimental-ffi", "--experimental-ffi"], true], + ["", ["--experimental-ffi", "--no-experimental-ffi"], false], + ])("applies Node flag precedence for %s and %j", (nodeOptions, execArgv, enabled) => { + expect( + getOpenTuiRuntimeError({ node: "26.4.0" }, execArgv, nodeOptions) === undefined, + ).toBe(enabled); + }); }); describe("plain chat input", () => { From 186a4d79502077eeb8d8baf2cbca1adda71a750b Mon Sep 17 00:00:00 2001 From: Uka Osim Date: Mon, 5 Oct 2026 04:36:47 +0100 Subject: [PATCH 3/6] Fix OpenTUI release and SIGHUP handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/core/library/cliChatUi.ts | 5 ++++- api/core/library/cliOpenTui.ts | 8 ++++++-- scripts/release.mjs | 12 +++++++++++- scripts/test-cli-opentui.mjs | 5 ++++- tests/cliChat.test.ts | 15 +++++++++++++++ 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/api/core/library/cliChatUi.ts b/api/core/library/cliChatUi.ts index 62e5930..b619997 100644 --- a/api/core/library/cliChatUi.ts +++ b/api/core/library/cliChatUi.ts @@ -134,7 +134,10 @@ export const createPlainChatUi = ( process.exitCode = 143; close(); }; - const disconnect = () => close(); + const disconnect = () => { + process.exitCode = 129; + close(); + }; rl.on("line", (line) => { if (pendingInput) resolveInput(line); else queued.push(line); diff --git a/api/core/library/cliOpenTui.ts b/api/core/library/cliOpenTui.ts index 9155b9e..956baa4 100644 --- a/api/core/library/cliOpenTui.ts +++ b/api/core/library/cliOpenTui.ts @@ -192,6 +192,10 @@ export const mountOpenTuiChatUi = ( process.exitCode = 143; close(); }; + const hangup = () => { + process.exitCode = 129; + close(); + }; const resize = (_width: number, height: number) => { composer.minHeight = Math.min(3, Math.max(1, height - 7)); composer.maxHeight = Math.max(1, Math.min(8, height - 7)); @@ -206,7 +210,7 @@ export const mountOpenTuiChatUi = ( renderer.off(CliRenderEvents.RESIZE, resize); process.off("SIGINT", interrupt); process.off("SIGTERM", terminate); - process.off("SIGHUP", close); + process.off("SIGHUP", hangup); }; const onKey = (key: KeyEvent) => { if (key.eventType === "release") return; @@ -233,7 +237,7 @@ export const mountOpenTuiChatUi = ( renderer.once(CliRenderEvents.DESTROY, cleanup); process.on("SIGINT", interrupt); process.on("SIGTERM", terminate); - process.on("SIGHUP", close); + process.on("SIGHUP", hangup); const updateToolRow = (row: ToolRow) => { const label = diff --git a/scripts/release.mjs b/scripts/release.mjs index 0dd1dec..bc6c880 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import process from "node:process"; const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); @@ -99,6 +99,16 @@ console.log(`Preparing ${packageJson.name}@${packageJson.version}`); run("npm", ["run", "typecheck"]); run("npm", ["test", "--", "--runInBand"]); run("npm", ["run", "build"]); +const openTuiArtifact = new URL( + "../dist/api/core/library/cliOpenTui.js", + import.meta.url, +); +if (!existsSync(openTuiArtifact)) { + throw new Error( + "Release build did not emit dist/api/core/library/cliOpenTui.js. " + + "Install optional dependencies before releasing.", + ); +} run("npm", ["pack", "--dry-run"]); if (shouldPublish) { diff --git a/scripts/test-cli-opentui.mjs b/scripts/test-cli-opentui.mjs index 048fddc..1865b25 100644 --- a/scripts/test-cli-opentui.mjs +++ b/scripts/test-cli-opentui.mjs @@ -6,6 +6,7 @@ import { } from "../dist/api/core/library/cliOpenTui.js"; const signalListeners = process.listenerCount("SIGINT"); +const previousExitCode = process.exitCode; const test = await createTestRenderer({ ...chatRendererConfig, width: 100, @@ -72,10 +73,12 @@ try { await test.renderOnce(); assert.match(test.captureCharFrame(), /Enter send/); const waiting = ui.readInput(); - ui.dispose(); + process.emit("SIGHUP"); + assert.equal(process.exitCode, 129); assert.equal(await waiting, null); assert.equal(process.listenerCount("SIGINT"), signalListeners); } finally { ui.dispose(); + process.exitCode = previousExitCode; } console.log("OpenTUI native chat checks passed."); diff --git a/tests/cliChat.test.ts b/tests/cliChat.test.ts index 5e74141..fed61f2 100644 --- a/tests/cliChat.test.ts +++ b/tests/cliChat.test.ts @@ -110,6 +110,21 @@ describe("plain chat input", () => { ui.dispose(); expect(process.listenerCount("SIGINT")).toBe(before); }); + + it("sets a failing exit code when the terminal hangs up", async () => { + const previousExitCode = process.exitCode; + const input = new PassThrough(); + const ui = createPlainChatUi(input, new PassThrough()); + try { + const waiting = ui.readInput(); + process.emit("SIGHUP"); + expect(process.exitCode).toBe(129); + expect(await waiting).toBeNull(); + } finally { + ui.dispose(); + process.exitCode = previousExitCode; + } + }); }); describe("CLI agent stream", () => { From e21790e8e6b3eca1ab9fd573689e2439e8daf312 Mon Sep 17 00:00:00 2001 From: Uka Osim Date: Mon, 5 Oct 2026 04:50:02 +0100 Subject: [PATCH 4/6] Complete OpenTUI Yarn and CI coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 23 +++++++++++++++++++++++ yarn.lock | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe86dd3..52db49c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,30 @@ jobs: run: npm run typecheck - name: Build run: npm run build + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.0 + - name: Native OpenTUI test + run: npm run test:cli-opentui - name: Package consumer compatibility run: npm run test:package-consumer - name: Test run: npm test -- --runInBand --forceExit + yarn-lock: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Install Yarn + run: corepack enable + - name: Install dependencies from the Yarn lockfile + run: yarn install --frozen-lockfile --ignore-scripts --non-interactive diff --git a/yarn.lock b/yarn.lock index bffa362..5338742 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1689,6 +1689,41 @@ "@opentui/core-win32-arm64" "0.5.14" "@opentui/core-win32-x64" "0.5.14" +"@opentui/core-darwin-x64@0.5.14": + version "0.5.14" + resolved "https://registry.npmjs.org/@opentui/core-darwin-x64/-/core-darwin-x64-0.5.14.tgz" + integrity sha512-7smHKDH8IhUaBsgYuAClMl2mHWu+yjpMrtdw3wEvBVtCzKMrHi/TJ5PtydO+IfUzR3BXpVubdbR1irD8BTcR/w== + +"@opentui/core-linux-arm64@0.5.14": + version "0.5.14" + resolved "https://registry.npmjs.org/@opentui/core-linux-arm64/-/core-linux-arm64-0.5.14.tgz" + integrity sha512-xH1hP+NaLySEJeZkl21NlkZBMddMfQ1jU8NeX1AEBc2GNBOvDXU4Ud/xw87SrAvU1xG9K7/9C4oy4AmIMEpVGg== + +"@opentui/core-linux-arm64-musl@0.5.14": + version "0.5.14" + resolved "https://registry.npmjs.org/@opentui/core-linux-arm64-musl/-/core-linux-arm64-musl-0.5.14.tgz" + integrity sha512-mnBBAuTb92NiRLAjOD755tS8/tNQemDztbg9tMvoCT90G52FtVrRb31Ge6OrYqfm0c9DkZGhEBOhunsId/4zSA== + +"@opentui/core-linux-x64@0.5.14": + version "0.5.14" + resolved "https://registry.npmjs.org/@opentui/core-linux-x64/-/core-linux-x64-0.5.14.tgz" + integrity sha512-Hkk4kaDGMcn9bmJFJPW3/QOGGbPuWe3sCFV/CkiVb4+bCvcTm7EXQr4QTAA63BYy2dKE5bUFUi1zZlyeMkWpnA== + +"@opentui/core-linux-x64-musl@0.5.14": + version "0.5.14" + resolved "https://registry.npmjs.org/@opentui/core-linux-x64-musl/-/core-linux-x64-musl-0.5.14.tgz" + integrity sha512-ngJ+U2grOGEteeQvZdAJNqn09M+At5mfWInauK5aS427bea1yLo+e6hor/CRmbn9SxEEF+SwoyekaOrLPWyU7w== + +"@opentui/core-win32-arm64@0.5.14": + version "0.5.14" + resolved "https://registry.npmjs.org/@opentui/core-win32-arm64/-/core-win32-arm64-0.5.14.tgz" + integrity sha512-T9kNqKXg2jysmTsyyZ1A8LBQotFBM+iPjzyRslxyexqrs8a1UcmxbApEo+COtxQuqukMTbvwyqEdAT8vcmxEkQ== + +"@opentui/core-win32-x64@0.5.14": + version "0.5.14" + resolved "https://registry.npmjs.org/@opentui/core-win32-x64/-/core-win32-x64-0.5.14.tgz" + integrity sha512-mqKSkab8VdMLSmMdocna7+BTyMoIutkVXOV9lfmPrBO2g7Np5c6c4SJ4QIVZqPvast11XyT0/7FfXOYLKAS72w== + "@parcel/watcher-darwin-arm64@2.6.0": version "2.6.0" resolved "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz" From d074469f13ca54aa82826beda241af9c56290e92 Mon Sep 17 00:00:00 2001 From: Uka Osim Date: Mon, 5 Oct 2026 05:29:14 +0100 Subject: [PATCH 5/6] Raise minimum Node.js version to 26.4.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 4 +- .github/workflows/update-homebrew.yml | 2 +- .nvmrc | 1 + CONTRIBUTING.md | 2 +- Formula/iris-agent.rb | 4 +- README.md | 14 +-- package-lock.json | 2 +- package.json | 2 +- scripts/test-cli-opentui.mjs | 136 +++++++++++++++----------- scripts/test-cli-optional.mjs | 11 +-- tests/cliChat.test.ts | 2 +- 11 files changed, 100 insertions(+), 80 deletions(-) create mode 100644 .nvmrc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52db49c..617c474 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 26.4.0 cache: npm - name: Install dependencies run: npm ci @@ -48,7 +48,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 26.4.0 - name: Install Yarn run: corepack enable - name: Install dependencies from the Yarn lockfile diff --git a/.github/workflows/update-homebrew.yml b/.github/workflows/update-homebrew.yml index 012962b..a4e5b1e 100644 --- a/.github/workflows/update-homebrew.yml +++ b/.github/workflows/update-homebrew.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 22 + node-version: 26.4.0 - name: Check out Homebrew tap uses: actions/checkout@v4 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..bb6eac9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +26.4.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba3c273..c6f404a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Thank you for helping improve Iris Agent. Requirements: -- Node.js `>=22.13.0` +- Node.js `>=26.4.0` - npm or Yarn ```sh diff --git a/Formula/iris-agent.rb b/Formula/iris-agent.rb index a283715..b56eefa 100644 --- a/Formula/iris-agent.rb +++ b/Formula/iris-agent.rb @@ -5,10 +5,10 @@ class IrisAgent < Formula sha256 "8e2fb39f2cc081feabf6303963157111088adb7dbc8f526c278d0aaedc42a085" license "MIT" - depends_on "node@22" + depends_on "node" def install - ENV["PATH"] = "#{formula_opt_bin("node@22")}:#{ENV["PATH"]}" + ENV["PATH"] = "#{formula_opt_bin("node")}:#{ENV["PATH"]}" system "npm", "install", *std_npm_args bin.install_symlink libexec/"bin/iris-agent" diff --git a/README.md b/README.md index 10940bf..ef73a81 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ It provides streaming chat, workspace tools, LSP routes, MCP integration, comman - **CLI** - Interactive chat in the terminal - **ACP Server** - Agent Client Protocol via stdio for seamless IDE integration -The SDK and ACP server require Node.js `>=22.13.0`. `IrisClient` is a +Iris Agent requires Node.js `>=26.4.0`. `IrisClient` is a Node.js API for IDE desktop or backend processes; it is not intended to run in a browser renderer. @@ -163,9 +163,9 @@ Markdown, tool activity and a multiline message editor. The existing memory thread, prompt budgeting and workspace tools are reused. OpenTUI 0.5.14 requires **Bun >=1.3.0** or **Node.js >=26.4.0 with -`--experimental-ffi`**. The HTTP service, SDK, ACP server and plain chat retain -their Node.js >=22.13.0 requirement. The OpenTUI stack is optional, so unsupported -runtimes can install and use the package without bypassing engine checks. +`--experimental-ffi`**. The HTTP service, SDK, ACP server and plain chat also +require Node.js >=26.4.0. The OpenTUI stack is optional, so it can be omitted +when building or installing without using the native UI. Keep optional dependencies enabled to use OpenTUI, including its native library for the host platform. Build release packages with Node.js >=26.4.0 and the optional dependencies installed so the compiled OpenTUI interface is included. @@ -223,7 +223,7 @@ Validate compilation and plain chat without the optional UI dependencies: ```sh npm run test:cli-optional -# On Node.js 22 with Yarn 1 available, also verify optional engine handling +# On Node.js >=26.4.0 with Yarn 1 available, also verify Yarn installation npm run test:cli-optional -- --check-yarn-install ``` @@ -299,7 +299,7 @@ iris-agent --chat OPENROUTER_API_KEY=... iris-agent --acp ``` -The Homebrew formula installs Node.js 22 and keeps Iris Agent and its +The Homebrew formula installs Node.js and keeps Iris Agent and its dependencies under Homebrew's managed prefix. Upgrade it with: ```sh @@ -934,7 +934,7 @@ NPM_CONFIG_OTP= npm run release:publish `npm run release` requires a clean worktree, runs type checking, tests, build, and `npm pack --dry-run`. `npm run release:publish` performs the same checks -before publishing with public npm access. Use Node.js `>=22.13.0`, matching the +before publishing with public npm access. Use Node.js `>=26.4.0`, matching the package engine requirement. The repository also provides Make targets: diff --git a/package-lock.json b/package-lock.json index d9455c6..feda40d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,7 @@ "typescript": "^5.9.3" }, "engines": { - "node": ">=22.13.0" + "node": ">=26.4.0" }, "optionalDependencies": { "@opentui/core": "0.5.14", diff --git a/package.json b/package.json index ec928bc..762d419 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "access": "public" }, "engines": { - "node": ">=22.13.0" + "node": ">=26.4.0" }, "scripts": { "start": "tsx server.ts", diff --git a/scripts/test-cli-opentui.mjs b/scripts/test-cli-opentui.mjs index 1865b25..82daea9 100644 --- a/scripts/test-cli-opentui.mjs +++ b/scripts/test-cli-opentui.mjs @@ -1,4 +1,6 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import { createTestRenderer } from "@opentui/core/testing"; import { chatRendererConfig, @@ -6,7 +8,6 @@ import { } from "../dist/api/core/library/cliOpenTui.js"; const signalListeners = process.listenerCount("SIGINT"); -const previousExitCode = process.exitCode; const test = await createTestRenderer({ ...chatRendererConfig, width: 100, @@ -21,64 +22,83 @@ const ui = mountOpenTuiChatUi(test.renderer, { modelId: "test/model", workspaceRoot: "/workspace/test", }); -try { - const input = ui.readInput(); - await test.mockInput.typeText("first"); - test.mockInput.pressEnter({ shift: true }); - await test.mockInput.typeText(" second"); - test.mockInput.pressKey("j", { ctrl: true }); - await test.mockInput.typeText("third"); - test.mockInput.pressEnter(); - assert.equal(await input, "first\n second\nthird"); +if (process.argv.includes("--check-sighup")) { + try { + const waiting = ui.readInput(); + process.emit("SIGHUP"); + assert.equal(process.exitCode, 129); + assert.equal(await waiting, null); + assert.equal(process.listenerCount("SIGINT"), signalListeners); + } finally { + ui.dispose(); + } + console.log("OpenTUI SIGHUP handling passed."); +} else { + try { + const input = ui.readInput(); + await test.mockInput.typeText("first"); + test.mockInput.pressEnter({ shift: true }); + await test.mockInput.typeText(" second"); + test.mockInput.pressKey("j", { ctrl: true }); + await test.mockInput.typeText("third"); + test.mockInput.pressEnter(); + assert.equal(await input, "first\n second\nthird"); - let cancelled = 0; - ui.beginTurn("first\n second\nthird", () => cancelled++); - ui.appendText("# Streaming response\n\nHello **world**."); - ui.toolCall("readFile", "tool-1"); - ui.toolResult("in_progress", "tool-1"); - ui.toolResult("completed", "tool-1"); - ui.toolCall("executeCommand", "tool-2"); - ui.toolResult("pending_confirmation", "tool-2"); - ui.appendText("\nReady to continue."); - // The real parser worker needs event-loop time, unlike a mocked Tree-sitter client. - let frame = ""; - const deadline = Date.now() + 5_000; - do { - await test.renderOnce(); - frame = test.captureCharFrame(); - if (frame.includes("Streaming response") && frame.includes("Hello world")) - break; - assert.ok( - Date.now() < deadline, - "Markdown did not render before the deadline", - ); - await new Promise((resolve) => setTimeout(resolve, 20)); - } while (true); - assert.match(frame, /Streaming response/); - assert.match(frame, /Hello world/); - assert.match(frame, /readFile.*completed/); - assert.match(frame, /executeCommand.*approval required/); + let cancelled = 0; + ui.beginTurn("first\n second\nthird", () => cancelled++); + ui.appendText("# Streaming response\n\nHello **world**."); + ui.toolCall("readFile", "tool-1"); + ui.toolResult("in_progress", "tool-1"); + ui.toolResult("completed", "tool-1"); + ui.toolCall("executeCommand", "tool-2"); + ui.toolResult("pending_confirmation", "tool-2"); + ui.appendText("\nReady to continue."); + // The real parser worker needs event-loop time, unlike a mocked Tree-sitter client. + let frame = ""; + const deadline = Date.now() + 5_000; + do { + await test.renderOnce(); + frame = test.captureCharFrame(); + if (frame.includes("Streaming response") && frame.includes("Hello world")) + break; + assert.ok( + Date.now() < deadline, + "Markdown did not render before the deadline", + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + } while (true); + assert.match(frame, /Streaming response/); + assert.match(frame, /Hello world/); + assert.match(frame, /readFile.*completed/); + assert.match(frame, /executeCommand.*approval required/); - // Editing remains possible during a response, but Enter cannot start a second turn. - await test.mockInput.pasteBracketedText("draft\n 😀 next"); - test.mockInput.pressEnter(); - test.mockInput.pressEscape(); - assert.equal(cancelled, 1); - ui.finishTurn("cancelled"); - const draft = ui.readInput(); - test.mockInput.pressEnter(); - assert.equal(await draft, "draft\n 😀 next"); + // Editing remains possible during a response, but Enter cannot start a second turn. + await test.mockInput.pasteBracketedText("draft\n 😀 next"); + test.mockInput.pressEnter(); + test.mockInput.pressEscape(); + assert.equal(cancelled, 1); + ui.finishTurn("cancelled"); + const draft = ui.readInput(); + test.mockInput.pressEnter(); + assert.equal(await draft, "draft\n 😀 next"); - test.resize(40, 12); - await test.renderOnce(); - assert.match(test.captureCharFrame(), /Enter send/); - const waiting = ui.readInput(); - process.emit("SIGHUP"); - assert.equal(process.exitCode, 129); - assert.equal(await waiting, null); - assert.equal(process.listenerCount("SIGINT"), signalListeners); -} finally { - ui.dispose(); - process.exitCode = previousExitCode; + test.resize(40, 12); + await test.renderOnce(); + assert.match(test.captureCharFrame(), /Enter send/); + ui.dispose(); + assert.equal(process.listenerCount("SIGINT"), signalListeners); + } finally { + ui.dispose(); + } + const sighupCheck = spawnSync( + process.execPath, + [fileURLToPath(import.meta.url), "--check-sighup"], + { encoding: "utf8" }, + ); + assert.equal( + sighupCheck.status, + 129, + `Expected the SIGHUP test process to exit 129.\n${sighupCheck.stderr || ""}`, + ); + console.log("OpenTUI native chat checks passed."); } -console.log("OpenTUI native chat checks passed."); diff --git a/scripts/test-cli-optional.mjs b/scripts/test-cli-optional.mjs index 8c1cc00..7a00e19 100644 --- a/scripts/test-cli-optional.mjs +++ b/scripts/test-cli-optional.mjs @@ -23,12 +23,11 @@ try { stdio: "inherit", }); const [major, minor] = process.versions.node.split(".").map(Number); - if (major < 26 || (major === 26 && minor < 4)) { - await assert.rejects( - fs.stat(path.join(installRoot, "node_modules/@opentui/core")), - { code: "ENOENT" }, - ); - } + assert.ok( + major > 26 || (major === 26 && minor >= 4), + "Yarn install validation requires Node.js >=26.4.0.", + ); + await fs.access(path.join(installRoot, "node_modules/@opentui/core")); } for (const entry of [ "api", "scripts", "tests", "index.ts", "server.ts", "cli.ts", diff --git a/tests/cliChat.test.ts b/tests/cliChat.test.ts index fed61f2..19d57c0 100644 --- a/tests/cliChat.test.ts +++ b/tests/cliChat.test.ts @@ -35,7 +35,7 @@ const chunks = ( describe("CLI chat runtime selection", () => { it("requires a compatible runtime and Node FFI", () => { - expect(getOpenTuiRuntimeError({ node: "22.13.0" }, [], "")).toContain( + expect(getOpenTuiRuntimeError({ node: "26.3.0" }, [], "")).toContain( "Bun", ); expect( From 8bd2c6c96df286cd35f2f37757c02eee5172b2df Mon Sep 17 00:00:00 2001 From: Uka Osim Date: Mon, 5 Oct 2026 05:44:18 +0100 Subject: [PATCH 6/6] Update Yarn installation method in CI workflow Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 617c474..e9a80b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,6 @@ jobs: with: node-version: 26.4.0 - name: Install Yarn - run: corepack enable + run: npm install --global yarn@1.22.22 - name: Install dependencies from the Yarn lockfile run: yarn install --frozen-lockfile --ignore-scripts --non-interactive