MiniCPM5-2B-WebGPU-Pi-HTTP / app /src /pi /terminal-ui.mjs
Mike0021's picture
Enable browser HTTP commands with CORS-aware errors and bounded requests
39371ea verified
Raw History Blame Contribute Delete
19.5 kB
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { UnicodeGraphemesAddon } from '@xterm/addon-unicode-graphemes';
import '@xterm/xterm/css/xterm.css';
import { TuiAltScreen, Container, Text, TruncatedText, Box, Spacer, ScrollView, VStack,
KeybindingsManager, TUI_KEYBINDINGS, setKeybindings, stripTerminalSequences } from './entry.mjs';
import { CustomEditor } from '../../vendor/pi-coding-agent/components/custom-editor.js';
import { UserMessageComponent } from '../../vendor/pi-coding-agent/components/user-message.js';
import { AssistantMessageComponent } from '../../vendor/pi-coding-agent/components/assistant-message.js';
import { getEditorTheme, theme, terminalTheme } from './theme.mjs';
import { XtermTerminal } from './xterm-terminal.mjs';
import { textContent } from '../protocol.mjs';
import { contextLabel, restoredContextUsage } from '../context-usage.mjs';
import { createEmptyMascot } from './empty-mascot.mjs';
import { createIdlePaws } from './idle-paws.mjs';
// Model output and workspace files are text, never terminal instructions.
export const terminalText = value => stripTerminalSequences(String(value ?? ''))
.replace(/\r\n?/g, '\n').replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '');
const commands = [
{ value: '/help', label: '/help', description: 'Browser terminal commands' },
{ value: '/load', label: '/load', description: 'Load MiniCPM on this device' },
{ value: '/new', label: '/new', description: 'Start a new conversation; keep files' },
{ value: '/clear', label: '/clear', description: 'Same as /new' },
];
export function createTerminalUI(element, actions) {
const xterm = new Terminal({
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', fontSize: 13, lineHeight: 1.3,
theme: terminalTheme,
// Pi owns alternate-screen scrolling. Native scrollback would reserve an
// unused 15px scrollbar gutter in FitAddon and make right padding uneven.
cursorBlink: false, scrollback: 0, screenReaderMode: true, allowProposedApi: true,
});
// Pi measures grapheme clusters. Match that in xterm so emoji/ZWJ sequences
// don't displace padding or put the editing cursor in the wrong column.
xterm.loadAddon(new UnicodeGraphemesAddon());
const mount = document.createElement('div'); mount.className = 'pi-terminal-mount'; element.append(mount);
const fitAddon = new FitAddon(); xterm.loadAddon(fitAddon); xterm.open(mount);
const terminal = new XtermTerminal(xterm);
const ui = new TuiAltScreen(terminal, true, undefined, {
copyOnSelect: false,
copySelection: async text => { try { await navigator.clipboard.writeText(text); return true; } catch { return false; } },
openUrl: url => { if (/^https?:\/\//i.test(url)) window.open(url, '_blank', 'noopener,noreferrer'); },
scrollToEndIndicator: () => theme.fg('accent', ' ↓ Latest '),
searchMatchStyle: text => theme.bg('selectedBg', theme.fg('text', text)),
searchCurrentMatchStyle: text => theme.bg('accent', theme.fg('userMessageBg', theme.bold(text))),
});
const keybindings = new KeybindingsManager({ ...TUI_KEYBINDINGS,
'app.interrupt': { defaultKeys: ['escape', 'ctrl+c'] }, 'app.exit': { defaultKeys: 'ctrl+d' },
'app.tools.expand': { defaultKeys: 'ctrl+o' },
'tui.editor.historyPrevious': { defaultKeys: 'ctrl+p' }, 'tui.editor.historyNext': { defaultKeys: 'ctrl+n' },
});
setKeybindings(keybindings);
// Pi defaults: outputPad=1, editorPaddingX=0.
const editor = new CustomEditor(ui, getEditorTheme(), keybindings, { paddingX: 0 });
const transcript = new Container();
const scroll = new ScrollView(transcript, { follow: 'end', primary: true, scrollbar: 'auto',
scrollbarTrackStyle: text => theme.fg('borderMuted', text), scrollbarThumbStyle: text => theme.fg('muted', text) });
const status = new TruncatedText('', 0, 0);
let statusReservedColumns = 0;
const renderStatus = status.render.bind(status);
status.render = width => {
const reserved = Math.min(statusReservedColumns, Math.max(0, width - 1));
const lines = renderStatus(width - reserved).map(line => line + ' '.repeat(reserved));
// Give live activity a little breathing room below the transcript.
return state.busy ? ['', ...lines] : lines;
};
const footer = {
invalidate() {},
render(width) {
const location = '/workspace · MiniCPM5-2B';
const usage = contextLabel(state.contextUsage);
const combined = `${location} · ${usage}`;
const lines = combined.length <= width - 2 ? [combined] : [location, usage];
return lines.flatMap(line => new TruncatedText(theme.fg('dim', line), 1, 0).render(width));
},
};
ui.setLayoutRoot(new VStack([
// On a short viewport the editor takes priority over transcript rows.
{ component: scroll, basis: 0, grow: 1, minSize: 0 },
{ component: new VStack([status, editor, footer]), basis: 'auto', shrink: 0 },
]));
ui.setFocus(editor);
let state = { loaded: false, busy: false, activity: '', contextUsage: restoredContextUsage([]) }, files = [], messages = [], expanded = false;
let inputPending = false;
let queuedPrompt;
let hydrated = false;
const idlePaws = createIdlePaws(element, () => {
const screen = mount.querySelector('.xterm-screen')?.getBoundingClientRect();
if (!screen || !xterm.rows) return;
const root = element.getBoundingClientRect();
// Keep every print inside the transcript, above the status and input rows.
return { left: screen.left - root.left, top: screen.top - root.top,
width: screen.width, height: screen.height * scroll.viewportHeight / xterm.rows };
});
const mascot = createEmptyMascot(element, text => {
if (state.busy || inputPending || editor.getText().trim()) return;
editor.setText(text); xterm.focus(); ui.requestRender();
});
function refreshMascot() {
idlePaws.setEnabled(hydrated && !state.busy && !inputPending && !queuedPrompt);
const screen = mount.querySelector('.xterm-screen');
if (!hydrated || messages.some(message => message.role === 'user') || state.busy || inputPending) mascot.hide();
else mascot.update({ screen, rows: xterm.rows,
viewportRows: scroll.viewportHeight, contentRows: transcript.render(xterm.cols).length,
draft: editor.getText(), hasSales: files.includes('sales.csv'), hasProducts: files.includes('products.json') || files.includes('products.csv') });
// Keep the status text to the left of the cat's lower body.
const cellWidth = screen?.getBoundingClientRect().width / xterm.cols;
const reserved = cellWidth > 0 ? Math.ceil(mascot.statusInset / cellWidth) : 0;
if (reserved !== statusReservedColumns) { statusReservedColumns = reserved; ui.requestRender(); }
}
const mascotRender = xterm.onRender(refreshMascot);
const components = new Map();
const localEntries = [];
const history = new Set();
const welcome = new Container();
welcome.addChild(new Text(theme.fg('accent', theme.bold('π Pi')) + theme.fg('muted', ' browser workspace'), 1, 1));
welcome.addChild(new Text('Ask Pi to work with your files.\n\n/load load MiniCPM\n/help show commands\n!ls run a workspace command', 1, 0));
welcome.addChild(new Spacer(1));
// Keep the first suggested task visible on small phones. Command help stays
// available through /help without taking up the empty conversation area.
const compactWelcome = new Text(theme.fg('accent', theme.bold('π Pi')) + theme.fg('muted', ' browser workspace') + '\nAsk Pi to work with your files.', 1, 0);
const renderWelcome = welcome.render.bind(welcome);
welcome.render = width => xterm.rows < 30 ? compactWelcome.render(width) : renderWelcome(width);
function addLocal(text, error = false) {
localEntries.push({ at: messages.length, component: new Text(theme.fg(error ? 'error' : 'muted', terminalText(text)), 1, 1) });
renderMessages(); ui.scrollToBottom();
}
function remember(text) {
if (!text || history.has(text)) return;
history.add(text); editor.addToHistory(text);
}
function toolComponent(call, result) {
const container = new Container();
container.addChild(new Spacer(1));
// Match ToolExecutionComponent's default shell: one outer blank row and
// Box(1, 1), with a ten-line collapsed preview and no extra inner padding.
const box = new Box(1, 1, text => theme.bg(result ? result.isError ? 'toolErrorBg' : 'toolSuccessBg' : 'toolPendingBg', text));
const title = theme.fg('toolTitle', theme.bold(terminalText(call.name))) + ' ' +
theme.fg('accent', terminalText(call.arguments?.path ?? call.arguments?.command ?? ''));
box.addChild(new Text(title, 0, 0));
const args = terminalText(JSON.stringify(call.arguments, null, 2));
const output = terminalText(result ? textContent(result.content) : 'Running…').replace(/\n+$/, '');
const lines = output.split('\n');
box.addChild(new Text(theme.fg('toolOutput', expanded ? args + '\n\n' + output : lines.slice(0, 10).join('\n')), 0, 0));
if (!expanded && lines.length > 10) box.addChild(new Text(theme.fg('dim', `… ${lines.length - 10} more lines · Ctrl+O to expand`), 0, 0));
container.addChild(box); return container;
}
function renderMessages() {
transcript.clear(); transcript.addChild(welcome);
const results = new Map(messages.filter(m => m.role === 'toolResult').map(m => [m.toolCallId, m]));
const liveKeys = new Set();
for (let i = 0; i <= messages.length; i++) {
for (const local of localEntries) if (local.at === i) transcript.addChild(local.component);
const message = messages[i];
if (!message || message.role === 'toolResult') continue;
const key = message.role + ':' + message.timestamp + ':' + i; liveKeys.add(key);
let cached = components.get(key);
if (!cached) {
cached = { component: message.role === 'user' ? new UserMessageComponent(terminalText(textContent(message.content))) : new AssistantMessageComponent(undefined, true) };
components.set(key, cached);
}
if (message.role === 'user') remember(textContent(message.content));
if (message.role === 'assistant' && cached.message !== message) {
const clean = { ...message, content: message.content.map(block => block.type === 'text' ? { ...block, text: terminalText(block.text) } :
block.type === 'thinking' ? { ...block, thinking: terminalText(block.thinking) } : block),
errorMessage: message.errorMessage ? terminalText(message.errorMessage) : undefined };
cached.component.setHiddenThinkingLabel(message.stopReason ? 'Thoughts · click to expand' : 'Thinking…');
cached.component.updateContent(clean, !message.stopReason);
}
// Pi's assistant and tool components start with a separator. Apply the
// same outer gap before user boxes; keep their native inner padding.
if (message.role === 'user' && (i > 0 || localEntries.length)) transcript.addChild(new Spacer(1));
cached.message = message; transcript.addChild(cached.component);
for (const call of Array.isArray(message.content) ? message.content : []) {
if (call.type !== 'toolCall') continue;
const result = results.get(call.id), toolKey = 'tool:' + call.id; liveKeys.add(toolKey);
const signature = JSON.stringify([call, result, expanded]);
let tool = components.get(toolKey);
if (tool?.signature !== signature) { tool = { signature, component: toolComponent(call, result) }; components.set(toolKey, tool); }
transcript.addChild(tool.component);
}
}
for (const key of components.keys()) if (!liveKeys.has(key)) components.delete(key);
if (queuedPrompt) {
transcript.addChild(new Spacer(1));
transcript.addChild(queuedPrompt);
transcript.addChild(new Text(theme.fg('muted', 'Queued · sends when the model is ready'), 1, 0));
}
refreshMascot();
ui.requestRender();
}
function refreshStatus() {
status.text = theme.fg(state.busy ? 'accent' : 'muted', terminalText(state.activity || (state.busy ? '' : state.loaded ? 'Ready' : 'Send a message to get started · /help')));
editor.disableSubmit = state.busy || inputPending;
editor.borderColor = text => theme.fg(state.busy ? 'accent' : 'borderMuted', text);
actions.onInput?.(editor.getText()); ui.requestRender();
refreshMascot();
}
async function submit(text) {
if (!text.trim() || state.busy || inputPending) return;
inputPending = true; refreshStatus();
remember(text); ui.scrollToBottom();
try {
const trimmed = text.trim();
if (trimmed === '/help') {
addLocal('/load Load MiniCPM on this device\n/new Clear the conversation; keep workspace files\n!cmd Run cmd with just-bash\n\nEnter: send · Shift+Enter: newline\n↑/↓: edit/history · Ctrl+P/N: history\nEsc or Ctrl+C: stop · Ctrl+O: expand tools\nCtrl+Shift+F: search transcript\n\nPi’s original terminal UI and agent core run here.\nNode CLI extensions, npm, Python and native binaries are unavailable.');
} else if (trimmed === '/load') { await actions.load(); }
else if (trimmed === '/new' || trimmed === '/clear') { await actions.newChat(); localEntries.length = 0; renderMessages(); }
else if (trimmed.startsWith('/')) { throw Error('Unknown browser command. Type /help for available commands.'); }
else if (trimmed.startsWith('!')) {
const command = trimmed.slice(1).trim(); if (!command) throw Error('Enter a shell command after !.');
const result = await actions.shell(command);
addLocal(`$ ${command}\n${result.stdout}${result.stderr}\n[exit ${result.exitCode}]`, result.exitCode !== 0);
} else { await actions.prompt(text); }
} catch (error) {
if (error.name !== 'AbortError') addLocal(error.message, true);
// Preserve a failed submission without overwriting a draft typed meanwhile.
if (!editor.getText()) editor.setText(text);
} finally { inputPending = false; refreshStatus(); }
}
editor.onSubmit = text => { void submit(text); };
editor.onChange = () => { actions.onInput?.(editor.getText()); ui.requestRender(); };
editor.onEscape = () => { if (state.busy) void actions.stop().catch(error => addLocal(error.message, true)); };
editor.onCtrlD = () => addLocal('This terminal lives in your browser. Close the tab to leave.');
editor.onAction('app.tools.expand', () => { expanded = !expanded; renderMessages(); });
editor.setAutocompleteProvider({
getSuggestions(lines, line, col) {
const before = lines[line].slice(0, col), prefix = before.match(/\S*$/)?.[0] ?? '';
if (!prefix) return null;
const candidates = before.startsWith('/') ? commands : files.map(path => ({ value: path, label: path, description: 'workspace file' }));
const items = candidates.filter(item => item.value.startsWith(prefix) && item.value !== prefix);
return items.length ? { items, prefix } : null;
},
applyCompletion(lines, line, col, item, prefix) {
const updated = [...lines]; updated[line] = lines[line].slice(0, col - prefix.length) + item.value + ' ' + lines[line].slice(col);
return { lines: updated, cursorLine: line, cursorCol: col - prefix.length + item.value.length + 1 };
},
});
xterm.attachCustomKeyEventHandler(event => {
if (event.type !== 'keydown' || event.isComposing) return true;
// Let the browser deliver its native paste event (no clipboard permission
// request). xterm then applies bracketed paste before Pi sees the text.
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'v') return false;
// Legacy xterm input folds Ctrl+Shift+F into Ctrl+F. Preserve the modifier
// in CSI-u form for Pi's existing fullscreen transcript-search binding.
if (event.ctrlKey && event.shiftKey && event.code === 'KeyF') {
event.preventDefault(); terminal.inputData('\x1b[102;6u'); return false;
}
if (event.key === 'Enter' && event.shiftKey) { event.preventDefault(); terminal.inputData('\x1b\r'); return false; }
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c' && (xterm.hasSelection() || ui.hasActiveSelection())) {
if (ui.hasActiveSelection()) { event.preventDefault(); void ui.copyActiveSelectionToClipboard(); }
return false;
}
return true;
});
// xterm's alternate buffer has no native scrollback. Route touch gestures to
// Pi's ScrollView while leaving taps/IME handling with xterm itself.
let touchY;
element.addEventListener('touchstart', event => { if (event.touches.length === 1) touchY = event.touches[0].clientY; }, { passive: true });
element.addEventListener('touchmove', event => {
if (touchY === undefined || event.touches.length !== 1) return;
const y = event.touches[0].clientY, rows = Math.trunc((touchY - y) / 18);
if (rows) { ui.scrollBy(rows); touchY = y; event.preventDefault(); }
}, { passive: false });
element.addEventListener('touchend', () => { touchY = undefined; }, { passive: true });
function fit() {
idlePaws.reset();
if (element.clientWidth > 0 && element.clientHeight > 0) { fitAddon.fit(); ui.requestRender(); }
}
const observer = new ResizeObserver(fit); observer.observe(element);
fit(); ui.start(); renderMessages(); refreshStatus();
document.fonts.ready.then(fit);
xterm.textarea.setAttribute('aria-label', 'Message Pi');
xterm.textarea.setAttribute('autocapitalize', 'off');
xterm.textarea.setAttribute('spellcheck', 'false');
return {
setMessages(value, { reset = false } = {}) { hydrated = true; messages = value; if (reset) { localEntries.length = 0; components.clear(); } renderMessages(); },
setFiles(value) { files = Object.keys(value).map(path => path.replace(/^\/workspace\//, '')); refreshMascot(); },
setState(value) { state = { ...state, ...value }; refreshStatus(); },
setQueuedPrompt(text) {
if (!text && !queuedPrompt) return;
queuedPrompt = text ? new UserMessageComponent(terminalText(text)) : undefined;
renderMessages(); ui.scrollToBottom();
},
submit() { const text = editor.getExpandedText(); if (!editor.disableSubmit && text.trim()) { editor.setText(''); return submit(text); } },
focus() { xterm.focus(); },
newline() { terminal.inputData('\x1b\r'); xterm.focus(); },
get text() { return editor.getExpandedText(); },
get cols() { return xterm.cols; }, get rows() { return xterm.rows; },
get viewportTop() { return ui.viewportTop; },
readScreen() { return Array.from({ length: xterm.buffer.active.length }, (_, i) => xterm.buffer.active.getLine(i)?.translateToString(true) ?? '').join('\n'); },
readDocument() { return scroll.render(xterm.cols).map(line => stripTerminalSequences(line).trimEnd()); },
readCells() {
return Array.from({ length: xterm.rows }, (_, row) => Array.from({ length: xterm.cols }, (_, col) => {
const cell = xterm.buffer.active.getLine(row)?.getCell(col);
return cell ? { text: cell.getChars(), width: cell.getWidth(),
fg: cell.isFgDefault() ? terminalTheme.foreground : cell.getFgColor(),
bg: cell.isBgDefault() ? terminalTheme.background : cell.getBgColor() } : null;
}));
},
dispose() { observer.disconnect(); mascotRender.dispose(); mascot.dispose(); idlePaws.dispose(); ui.stop({ preserveScreen: true }); xterm.dispose(); },
};
}