260 lines
10 KiB
JavaScript
260 lines
10 KiB
JavaScript
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 (
|
|
<Box flexDirection="column" borderStyle="round" paddingX={1} paddingY={0} flexGrow={1} flexShrink={1} minWidth={0}>
|
|
<Text color="cyan">{title}</Text>
|
|
<Box flexDirection="column" width="100%" flexShrink={1} minWidth={0}>
|
|
{(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 (
|
|
<Text key={index}>{finalLine}</Text>
|
|
);
|
|
})
|
|
: <Text dimColor>—</Text>
|
|
}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
}
|
|
|
|
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 (
|
|
<Box flexDirection="column" height="100%">
|
|
<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" minWidth={0}>
|
|
<Pane title="Terminal" lines={terminalView} maxWidth={paneContentWidth} showCursor cursorBlinkMs={600} />
|
|
<Pane title="Logging" lines={logsView} maxWidth={paneContentWidth} />
|
|
</Box>
|
|
</Box>
|
|
<Box marginTop={1}>
|
|
<Text>Input: </Text>
|
|
<TextInput
|
|
value={input}
|
|
onChange={this.handleChange}
|
|
onSubmit={this.handleSubmit}
|
|
placeholder="Type and press Enter..."
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
}
|
|
|
|
|