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