Enhance CLI terminal integration and output handling in InkApp. Introduce terminalService for managing PTY backend, including resizing and updating terminal output. Implement ANSI stripping and tab expansion for accurate line rendering. Improve state management for terminal, logs, and LLM output, ensuring responsive UI updates and error handling during input submission.

This commit is contained in:
sebseb7
2025-08-12 18:34:07 +00:00
parent ac09e4ed08
commit b515275407
4 changed files with 4935 additions and 16 deletions

View File

@@ -1,17 +1,60 @@
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 {
// 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;
}
render() {
const { title, lines } = this.props;
const { title, lines, maxWidth } = this.props;
return (
<Box flexDirection="column" borderStyle="round" paddingX={1} paddingY={0} flexGrow={1}>
<Box flexDirection="column" borderStyle="round" paddingX={1} paddingY={0} flexGrow={1} flexShrink={1} minWidth={0}>
<Text color="cyan">{title}</Text>
<Box flexDirection="column">
<Box flexDirection="column" width="100%" flexShrink={1} minWidth={0}>
{(lines && lines.length > 0)
? lines.map((line, index) => (
<Text key={index}>{line}</Text>
<Text key={index}>{
(() => {
const clean = this.stripAnsi(line);
const width = typeof maxWidth === 'number' && maxWidth > 0 ? maxWidth : undefined;
// Expand tabs before slicing to visual width
const expanded = this.expandTabs(clean, 8, width);
if (width && expanded.length > width) {
return expanded.slice(0, width);
}
return expanded;
})()
}</Text>
))
: <Text dimColor></Text>
}
@@ -33,10 +76,75 @@ export default class InkApp extends React.Component {
};
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() { }
componentWillUnmount() { }
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 });
@@ -45,27 +153,56 @@ export default class InkApp extends React.Component {
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}`],
terminal: [...state.terminal, `echo ${input}`],
chainOfThought: [...state.chainOfThought, `(internal) Thought about: ${input}`],
llmOutput: [...state.llmOutput, `Model says: ${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 (
<Box flexDirection="column" height="100%">
<Box flexGrow={1} flexDirection="row">
<Box flexGrow={1} flexDirection="column">
<Pane title="LLM Output" lines={llmOutput} />
<Pane title="Chain of Thought" lines={chainOfThought} />
<Box flexGrow={1} flexDirection="row" minWidth={0}>
<Box flexGrow={1} flexDirection="column" minWidth={0}>
<Pane title="LLM Output" lines={llmOutputView} maxWidth={paneContentWidth} />
<Pane title="Chain of Thought" lines={chainOfThoughtView} maxWidth={paneContentWidth} />
</Box>
<Box flexGrow={1} flexDirection="column">
<Pane title="Terminal" lines={terminal} />
<Pane title="Logging" lines={logs} />
<Box flexGrow={1} flexDirection="column" minWidth={0}>
<Pane title="Terminal" lines={terminalView} maxWidth={paneContentWidth} />
<Pane title="Logging" lines={logsView} maxWidth={paneContentWidth} />
</Box>
</Box>
<Box marginTop={1}>