Download app/src/pi/terminal-ui.mjs from Mike0021/MiniCPM5-2B-WebGPU-Pi-HTTP: direct link, hf CLI and curl.
- Browser
- Download file 19.5 kB
-
https://huggingface.co/spaces/Mike0021/MiniCPM5-2B-WebGPU-Pi-HTTP/resolve/main/app/src/pi/terminal-ui.mjs
- Command line
-
hf download hf://spaces/Mike0021/MiniCPM5-2B-WebGPU-Pi-HTTP/app/src/pi/terminal-ui.mjs
-
curl -L -o terminal-ui.mjs https://huggingface.co/spaces/Mike0021/MiniCPM5-2B-WebGPU-Pi-HTTP/resolve/main/app/src/pi/terminal-ui.mjs
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(); }, | |
| }; | |
| } | |