469 lines
19 KiB
JavaScript
469 lines
19 KiB
JavaScript
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 (
|
||
<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: [],
|
||
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 (
|
||
<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>
|
||
{this.state.menuOpen && this.state.menuMode !== 'model' && (
|
||
<Box borderStyle="round" paddingX={1} paddingY={0} marginTop={1} flexDirection="column">
|
||
<Text color="yellow">Main Menu (Up/Down to navigate, Enter to select)</Text>
|
||
{menuItems.map((label, i) => (
|
||
<Text key={label} color={i === selected ? 'cyan' : undefined}>
|
||
{i === selected ? '› ' : ' '}{label}
|
||
</Text>
|
||
))}
|
||
</Box>
|
||
)}
|
||
{this.state.menuOpen && this.state.menuMode === 'model' && (
|
||
<Box borderStyle="round" paddingX={1} paddingY={0} marginTop={1} flexDirection="column">
|
||
<Text color="yellow">Model Settings (Up/Down select, Left/Right change, Enter back)</Text>
|
||
{this.getModelSettingsItems().map((item, i) => (
|
||
<Text key={item.key || item.label} color={i === selected ? 'cyan' : undefined}>
|
||
{i === selected ? '› ' : ' '}
|
||
{item.label}{item.options ? `: ${this.state[item.key]}` : ''}
|
||
</Text>
|
||
))}
|
||
</Box>
|
||
)}
|
||
<Box marginTop={1}>
|
||
<Text>Input: </Text>
|
||
{this.state.isLoading ? (
|
||
<Text color="yellow">{npmSpinnerFrames[this.state.spinnerIndex]} Processing...</Text>
|
||
) : (
|
||
<TextInput
|
||
value={input}
|
||
onChange={this.handleChange}
|
||
onSubmit={this.handleSubmit}
|
||
placeholder="Type and press Enter..."
|
||
/>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
);
|
||
}
|
||
}
|
||
|
||
|