diff --git a/CHANGELOG.md b/CHANGELOG.md index bb26464c..d43c531b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ - fix(autostart): work on nvm + npm 9/10, actually register with launchctl (#1104, fixes #1082) - Fix Ollama usage not tracked/shown in UI (#1102) - fix(opencode): preserve DeepSeek reasoning content (#1099, fixes #1093) +- Fix TUI input lag (replace enquirer with native readline, persistent raw mode) +- fix(ui): show API key row actions on mobile (#1112) ## Improvements - Sync DeepSeek TUI card style with other CLI tools (badges, layout, manual config modal) diff --git a/cli/src/cli/terminalUI.js b/cli/src/cli/terminalUI.js index c4426bb9..fb28330e 100644 --- a/cli/src/cli/terminalUI.js +++ b/cli/src/cli/terminalUI.js @@ -14,23 +14,13 @@ const COLORS = { cyan: "\x1b[36m" }; -/** - * Build header content with endpoint and API keys - * @param {number} port - Server port - * @returns {Promise} Header content string - */ -async function buildHeaderContent(port) { - const [keysResult, tunnelResult] = await Promise.all([ - api.getApiKeys(), - api.getTunnelStatus() - ]); - - const keys = keysResult.success ? (keysResult.data.keys || []) : []; - const tunnel = tunnelResult.success ? (tunnelResult.data || {}) : {}; - const tunnelEnabled = tunnel.enabled === true; - +// Cached header (SWR): show last value instantly, refresh in background. +let cachedHeader = ""; +let fetchingHeader = false; + +function renderHeader(port, keys, tunnel) { + const tunnelEnabled = tunnel && tunnel.enabled === true; const lines = []; - if (tunnelEnabled && tunnel.publicUrl) { lines.push(`Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`); lines.push(`Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`); @@ -38,17 +28,37 @@ async function buildHeaderContent(port) { lines.push(`Endpoint: http://localhost:${port}/v1`); lines.push(`Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`); } - - if (keys.length === 0) { + if (!keys || keys.length === 0) { lines.push(`Key: ${COLORS.dim}No API keys yet${COLORS.reset}`); } else { lines.push(`Key: ${COLORS.cyan}${keys[0].key}${COLORS.reset}`); keys.slice(1).forEach(k => lines.push(` ${COLORS.cyan}${k.key}${COLORS.reset}`)); } - return lines.join("\n"); } +async function refreshHeaderBg(port) { + if (fetchingHeader) return; + fetchingHeader = true; + try { + const [keysResult, tunnelResult] = await Promise.all([ + api.getApiKeys(), + api.getTunnelStatus() + ]); + const keys = keysResult.success ? (keysResult.data.keys || []) : []; + const tunnel = tunnelResult.success ? (tunnelResult.data || {}) : {}; + cachedHeader = renderHeader(port, keys, tunnel); + } finally { + fetchingHeader = false; + } +} + +function getHeader(port) { + // Kick off background refresh; return cache (or placeholder on first call). + refreshHeaderBg(port); + return cachedHeader || `Endpoint: http://localhost:${port}/v1\nTunnel: ${COLORS.dim}...${COLORS.reset}\nKey: ${COLORS.dim}...${COLORS.reset}`; +} + /** * Start Terminal UI * @param {number} port - Server port number @@ -56,15 +66,17 @@ async function buildHeaderContent(port) { async function startTerminalUI(port) { // Configure API client api.configure({ port }); - + const basePath = ["9Router"]; - + + // Prime header cache before first render + await refreshHeaderBg(port); + // Main menu await showMenuWithBack({ title: "📡 9Router Terminal UI", breadcrumb: basePath, - headerContent: async () => await buildHeaderContent(port), - refresh: async () => ({}), // Refresh header on each loop + headerContent: () => getHeader(port), items: [ { label: "Providers", diff --git a/cli/src/cli/utils/input.js b/cli/src/cli/utils/input.js index fbccf3cc..5761a2bf 100644 --- a/cli/src/cli/utils/input.js +++ b/cli/src/cli/utils/input.js @@ -1,4 +1,4 @@ -const { Input, Confirm, Select } = require("enquirer"); +const readline = require("readline"); const COLORS = { reset: "\x1b[0m", @@ -18,123 +18,132 @@ const COLORS = { bgTerracotta: "\x1b[48;2;217;119;87m" }; -// Hex color used by enquirer styles -const TERRACOTTA_HEX = "#D97757"; - -function handleCancel(err) { - // Enquirer throws empty string on ESC/Ctrl+C — treat as cancel - if (err === "" || err === undefined) return null; - throw err; -} - -// Workaround enquirer raw-mode bug (PR #460): prime stdin into raw mode -// + utf8 encoding BEFORE each prompt so arrow keys don't leak as ^[[A/^[[B. -function primeStdin() { - if (!process.stdin.isTTY) return; +// Prime stdin once globally. Toggling raw mode between menus adds latency on +// macOS, so we keep raw mode on for the whole TUI session. +let rawPrimed = false; +function primeRawOnce() { + if (rawPrimed || !process.stdin.isTTY) return; try { + readline.emitKeypressEvents(process.stdin); process.stdin.setRawMode(true); process.stdin.setEncoding("utf8"); process.stdin.resume(); + rawPrimed = true; } catch {} } -function restoreStdin() { - if (!process.stdin.isTTY) return; - try { - process.stdin.setRawMode(false); - } catch {} - process.stdin.pause(); -} - -async function runPrompt(p) { - primeStdin(); - try { - return await p.run(); - } finally { - restoreStdin(); +function suspendRawFor(fn) { + // Temporarily drop raw mode so readline.question can buffer line input. + const wasPrimed = rawPrimed; + if (wasPrimed && process.stdin.isTTY) { + try { process.stdin.setRawMode(false); } catch {} } + return fn().finally(() => { + if (wasPrimed && process.stdin.isTTY) { + try { process.stdin.setRawMode(true); } catch {} + process.stdin.resume(); + } + }); } async function prompt(question) { - const p = new Input({ name: "value", message: question.replace(/:\s*$/, "") }); - try { - const answer = await runPrompt(p); - return (answer || "").trim(); - } catch (err) { - return handleCancel(err) ?? ""; - } + return suspendRawFor(() => new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(question, (answer) => { + rl.close(); + resolve((answer || "").trim()); + }); + })); } async function select(question, options) { - const p = new Select({ - name: "value", - message: question, - choices: options.map((label, i) => ({ name: String(i), message: label })), - }); - try { - const answer = await runPrompt(p); - return parseInt(answer, 10); - } catch (err) { - handleCancel(err); - return -1; + console.log(question); + options.forEach((opt, i) => console.log(` ${i + 1}. ${opt}`)); + while (true) { + const answer = await prompt("\nSelect option (number): "); + const num = parseInt(answer, 10); + if (!isNaN(num) && num >= 1 && num <= options.length) return num - 1; + console.log(`Invalid selection. Please enter a number between 1 and ${options.length}`); } } async function confirm(question) { - const p = new Confirm({ name: "value", message: question }); - try { - return await runPrompt(p); - } catch (err) { - handleCancel(err); - return false; + while (true) { + const answer = await prompt(`${question} (y/n): `); + const lower = answer.toLowerCase(); + if (lower === "y" || lower === "yes") return true; + if (lower === "n" || lower === "no") return false; + console.log("Please answer 'y' or 'n'"); } } async function pause(message = "Press Enter to continue...") { - const p = new Input({ name: "value", message }); - try { - await runPrompt(p); - } catch (err) { - handleCancel(err); - } + return suspendRawFor(() => new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(message, () => { rl.close(); resolve(); }); + })); } /** - * Interactive arrow-key menu using enquirer Select. - * Header (title/subtitle/breadcrumb/headerContent) rendered before prompt. + * Interactive arrow-key menu. Renders ★/☆ icons; selected line uses reverse+bright + * (no underline). Uses readline keypress + raw 'data' fallback to prevent + * arrow-key escape sequence leaks on macOS. */ async function selectMenu(title, items, defaultIndex = 0, subtitle = "", headerContent = "", breadcrumb = []) { - process.stdout.write("\x1b[2J\x1b[H"); - const width = Math.min(process.stdout.columns || 40, 40); - console.log(`\n${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`); - console.log(` ${COLORS.bright}${COLORS.terracotta}${title}${COLORS.reset}`); - if (subtitle) { - console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`); - } - console.log(`${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`); - if (breadcrumb.length > 0) { - console.log(` ${COLORS.dim}${breadcrumb.join(" > ")}${COLORS.reset}`); - } - console.log(); - if (headerContent) { - console.log(headerContent); - console.log(); - } + return new Promise((resolve) => { + let selectedIndex = defaultIndex; + let isActive = true; - const p = new Select({ - name: "menu", - message: "Select", - initial: defaultIndex, - choices: items.map((item, i) => ({ name: String(i), message: item.label })), + primeRawOnce(); + if (!process.stdin.isTTY) { resolve(-1); return; } + + const renderMenu = () => { + if (!isActive) return; + process.stdout.write("\x1b[2J\x1b[H"); + const width = Math.min(process.stdout.columns || 40, 40); + console.log(`\n${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`); + console.log(` ${COLORS.bright}${COLORS.terracotta}${title}${COLORS.reset}`); + if (subtitle) console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`); + console.log(`${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`); + if (breadcrumb.length > 0) console.log(` ${COLORS.dim}${breadcrumb.join(" > ")}${COLORS.reset}`); + console.log(); + if (headerContent) { console.log(headerContent); console.log(); } + + const isWin = process.platform === "win32"; + items.forEach((item, index) => { + const isSelected = index === selectedIndex; + const icon = isSelected ? (isWin ? ">" : "★") : (isWin ? " " : "☆"); + if (isSelected) { + console.log(` ${COLORS.reverse}${COLORS.bright}${icon} ${item.label}${COLORS.reset}`); + } else { + console.log(` ${icon} ${item.label}`); + } + }); + }; + + const cleanup = () => { + if (!isActive) return; + isActive = false; + process.stdin.removeListener("keypress", onKeypress); + }; + + const move = (delta) => { + selectedIndex = (selectedIndex + delta + items.length) % items.length; + renderMenu(); + }; + + const onKeypress = (_str, key) => { + if (!isActive || !key) return; + if (key.name === "up") return move(-1); + if (key.name === "down") return move(1); + if (key.name === "return") { cleanup(); resolve(selectedIndex); return; } + if (key.name === "escape") { cleanup(); resolve(-1); return; } + if (key.ctrl && key.name === "c") { cleanup(); process.exit(0); } + }; + + process.stdin.on("keypress", onKeypress); + renderMenu(); }); - - try { - const answer = await runPrompt(p); - return parseInt(answer, 10); - } catch (err) { - handleCancel(err); - return -1; - } } module.exports = {