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

@@ -3,6 +3,10 @@ import 'dotenv/config';
import React from 'react';
import { render } from 'ink';
import InkApp from './src/ui/InkApp.jsx';
import terminalService from './src/terminalService.js';
// Start the PTY backend independent from UI lifecycle
terminalService.start();
const { unmount } = render(React.createElement(InkApp));
@@ -14,6 +18,7 @@ if (process.stdin.isTTY) {
const exitCleanly = () => {
unmount();
try { terminalService.dispose(); } catch { }
try { process.stdin.setRawMode(false); } catch { }
process.exit(0);
};
@@ -21,6 +26,10 @@ if (process.stdin.isTTY) {
const onData = (data) => {
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(String(data));
for (const byte of buffer) {
// Ctrl-C (ETX)
if (byte === 0x03) {
return exitCleanly();
}
if (!escPending) {
if (byte === 0x1b) { // ESC
escPending = true;
@@ -42,6 +51,10 @@ if (process.stdin.isTTY) {
};
process.stdin.on('data', onData);
// Also handle SIGINT in case raw mode changes or comes from elsewhere
const onSigint = () => exitCleanly();
process.on('SIGINT', onSigint);
}

4646
out1 Normal file

File diff suppressed because it is too large Load Diff

123
src/terminalService.js Normal file
View File

@@ -0,0 +1,123 @@
import EventEmitter from 'events';
import pty from 'node-pty';
class TerminalService extends EventEmitter {
constructor() {
super();
this.lines = [];
this.partial = '';
this.ptyProcess = null;
this.started = false;
this.maxLines = 1000;
}
start() {
if (this.started) return;
const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash';
const cols = process.stdout && process.stdout.columns ? process.stdout.columns : 120;
const rows = process.stdout && process.stdout.rows ? process.stdout.rows : 30;
const isWindows = process.platform === 'win32';
const userShell = process.env.SHELL && !isWindows ? process.env.SHELL : null;
const shellPath = userShell || (isWindows ? 'powershell.exe' : '/bin/bash');
const args = ['--rcfile','rc'];
this.ptyProcess = pty.spawn(shellPath, args, {
name: 'xterm-256color',
cols,
rows,
cwd: process.cwd(),
env: {
...process.env,
TERM: 'xterm-256color',
PS1: '> '
},
});
this.ptyProcess.onData((data) => {
// Normalize line endings
const normalized = String(data).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const parts = normalized.split('\n');
// First part joins the existing partial
if (parts.length > 0) {
this.partial += parts[0];
}
// For each subsequent part before the last, we have completed lines
for (let i = 1; i < parts.length; i += 1) {
this.lines.push(this.partial);
this.partial = parts[i];
}
// Enforce max lines buffer
if (this.lines.length > this.maxLines) {
this.lines.splice(0, this.lines.length - this.maxLines);
}
// Emit lines including current partial to ensure prompts (no trailing newline) are visible
const display = this.partial ? [...this.lines, this.partial] : this.lines.slice();
this.emit('update', display);
});
// Resize with the host TTY
const onResize = () => {
try {
const newCols = process.stdout.columns || cols;
const newRows = process.stdout.rows || rows;
this.ptyProcess.resize(newCols, newRows);
} catch {
// ignore
}
};
if (process.stdout && process.stdout.on) {
process.stdout.on('resize', onResize);
}
this.ptyProcess.onExit(({ exitCode, signal }) => {
this.emit('exit', { exitCode, signal });
});
this.started = true;
}
getLines() {
return this.lines.slice();
}
write(input) {
if (!this.ptyProcess) return;
this.ptyProcess.write(input);
}
resize(columns, rows) {
if (!this.ptyProcess) return;
try {
const cols = Math.max(1, Number(columns) || 1);
const r = rows ? Math.max(1, Number(rows) || 1) : undefined;
if (r !== undefined) {
this.ptyProcess.resize(cols, r);
} else {
this.ptyProcess.resize(cols, this.ptyProcess.rows || 24);
}
} catch {
// ignore
}
}
dispose() {
try {
if (this.ptyProcess) {
this.ptyProcess.kill();
this.ptyProcess = null;
}
} catch {
// ignore
}
}
}
const terminalService = new TerminalService();
export default terminalService;

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}>