import React from 'react'; import { Box, Text } from 'ink'; import uiService from './uiService.js'; import TextInput from 'ink-text-input'; import terminalService from '../terminalService.js'; import ModelDialog from '../../modelDialog.js'; const sharedModelDialog = new ModelDialog(); const npmSpinnerFrames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏']; class Pane extends React.Component { constructor(props) { super(props); this.state = { cursorVisible: true, }; this._cursorTimer = null; } componentDidMount() { if (this.props.showCursor) { this._cursorTimer = setInterval(() => { this.setState((s) => ({ cursorVisible: !s.cursorVisible })); }, typeof this.props.cursorBlinkMs === 'number' && this.props.cursorBlinkMs > 0 ? this.props.cursorBlinkMs : 500); } } componentWillUnmount() { if (this._cursorTimer) { clearInterval(this._cursorTimer); this._cursorTimer = null; } } // Strip ANSI escape sequences so width measurement/truncation is accurate stripAnsi(input) { if (input == null) return ''; const str = String(input); const ansiPattern = /\u001B\[[0-9;?]*[ -\/]*[@-~]/g; // ESC[ ... cmd return str.replace(ansiPattern, ''); } // Expand tab stops to spaces using 8-column tabs (like a typical terminal) expandTabs(input, tabWidth, limit) { const width = typeof tabWidth === 'number' && tabWidth > 0 ? tabWidth : 8; const max = typeof limit === 'number' && limit > 0 ? limit : Infinity; let col = 0; let out = ''; for (let i = 0; i < input.length; i += 1) { const ch = input[i]; if (ch === '\t') { const spaces = width - (col % width); // If adding spaces exceeds max, clamp const add = Math.min(spaces, Math.max(0, max - col)); out += ' '.repeat(add); col += add; if (col >= max) break; } else { // Treat as width 1 for simplicity (does not account for wide unicode) if (col + 1 > max) break; out += ch; col += 1; } } return out; } // Apply a blinking cursor to the given line according to width constraints withCursor(line, maxWidth) { const cursorChar = typeof this.props.cursorChar === 'string' && this.props.cursorChar.length > 0 ? this.props.cursorChar[0] : '█'; const visible = !!this.state.cursorVisible; if (typeof maxWidth !== 'number' || maxWidth <= 0) { return line; } // Place cursor at the logical end of content, clamped to last column const width = maxWidth; const base = (line || '').slice(0, width); const cursorIndex = Math.min(base.length, width - 1); const targetLen = Math.min(width, cursorIndex + 1); const padLen = Math.max(0, targetLen - base.length); const padded = padLen > 0 ? `${base}${' '.repeat(padLen)}` : base.slice(0, targetLen); const chars = padded.split(''); chars[cursorIndex] = visible ? cursorChar : ' '; return chars.join(''); } render() { const { title, lines, maxWidth } = this.props; return ( {title} {(lines && lines.length > 0) ? lines.map((line, index) => { const isLast = index === lines.length - 1; const width = typeof maxWidth === 'number' && maxWidth > 0 ? maxWidth : undefined; const clean = this.stripAnsi(line); const expanded = this.expandTabs(clean, 8, width); const baseLine = (width && expanded.length > width) ? expanded.slice(0, width) : expanded; const finalLine = (this.props.showCursor && isLast) ? this.withCursor(baseLine, width) : baseLine; return ( {finalLine} ); }) : } ); } } export default class InkApp extends React.Component { constructor(props) { super(props); this.state = { input: '', logs: [], terminal: [], chainOfThought: [], llmOutput: [], menuOpen: false, menuIndex: 0, model: 'gpt-5', reasoningEffort: 'minimal', outputVerbosity: 'low', isLoading: false, spinnerIndex: 0 }; this.handleSubmit = this.handleSubmit.bind(this); this.handleChange = this.handleChange.bind(this); this.setLLMOutput = this.setLLMOutput.bind(this); this.setChainOfThought = this.setChainOfThought.bind(this); this.setTerminal = this.setTerminal.bind(this); this.setLogs = this.setLogs.bind(this); this.toggleMenu = this.toggleMenu.bind(this); this.onKeypress = this.onKeypress.bind(this); this.menuAction = this.menuAction.bind(this); this.getModelSettingsItems = this.getModelSettingsItems.bind(this); this.handleModelSettingAdjust = this.handleModelSettingAdjust.bind(this); } componentDidMount() { this.terminalUnsub = (lines) => { this.setTerminal(lines); }; terminalService.on('update', this.terminalUnsub); // initialize with current buffered output if any const initial = terminalService.getLines(); if (initial && initial.length) { this.setTerminal(initial); } // Resize PTY columns to match the Terminal pane width on start and on TTY resize this.onResize = () => { const totalCols = (process && process.stdout && process.stdout.columns) ? process.stdout.columns : 80; const columnWidth = Math.max(1, Math.floor(totalCols / 2)); const paneContentWidth = Math.max(1, columnWidth - 4); // borders + padding terminalService.resize(paneContentWidth); }; if (process.stdout && process.stdout.on) { process.stdout.on('resize', this.onResize); } this.onResize(); // Keyboard handling for menu if (process.stdin && process.stdin.on) { try { process.stdin.setRawMode(true); } catch {} process.stdin.on('data', this.onKeypress); } // spinner timer this._spinnerTimer = setInterval(() => { if (this.state.isLoading) { this.setState((s) => ({ spinnerIndex: (s.spinnerIndex + 1) % npmSpinnerFrames.length })); } }, 80); } componentWillUnmount() { if (this.terminalUnsub) { terminalService.off('update', this.terminalUnsub); this.terminalUnsub = null; } if (this.onResize && process.stdout && process.stdout.off) { process.stdout.off('resize', this.onResize); this.onResize = null; } if (process.stdin && process.stdin.off) { process.stdin.off('data', this.onKeypress); } if (this._spinnerTimer) { clearInterval(this._spinnerTimer); this._spinnerTimer = null; } } setPaneLines(stateKey, lines) { if (typeof stateKey !== 'string' || !(stateKey in this.state)) { throw new Error(`Invalid state key: ${String(stateKey)}`); } if (lines === undefined) { this.setState({ [stateKey]: [] }); return; } if (!Array.isArray(lines)) { throw new TypeError(`Expected an array of lines or undefined for ${stateKey}`); } this.setState({ [stateKey]: lines }); } setLLMOutput(lines) { this.setPaneLines('llmOutput', lines); } setChainOfThought(lines) { this.setPaneLines('chainOfThought', lines); } setTerminal(lines) { this.setPaneLines('terminal', lines); } setLogs(lines) { this.setPaneLines('logs', lines); } handleChange(value) { this.setState({ input: value }); } async handleSubmit() { const { input } = this.state; if (!input) return; this.setState((state) => ({ logs: [...state.logs, `> ${input}`], input: '', isLoading: true })); try { const result = await sharedModelDialog.interrogate(input); const finalOutput = Array.isArray(result && result.output) ? result.output : [String(result && result.output ? result.output : '')]; const finalReasoning = Array.isArray(result && result.reasoning) ? result.reasoning : (result && result.reasoning ? [String(result.reasoning)] : []); // Append to LLM output with a separator, overwrite chain of thought this.setState((state) => ({ llmOutput: [ ...state.llmOutput, ...(state.llmOutput.length ? ['----------'] : []), ...finalOutput ] })); this.setChainOfThought(finalReasoning); this.setState((state) => ({ logs: [ ...state.logs, `tokens input: ${JSON.stringify(result && result.inputTokens)}`, `tokens cached: ${JSON.stringify(result && result.cachedTokens)}`, `tokens output: ${JSON.stringify(result && result.outputTokens)}` ] })); } catch (e) { this.setState((state) => ({ logs: [...state.logs, `! interrogate error: ${String(e && e.message ? e.message : e)}`] })); } finally { this.setState({ isLoading: false }); } } toggleMenu(open) { this.setState((s) => ({ menuOpen: typeof open === 'boolean' ? open : !s.menuOpen })); } onKeypress(buf) { const data = Buffer.isBuffer(buf) ? buf : Buffer.from(String(buf)); // ESC [ A => Up arrow const isUp = data.length >= 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x41; const isDown = data.length >= 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x42; const isEnter = data.length === 1 && data[0] === 0x0d; const isLeft = data.length >= 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x44; const isRight = data.length >= 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x43; const isCtrlC = data.length === 1 && data[0] === 0x03; if (!this.state.menuOpen) { if (isUp) { this.toggleMenu(true); return; } // let Ink TextInput handle normal typing; we don't intercept here if (isCtrlC) { // Pass through to exit behavior handled in cli-ink.js return; } return; } // Submenu: Model settings adjustments if (this.state.menuOpen && this.state.menuMode === 'model') { const items = this.getModelSettingsItems(); if (isUp) { this.setState((s) => ({ menuIndex: (s.menuIndex - 1 + items.length) % items.length })); return; } if (isDown) { this.setState((s) => ({ menuIndex: (s.menuIndex + 1) % items.length })); return; } if (isLeft || isRight) { const idx = this.state.menuIndex; const dir = isRight ? 1 : -1; this.handleModelSettingAdjust(items[idx].key, dir); return; } if (isEnter) { // Enter exits model submenu back to main menu this.setState({ menuMode: undefined, menuIndex: 0 }); return; } return; } // Menu navigation (main menu) const items = this.getMenuItems(); if (isUp) { this.setState((s) => ({ menuIndex: (s.menuIndex - 1 + items.length) % items.length })); return; } if (isDown) { this.setState((s) => ({ menuIndex: (s.menuIndex + 1) % items.length })); return; } if (isEnter) { const idx = this.state.menuIndex; this.menuAction(items[idx]); return; } } getMenuItems() { return [ 'Send CTRL-C to terminal', 'Restart Terminal', 'Model settings', 'Exit the app', 'Close menu' ]; } menuAction(label) { switch (label) { case 'Send CTRL-C to terminal': try { terminalService.write('\x03'); } catch {} break; case 'Restart Terminal': try { terminalService.restart(); } catch {} break; case 'Model settings': // Toggle a sub-menu state this.setState({ menuMode: 'model', menuIndex: 0 }); break; case 'Exit the app': try { process.exit(0); } catch {} break; case 'Close menu': this.toggleMenu(false); break; default: break; } } getModelSettingsItems() { return [ { key: 'model', label: 'Model', options: ['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano'] }, { key: 'reasoningEffort', label: 'Reasoning effort', options: ['minimal', 'low', 'medium', 'high'] }, { key: 'outputVerbosity', label: 'Output verbosity', options: ['low', 'medium', 'high'] }, { key: 'back', label: 'Back to main menu' } ]; } handleModelSettingAdjust(key, dir) { if (key === 'back') { this.setState({ menuMode: undefined, menuIndex: 0 }); return; } const items = this.getModelSettingsItems(); const item = items.find((i) => i.key === key); if (!item || !item.options) return; const currentValue = this.state[key]; const idx = item.options.indexOf(currentValue); const nextIdx = ((idx === -1 ? 0 : idx) + dir + item.options.length) % item.options.length; const nextValue = item.options[nextIdx]; this.setState({ [key]: nextValue }); } render() { const { input, logs, terminal, chainOfThought, llmOutput } = this.state; const totalCols = (process && process.stdout && process.stdout.columns) ? process.stdout.columns : 80; const totalRows = (process && process.stdout && process.stdout.rows) ? process.stdout.rows : 24; const columnWidth = Math.max(1, Math.floor(totalCols / 2)); const paneContentWidth = Math.max(1, columnWidth - 4); // borders (2) + paddingX=1 on both sides (2) // Compute how many lines fit vertically per pane // Reserve ~2 rows for the input area (label + margin) at bottom const reservedFooterRows = 2; const panesAreaRows = Math.max(4, totalRows - reservedFooterRows); const paneOuterHeight = Math.max(4, Math.floor(panesAreaRows / 2)); // Remove top/bottom border (2) and title line (1) const paneContentHeight = Math.max(0, paneOuterHeight - 3); const sliceLast = (arr) => { if (!Array.isArray(arr)) return []; if (paneContentHeight <= 0) return []; if (arr.length <= paneContentHeight) return arr; return arr.slice(arr.length - paneContentHeight); }; const llmOutputView = sliceLast(llmOutput); const chainOfThoughtView = sliceLast(chainOfThought); const terminalView = sliceLast(terminal); const logsView = sliceLast(logs); const menuItems = this.getMenuItems(); const selected = this.state.menuIndex; return ( {this.state.menuOpen && this.state.menuMode !== 'model' && ( Main Menu (Up/Down to navigate, Enter to select) {menuItems.map((label, i) => ( {i === selected ? '› ' : ' '}{label} ))} )} {this.state.menuOpen && this.state.menuMode === 'model' && ( Model Settings (Up/Down select, Left/Right change, Enter back) {this.getModelSettingsItems().map((item, i) => ( {i === selected ? '› ' : ' '} {item.label}{item.options ? `: ${this.state[item.key]}` : ''} ))} )} Input: {this.state.isLoading ? ( {npmSpinnerFrames[this.state.spinnerIndex]} Processing... ) : ( )} ); } }