import React from 'react'; import { Box, Text } from 'ink'; import TextInput from 'ink-text-input'; import terminalService from '../terminalService.js'; 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 (!visible) { return line; } if (typeof maxWidth === 'number' && maxWidth > 0) { if (line.length < maxWidth) { return `${line}${cursorChar}`; } if (line.length === maxWidth) { // Replace last char to avoid overflow return `${line.slice(0, maxWidth - 1)}${cursorChar}`; } // If somehow longer, just ensure width return `${line.slice(0, maxWidth - 1)}${cursorChar}`; } return `${line}${cursorChar}`; } 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: [] }; 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); } 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(); } 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; } } 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 }); } handleSubmit() { const { input } = this.state; if (!input) return; try { terminalService.write(`${input}\r`); } catch (e) { // do not hide errors; show in logs this.setState((state) => ({ logs: [...state.logs, `! write error: ${String(e && e.message ? e.message : e)}`], })); } this.setState((state) => ({ logs: [...state.logs, `> ${input}`], input: '' })); } 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); return ( Input: ); } }