Files
toolLooper/src/ui/InkApp.jsx

469 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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