From 577b4d749b905a0a6e7716b54c37c48c4e374447 Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Sun, 17 May 2026 10:00:52 -0700 Subject: [PATCH] feat: fix one-press send, add Codex parsed view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Send button: - submitText() sends text without trailing "\n", then a discrete Enter key event after 100ms. cmux delivers send_text as a bracketed paste, so a trailing newline became a literal newline in the Claude/Codex input box instead of submitting — this required a second (empty) press to actually send. Now one press = the full type-then-Enter sequence. Fixes Codex always needing two presses and Claude 4+ line messages not sending / rendering mid-state. - data-text shortcut keys now type then send Enter separately - mousedown.preventDefault keeps soft keyboard up so first tap submits - Remove IME composition Enter handler (textarea Enter = newline now) Codex view: - New codex-parser.js / codex-renderer.js for OpenAI Codex CLI surfaces - /api/codex-surfaces endpoint; generalize surface-ref lookup into _getSurfaceRefsForProcess() (claude + codex) - app.js updateCodexMode(), terminal.setCodexMode(), codex-mode body class (mutually exclusive with claude-mode) Claude mode: - Auto-load scrollback when content fits the viewport - Trim claude-keyboard.js scaffolding; parser/renderer/CSS tweaks Bump virtual-keyboard.js?v=9 cache-bust. Co-Authored-By: Claude Opus 4.7 --- public/css/claude.css | 236 +++++++++------------ public/css/keyboard.css | 26 ++- public/css/main.css | 1 + public/index.html | 26 ++- public/js/app.js | 40 +++- public/js/claude-keyboard.js | 138 +----------- public/js/claude-parser.js | 87 ++++++-- public/js/claude-renderer.js | 61 +++--- public/js/codex-parser.js | 217 +++++++++++++++++++ public/js/codex-renderer.js | 387 ++++++++++++++++++++++++++++++++++ public/js/terminal-view.js | 29 ++- public/js/virtual-keyboard.js | 47 +++-- src/bridge/cmux-client.ts | 37 ++-- src/server/app.ts | 11 + 14 files changed, 957 insertions(+), 386 deletions(-) create mode 100644 public/js/codex-parser.js create mode 100644 public/js/codex-renderer.js diff --git a/public/css/claude.css b/public/css/claude.css index 27397c6..837f3a4 100644 --- a/public/css/claude.css +++ b/public/css/claude.css @@ -1,7 +1,8 @@ /* Claude Code client view styles — scoped to body.claude-mode */ /* ── Terminal output override ── */ -body.claude-mode .terminal-output { +body.claude-mode .terminal-output, +body.codex-mode .terminal-output { white-space: normal; word-break: break-word; padding: 10px 14px; @@ -25,7 +26,8 @@ body.claude-mode .terminal-output { /* ────────────────────────────────────── */ /* Prompt block */ /* ────────────────────────────────────── */ -body.claude-mode .cc-prompt { +body.claude-mode .cc-prompt, +body.codex-mode .cc-prompt { display: flex; align-items: baseline; gap: 8px; @@ -38,27 +40,31 @@ body.claude-mode .cc-prompt { flex-wrap: wrap; } -body.claude-mode .cc-prompt.cc-prompt-active { +body.claude-mode .cc-prompt.cc-prompt-active, +body.codex-mode .cc-prompt.cc-prompt-active { background: var(--bg-tertiary); border-left-color: var(--green); border-top-color: var(--border-subtle); } -body.claude-mode .cc-prompt-marker { +body.claude-mode .cc-prompt-marker, +body.codex-mode .cc-prompt-marker { color: var(--green); font-weight: 700; font-size: 1.05em; flex-shrink: 0; } -body.claude-mode .cc-prompt-text { +body.claude-mode .cc-prompt-text, +body.codex-mode .cc-prompt-text { color: var(--text-primary); white-space: pre-wrap; word-break: break-word; flex: 1; } -body.claude-mode .cc-prompt-cont { +body.claude-mode .cc-prompt-cont, +body.codex-mode .cc-prompt-cont { width: 100%; padding-left: 1.8em; color: var(--text-secondary); @@ -69,29 +75,34 @@ body.claude-mode .cc-prompt-cont { /* ────────────────────────────────────── */ /* Tool-use block */ /* ────────────────────────────────────── */ -body.claude-mode .cc-tool-use { +body.claude-mode .cc-tool-use, +body.codex-mode .cc-tool-use { margin: 3px 0; } -body.claude-mode .cc-tool-header { +body.claude-mode .cc-tool-header, +body.codex-mode .cc-tool-header { display: flex; align-items: baseline; gap: 4px; overflow: hidden; } -body.claude-mode .cc-tool-icon { +body.claude-mode .cc-tool-icon, +body.codex-mode .cc-tool-icon { color: var(--blue); flex-shrink: 0; } -body.claude-mode .cc-tool-name { +body.claude-mode .cc-tool-name, +body.codex-mode .cc-tool-name { color: var(--blue); font-weight: 600; flex-shrink: 0; } -body.claude-mode .cc-tool-args { +body.claude-mode .cc-tool-args, +body.codex-mode .cc-tool-args { color: var(--cyan); overflow: hidden; text-overflow: ellipsis; @@ -100,12 +111,14 @@ body.claude-mode .cc-tool-args { min-width: 0; } -body.claude-mode .cc-tool-body { +body.claude-mode .cc-tool-body, +body.codex-mode .cc-tool-body { padding-left: 1.5em; margin-top: 1px; } -body.claude-mode .cc-tool-body .cc-line { +body.claude-mode .cc-tool-body .cc-line, +body.codex-mode .cc-tool-body .cc-line { white-space: pre-wrap; word-break: break-word; color: var(--text-secondary); @@ -115,14 +128,16 @@ body.claude-mode .cc-tool-body .cc-line { /* ────────────────────────────────────── */ /* Tool-result block */ /* ────────────────────────────────────── */ -body.claude-mode .cc-tool-result { +body.claude-mode .cc-tool-result, +body.codex-mode .cc-tool-result { border-left: 2px solid var(--blue); margin: 4px 0; padding-left: 8px; opacity: 0.85; } -body.claude-mode .cc-tool-result .cc-scrollable pre { +body.claude-mode .cc-tool-result .cc-scrollable pre, +body.codex-mode .cc-tool-result .cc-scrollable pre { margin: 0; padding: 4px 0; white-space: pre-wrap; @@ -135,7 +150,8 @@ body.claude-mode .cc-tool-result .cc-scrollable pre { /* ────────────────────────────────────── */ /* Thinking block */ /* ────────────────────────────────────── */ -body.claude-mode .cc-thinking { +body.claude-mode .cc-thinking, +body.codex-mode .cc-thinking { color: var(--purple); font-style: italic; opacity: 0.7; @@ -149,7 +165,9 @@ body.claude-mode .cc-thinking { /* Response / Text blocks */ /* ────────────────────────────────────── */ body.claude-mode .cc-response .cc-line, -body.claude-mode .cc-text .cc-line { +body.claude-mode .cc-text .cc-line, +body.codex-mode .cc-response .cc-line, +body.codex-mode .cc-text .cc-line { white-space: pre-wrap; word-break: break-word; line-height: 1.65; @@ -158,7 +176,8 @@ body.claude-mode .cc-text .cc-line { /* ────────────────────────────────────── */ /* Code block */ /* ────────────────────────────────────── */ -body.claude-mode .cc-code { +body.claude-mode .cc-code, +body.codex-mode .cc-code { margin: 6px 0; border-radius: var(--radius-xs); background: var(--bg-tertiary); @@ -166,7 +185,8 @@ body.claude-mode .cc-code { overflow: hidden; } -body.claude-mode .cc-code-content { +body.claude-mode .cc-code-content, +body.codex-mode .cc-code-content { margin: 0; padding: 10px 14px; white-space: pre; @@ -179,14 +199,16 @@ body.claude-mode .cc-code-content { /* ────────────────────────────────────── */ /* Table block */ /* ────────────────────────────────────── */ -body.claude-mode .cc-table-wrap { +body.claude-mode .cc-table-wrap, +body.codex-mode .cc-table-wrap { margin: 6px 0; border-radius: var(--radius-xs); border: 1px solid var(--border-subtle); overflow: hidden; } -body.claude-mode .cc-table { +body.claude-mode .cc-table, +body.codex-mode .cc-table { border-collapse: collapse; width: max-content; min-width: 100%; @@ -195,33 +217,39 @@ body.claude-mode .cc-table { } body.claude-mode .cc-table th, -body.claude-mode .cc-table td { +body.claude-mode .cc-table td, +body.codex-mode .cc-table th, +body.codex-mode .cc-table td { padding: 5px 10px; border: 1px solid var(--border-subtle); white-space: nowrap; text-align: left; } -body.claude-mode .cc-table thead th { +body.claude-mode .cc-table thead th, +body.codex-mode .cc-table thead th { background: var(--bg-tertiary); font-weight: 600; color: var(--text-primary); border-bottom: 1px solid var(--border-color); } -body.claude-mode .cc-table tbody td { +body.claude-mode .cc-table tbody td, +body.codex-mode .cc-table tbody td { color: var(--text-secondary); background: var(--bg-primary); } -body.claude-mode .cc-table tbody tr:nth-child(even) td { +body.claude-mode .cc-table tbody tr:nth-child(even) td, +body.codex-mode .cc-table tbody tr:nth-child(even) td { background: var(--bg-tertiary); } /* ────────────────────────────────────── */ /* Diff block */ /* ────────────────────────────────────── */ -body.claude-mode .cc-diff { +body.claude-mode .cc-diff, +body.codex-mode .cc-diff { margin: 4px 0; border-radius: var(--radius-xs); background: var(--bg-tertiary); @@ -229,29 +257,34 @@ body.claude-mode .cc-diff { overflow: hidden; } -body.claude-mode .cc-diff-content { +body.claude-mode .cc-diff-content, +body.codex-mode .cc-diff-content { padding: 6px 10px; font-size: 0.88em; line-height: 1.5; } -body.claude-mode .cc-diff-line { +body.claude-mode .cc-diff-line, +body.codex-mode .cc-diff-line { display: block; white-space: pre; word-break: normal; } -body.claude-mode .cc-diff-line.cc-added { +body.claude-mode .cc-diff-line.cc-added, +body.codex-mode .cc-diff-line.cc-added { color: var(--green); background: rgba(52, 211, 153, 0.18); } -body.claude-mode .cc-diff-line.cc-removed { +body.claude-mode .cc-diff-line.cc-removed, +body.codex-mode .cc-diff-line.cc-removed { color: var(--red); background: rgba(248, 113, 113, 0.18); } -body.claude-mode .cc-diff-line.cc-hunk { +body.claude-mode .cc-diff-line.cc-hunk, +body.codex-mode .cc-diff-line.cc-hunk { color: var(--cyan); opacity: 0.75; } @@ -310,7 +343,8 @@ body.claude-mode .cc-option-label { /* ────────────────────────────────────── */ /* Empty spacer */ /* ────────────────────────────────────── */ -body.claude-mode .cc-empty { +body.claude-mode .cc-empty, +body.codex-mode .cc-empty { height: 0.5em; } @@ -347,76 +381,42 @@ body.claude-mode .cc-empty { } .cc-mode-pill:active { opacity: 0.7; } -.cc-mode-plan { background: rgba(251, 191, 36, 0.18); color: var(--yellow); } -.cc-mode-code { background: rgba(52, 211, 153, 0.18); color: var(--green); } +.cc-mode-plan { background: rgba(251, 191, 36, 0.18); color: var(--yellow); } +.cc-mode-ask { background: rgba(52, 211, 153, 0.18); color: var(--green); } .cc-mode-bypass { background: rgba(248, 113, 113, 0.18); color: var(--red); } +.cc-mode-accept { background: rgba(96, 165, 250, 0.18); color: var(--blue); } -.cc-status-branch { - color: var(--text-dimmed); +.cc-status-model { + color: var(--accent); + font-weight: 600; } -.cc-status-stats { +.cc-status-branch { color: var(--text-dimmed); - margin-left: auto; -} - -/* ────────────────────────────────────── */ -/* Claude context action row */ -/* ────────────────────────────────────── */ -.key-row.cc-action-row { - display: flex; - gap: 5px; - padding: 0 6px 4px; - overflow-x: auto; - scrollbar-width: none; } -.key-row.cc-action-row::-webkit-scrollbar { display: none; } -.cc-action-row .cc-action-btn { - flex-shrink: 0; +.cc-context-bar { + flex: 1; + max-width: 80px; + height: 4px; + background: var(--border-subtle); + border-radius: 2px; + overflow: hidden; } -/* Mode pill in action row */ -.cc-action-row .cc-action-mode-pill { - padding: 0 10px; - font-size: 0.78em; - font-weight: 600; - flex-shrink: 0; +.cc-context-fill { + height: 100%; + background: var(--green); + border-radius: 2px; + transition: width 0.3s ease; } -.cc-action-row .cc-action-mode-pill.cc-mode-plan { background: rgba(251, 191, 36, 0.18); color: var(--yellow); } -.cc-action-row .cc-action-mode-pill.cc-mode-code { background: rgba(52, 211, 153, 0.18); color: var(--green); } -.cc-action-row .cc-action-mode-pill.cc-mode-bypass { background: rgba(248, 113, 113, 0.18); color: var(--red); } -/* Stop button thinking state */ -.cc-action-row .cc-action-thinking .cc-action-icon { - color: var(--red); - animation: cc-stop-pulse 1s ease-in-out infinite; -} -@keyframes cc-stop-pulse { - 0%, 100% { opacity: 0.6; } - 50% { opacity: 1; } -} +.cc-context-fill.cc-ctx-mid { background: var(--yellow); } +.cc-context-fill.cc-ctx-low { background: var(--red); } -/* Selection option buttons */ -.cc-action-row .cc-action-option { - flex: 1; - min-width: 0; - max-width: 100px; -} -.cc-action-row .cc-action-option .cc-action-icon { - color: var(--accent); - font-weight: 700; -} -.cc-action-row .cc-action-option .cc-action-label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 80px; - font-size: 0.58rem; -} -.cc-action-row .cc-action-option-focused { - border-color: var(--accent); - background: var(--key-active-bg); +.cc-status-stats { + color: var(--text-dimmed); + margin-left: auto; } /* ────────────────────────────────────── */ @@ -543,7 +543,8 @@ body.claude-mode .cc-empty { /* ────────────────────────────────────── */ /* Thinking block pulse animation */ /* ────────────────────────────────────── */ -body.claude-mode .cc-thinking { +body.claude-mode .cc-thinking, +body.codex-mode .cc-thinking { animation: cc-thinking-pulse 2s ease-in-out infinite; } @keyframes cc-thinking-pulse { @@ -554,7 +555,8 @@ body.claude-mode .cc-thinking { /* ────────────────────────────────────── */ /* Prompt turn boundaries */ /* ────────────────────────────────────── */ -body.claude-mode .cc-prompt ~ .cc-prompt { +body.claude-mode .cc-prompt ~ .cc-prompt, +body.codex-mode .cc-prompt ~ .cc-prompt { margin-top: 16px; padding-top: 10px; } @@ -574,53 +576,3 @@ body.claude-mode .cc-option.cc-option-focused { flex-shrink: 0; } -/* ────────────────────────────────────── */ -/* Mode pill group */ -/* ────────────────────────────────────── */ -.cc-mode-group { - display: flex; - gap: 2px; - border-radius: var(--radius-pill); - overflow: hidden; -} -.cc-mode-group .cc-mode-pill { - opacity: 0.35; - border-radius: 0; - transition: opacity 0.2s, transform 0.15s; -} -.cc-mode-group .cc-mode-pill:first-child { - border-radius: var(--radius-pill) 0 0 var(--radius-pill); -} -.cc-mode-group .cc-mode-pill:last-child { - border-radius: 0 var(--radius-pill) var(--radius-pill) 0; -} -.cc-mode-group .cc-mode-pill.cc-mode-active { - opacity: 1; - animation: cc-mode-pop 0.2s ease-out; -} -@keyframes cc-mode-pop { - 0% { transform: scale(0.9); } - 50% { transform: scale(1.08); } - 100% { transform: scale(1); } -} - -/* ────────────────────────────────────── */ -/* Context usage progress bar */ -/* ────────────────────────────────────── */ -.cc-context-bar { - flex: 1; - max-width: 80px; - height: 4px; - background: var(--border-subtle); - border-radius: 2px; - overflow: hidden; - align-self: center; -} -.cc-context-fill { - height: 100%; - background: var(--green); - border-radius: 2px; - transition: width 0.3s ease; -} -.cc-context-fill.cc-ctx-mid { background: var(--yellow); } -.cc-context-fill.cc-ctx-high { background: var(--red); } diff --git a/public/css/keyboard.css b/public/css/keyboard.css index 1f9178b..ced729d 100644 --- a/public/css/keyboard.css +++ b/public/css/keyboard.css @@ -4,13 +4,13 @@ backdrop-filter: blur(var(--blur-amount)); -webkit-backdrop-filter: blur(var(--blur-amount)); border-top: 1px solid var(--border-color); - padding: 10px 10px 8px; + padding: 6px 10px 2px; } .key-row { display: flex; gap: 6px; - margin-bottom: 7px; + margin-bottom: 5px; align-items: center; } @@ -95,13 +95,18 @@ .text-input { flex: 1; height: 44px; + min-height: 44px; + max-height: 44px; border: 1px solid var(--border-color); - border-radius: var(--radius-pill); + border-radius: 22px; background: var(--bg-primary); color: var(--text-primary); font-size: 1rem; - padding: 0 18px; + padding: 11px 18px; + line-height: 20px; outline: none; + resize: none; + overflow-y: auto; font-family: 'SF Mono', 'JetBrains Mono', 'Menlo', monospace; transition: border-color var(--transition-fast), box-shadow var(--transition-fast); } @@ -149,6 +154,12 @@ .text-input, .send-key { height: 34px; } + .text-input { + min-height: 34px; + max-height: 34px; + padding: 7px 14px; + border-radius: 17px; + } .virtual-keyboard { padding: 5px 6px 4px; } @@ -197,6 +208,10 @@ .key-row.all-keys::-webkit-scrollbar { display: none; } .key-row.all-keys .key.shortcut { min-width: 32px; padding: 0 5px; } +.key.claude-codex-only { display: none; } +body.claude-mode .key.claude-codex-only, +body.codex-mode .key.claude-codex-only { display: flex; } + .keyboard-toggle { width: 44px; height: 44px; @@ -231,5 +246,4 @@ border-color: var(--accent); } -.virtual-keyboard.collapsed .key-row:not(.input-row) { display: none; } -.virtual-keyboard.collapsed { padding-bottom: env(safe-area-inset-bottom, 8px); } \ No newline at end of file +.virtual-keyboard.collapsed .key-row:not(.input-row) { display: none; } \ No newline at end of file diff --git a/public/css/main.css b/public/css/main.css index 01e57c7..706956c 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -194,6 +194,7 @@ body { /* ── Terminal container ──────────────────────────── */ .terminal-container { flex: 1; + min-height: 0; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; diff --git a/public/index.html b/public/index.html index c19242e..60e2084 100644 --- a/public/index.html +++ b/public/index.html @@ -2,6 +2,7 @@ + cmux-remote @@ -10,11 +11,11 @@ - + - + - + @@ -69,10 +70,11 @@ - + + @@ -82,8 +84,8 @@ - + @@ -117,15 +119,17 @@ - - + + - - + + - + + + diff --git a/public/js/app.js b/public/js/app.js index 7a1fa9c..69d0f3d 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -407,18 +407,25 @@ }); } else { terminal.setContent(msg.lines); - } - if (terminal.claudeMode && terminal.lastClaudeDoc) { - claudeKeyboard.updateContext(terminal.lastClaudeDoc); + // If content fits in viewport and no scrollback loaded yet, auto-load + // so the user always has something to scroll through + if (scrollbackLines === 0 && !scrollbackPending && !inScrollMode) { + requestAnimationFrame(() => { + if (scrollbackLines === 0 && !scrollbackPending && !inScrollMode && + terminal.lines.length > 0 && currentWorkspace && currentSurface && + terminalContainer.scrollHeight <= terminalContainer.clientHeight) { + inScrollMode = true; + scrollbackLines = PAGE_LINES; + requestScrollback(scrollbackLines); + } + }); + } } }); ws.on('screen-diff', (msg) => { if (msg.surface === currentSurface && !inScrollMode) { terminal.applyDiff(msg.patches); - if (terminal.claudeMode && terminal.lastClaudeDoc) { - claudeKeyboard.updateContext(terminal.lastClaudeDoc); - } } }); @@ -438,6 +445,22 @@ if (!zoomOverlay.hidden) zoomImg.src = src; }); + async function updateCodexMode(surfaceRef) { + // Skip if claude mode is already active (mutually exclusive) + if (document.body.classList.contains('claude-mode')) return; + try { + const data = await fetch('/api/codex-surfaces').then(r => r.json()); + if (surfaceRef !== currentSurface) return; + const isCodex = data.surfaces.includes(surfaceRef); + document.body.classList.toggle('codex-mode', isCodex); + if (terminal.setCodexMode) terminal.setCodexMode(isCodex); + } catch { + if (surfaceRef !== currentSurface) return; + document.body.classList.remove('codex-mode'); + if (terminal.setCodexMode) terminal.setCodexMode(false); + } + } + async function updateClaudeMode(surfaceRef) { try { const data = await fetch('/api/claude-surfaces').then(r => r.json()); @@ -470,9 +493,11 @@ updateTitle(wsRef, surfaceRef); localStorage.setItem('cmux-last-surface', JSON.stringify({ ws: wsRef, surface: surfaceRef })); - // Immediately reset claude mode; updateClaudeMode will re-enable if needed + // Immediately reset claude/codex mode; update functions will re-enable if needed document.body.classList.remove('claude-mode'); + document.body.classList.remove('codex-mode'); terminal.setClaudeMode(false, null); + if (terminal.setCodexMode) terminal.setCodexMode(false); claudeKeyboard.deactivate(); terminal.clear(); @@ -488,6 +513,7 @@ sidebar.setActiveSurface(surfaceRef); setHashSurface(wsRef, surfaceRef); updateClaudeMode(surfaceRef); + updateCodexMode(surfaceRef); // If this is a browser surface, show loading indicator immediately const surfaceInfo = lastWorkspaces.flatMap(w => w.panes.flatMap(p => p.surfaces)) diff --git a/public/js/claude-keyboard.js b/public/js/claude-keyboard.js index 81ea624..b18bd21 100644 --- a/public/js/claude-keyboard.js +++ b/public/js/claude-keyboard.js @@ -3,139 +3,17 @@ class ClaudeKeyboard { this.onSendText = onSendText; this.onSendKey = onSendKey; this._active = false; - this._actionRow = null; - this._allKeysRow = null; - this._lastMode = null; - this._lastIsThinking = false; - this._lastSelectionOptions = null; + this._actionBar = null; } activate() { if (this._active) return; this._active = true; - this._allKeysRow = document.querySelector('.key-row.all-keys'); - if (this._allKeysRow) this._allKeysRow.hidden = true; - this._createActionRow(); } deactivate() { if (!this._active) return; this._active = false; - if (this._allKeysRow) { - this._allKeysRow.hidden = false; - this._allKeysRow = null; - } - if (this._actionRow && this._actionRow.parentNode) { - this._actionRow.parentNode.removeChild(this._actionRow); - } - this._actionRow = null; - this._lastMode = null; - this._lastIsThinking = false; - this._lastSelectionOptions = null; - } - - updateContext(doc) { - if (!this._active || !this._actionRow) return; - - const mode = doc.statusBar ? doc.statusBar.mode : null; - const lastBlock = this._getLastMeaningfulBlock(doc.blocks); - const isThinking = lastBlock && lastBlock.type === 'thinking'; - const selectionBlock = this._getLastSelectionBlock(doc.blocks); - const selectionOptions = selectionBlock ? selectionBlock.options : null; - - // Check if anything changed - const selKey = selectionOptions ? selectionOptions.map(o => o.key).join(',') : null; - const lastSelKey = this._lastSelectionOptions ? this._lastSelectionOptions.map(o => o.key).join(',') : null; - if (mode === this._lastMode && isThinking === this._lastIsThinking && selKey === lastSelKey) return; - - this._lastMode = mode; - this._lastIsThinking = isThinking; - this._lastSelectionOptions = selectionOptions; - this._buildActionRow(mode, isThinking, selectionOptions); - } - - _getLastMeaningfulBlock(blocks) { - for (let i = blocks.length - 1; i >= 0; i--) { - if (blocks[i].type !== 'empty') return blocks[i]; - } - return null; - } - - _getLastSelectionBlock(blocks) { - // Find the last selection block (only if near the end) - for (let i = blocks.length - 1; i >= 0; i--) { - const t = blocks[i].type; - if (t === 'selection') return blocks[i]; - if (t !== 'empty') return null; // stop at first non-empty non-selection - } - return null; - } - - _createActionRow() { - const row = document.createElement('div'); - row.className = 'key-row cc-action-row'; - const keyboard = document.getElementById('virtual-keyboard'); - const inputRow = keyboard ? keyboard.querySelector('.key-row.input-row') : null; - if (inputRow) { - keyboard.insertBefore(row, inputRow); - } - this._actionRow = row; - this._buildActionRow(null, false, null); - } - - _buildActionRow(mode, isThinking, selectionOptions) { - if (!this._actionRow) return; - this._actionRow.innerHTML = ''; - - // Mode pill (always present) - if (mode) { - const icons = { plan: '⏸', code: '●', bypass: '⏵' }; - const labels = { plan: 'Plan', code: 'Code', bypass: 'Bypass' }; - const pill = this._makeBtn( - `cc-action-mode-pill cc-mode-${mode}`, - icons[mode] || '●', - labels[mode] || mode, - () => this.onSendKey('Shift-Tab') - ); - this._actionRow.appendChild(pill); - } - - if (selectionOptions && selectionOptions.length > 0) { - // Selection mode: show option quick-buttons - for (const opt of selectionOptions) { - const label = opt.label.length > 12 ? opt.label.slice(0, 12) + '…' : opt.label; - const btn = this._makeBtn( - 'cc-action-option' + (opt.focused ? ' cc-action-option-focused' : ''), - opt.index + '.', - label, - () => this.onSendText(opt.key + '\n') - ); - this._actionRow.appendChild(btn); - } - } else { - // Normal mode: Stop + common keys - const stopBtn = this._makeBtn( - 'cc-action-stop' + (isThinking ? ' cc-action-thinking' : ''), - '⏹', - 'Stop', - () => this.onSendKey('Escape') - ); - this._actionRow.appendChild(stopBtn); - - const keys = [ - { icon: '⇥', label: 'Tab', key: 'Tab' }, - { icon: '^C', label: 'Int', key: 'Ctrl-c' }, - { icon: '/', label: '/', key: '/' }, - { icon: '◀', label: '', key: 'ArrowLeft' }, - { icon: '▼', label: '', key: 'ArrowDown' }, - { icon: '▲', label: '', key: 'ArrowUp' }, - { icon: '▶', label: '', key: 'ArrowRight' }, - ]; - for (const k of keys) { - const btn = this._makeBtn('', k.icon, k.label, () => this.onSendKey(k.key)); - this._actionRow.appendChild(btn); - } - } } _makeBtn(extraClass, icon, label, onClick) { @@ -146,15 +24,12 @@ class ClaudeKeyboard { iconEl.className = 'cc-action-icon'; iconEl.textContent = icon; - btn.appendChild(iconEl); - - if (label) { - const labelEl = document.createElement('span'); - labelEl.className = 'cc-action-label'; - labelEl.textContent = label; - btn.appendChild(labelEl); - } + const labelEl = document.createElement('span'); + labelEl.className = 'cc-action-label'; + labelEl.textContent = label; + btn.appendChild(iconEl); + btn.appendChild(labelEl); btn.addEventListener('click', () => { if (navigator.vibrate) navigator.vibrate(8); onClick(); @@ -162,6 +37,7 @@ class ClaudeKeyboard { return btn; } + } window.ClaudeKeyboard = ClaudeKeyboard; diff --git a/public/js/claude-parser.js b/public/js/claude-parser.js index babf226..995ae77 100644 --- a/public/js/claude-parser.js +++ b/public/js/claude-parser.js @@ -16,12 +16,13 @@ class ClaudeParser { return { blocks: [], statusBar: null }; } - const statusBarIdx = this._findStatusBarIdx(lines); - const statusBar = statusBarIdx >= 0 ? this._tryParseStatusBar(lines[statusBarIdx]) : null; + const statusBarIndices = this._findStatusBarLines(lines); + const statusBar = this._mergeStatusBars(lines, statusBarIndices); - // Content lines: exclude the status bar line - const contentLines = statusBarIdx >= 0 - ? lines.filter((_, i) => i !== statusBarIdx) + // Content lines: exclude all status bar lines + const statusSet = new Set(statusBarIndices); + const contentLines = statusBarIndices.length > 0 + ? lines.filter((_, i) => !statusSet.has(i)) : lines; const blocks = this._parseBlocks(contentLines); @@ -37,32 +38,72 @@ class ClaudeParser { return { blocks, statusBar }; } - _findStatusBarIdx(lines) { - // Last non-empty line only - for (let i = lines.length - 1; i >= 0; i--) { + _findStatusBarLines(lines) { + // Collect all status bar line indices in the bottom 5 non-empty lines + const indices = []; + let checked = 0; + for (let i = lines.length - 1; i >= 0 && checked < 5; i--) { const line = lines[i]; - if (!line || line.trim() === '') continue; - if (this._tryParseStatusBar(line)) return i; - return -1; // first non-empty from bottom isn't a status bar + if (!line || !line.trim()) continue; + checked++; + if (this._tryParseStatusBar(line)) indices.push(i); } - return -1; + return indices; + } + + _mergeStatusBars(lines, indices) { + if (indices.length === 0) return null; + const parsed = indices.map(i => this._tryParseStatusBar(lines[i])).filter(Boolean); + const merged = { mode: null, branch: null, model: null, stats: null, ctxPct: null, rawLine: parsed[0].rawLine }; + for (const p of parsed) { + if (p.mode) merged.mode = p.mode; + if (p.model) merged.model = p.model; + if (p.stats) merged.stats = p.stats; + if (p.ctxPct != null) merged.ctxPct = p.ctxPct; + if (p.branch) merged.branch = p.branch; + } + return merged; } _tryParseStatusBar(line) { - const stripped = this._stripAnsi(line).trim(); + if (!line) return null; + // Strip ANSI then strip remaining C0/C1 control characters for robust matching + const stripped = this._stripAnsi(line).replace(/[\x00-\x1f\x7f-\x9f]/g, '').trim(); + if (!stripped) return null; // Exclude prompt and spinner/thinking lines — not a status bar if (/^[\u276F\u2700-\u27BF]/.test(stripped)) return null; - // New format: "⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt 9% until auto-compact" + // New format: "⏵⏵ bypass permissions on (shift+tab to cycle)" // "⏸ plan mode (shift+tab to cycle)" + // "⏵ accept edits on (shift+tab to cycle)" if (/[\u23F8\u23F5]/.test(stripped) && /shift\+tab/i.test(stripped)) { - let mode = 'code'; - if (/\u23F8/.test(stripped)) mode = 'plan'; - else if (/\u23F5/.test(stripped)) mode = 'bypass'; + let mode = 'ask'; + if (/\u23F8/.test(stripped) || /plan\s+mode/i.test(stripped)) mode = 'plan'; + else if (/bypass/i.test(stripped)) mode = 'bypass'; + else if (/accept\s+edits/i.test(stripped)) mode = 'accept'; const pctMatch = stripped.match(/(\d+)%\s+until\s+auto-compact/i); const stats = pctMatch ? `${pctMatch[1]}% context` : null; - return { mode, branch: null, stats, rawLine: line }; + return { mode, branch: null, model: null, stats, ctxPct: null, rawLine: line }; + } + + // Combined line: "user@host ... 11% until auto-compact" (tmux status + Claude compact %) + const compactMatch = stripped.match(/(\d+)%\s+until\s+auto-compact/i); + if (compactMatch) { + const ctxPct = parseInt(compactMatch[1], 10); + const modelMatch = stripped.match(/(Opus|Sonnet|Haiku)/i); + const model = modelMatch ? modelMatch[1] : null; + return { mode: null, branch: null, model, stats: `${ctxPct}% context`, ctxPct, rawLine: line }; + } + + // Custom statusline: "user@host repo branch Sonnet 4.6 ctx:60%" + // ctx: is the definitive signal — model name is optional secondary info + const ctxMatch = stripped.match(/ctx:(\d+)%/i); + if (ctxMatch) { + const ctxPct = parseInt(ctxMatch[1], 10); + const modelMatch = stripped.match(/(Opus|Sonnet|Haiku)\s+(\d+(?:\.\d+)?)/i); + const model = modelMatch ? `${modelMatch[1]} ${modelMatch[2]}` : null; + return { mode: null, branch: null, model, stats: `${ctxPct}%`, ctxPct, rawLine: line }; } // Old format: "⏵ bypass · main · 3.2k tokens" @@ -79,7 +120,7 @@ class ClaudeParser { } else if (/\u23F5/.test(firstPart)) { mode = 'bypass'; } else { - mode = 'code'; + mode = 'ask'; } // Branch: first part with no spaces that looks like a branch name @@ -95,7 +136,7 @@ class ClaudeParser { const statsParts = parts.filter((_, i) => i !== 0 && i !== branchIdx); const stats = statsParts.length > 0 ? statsParts.join(' · ') : null; - return { mode, branch, stats, rawLine: line }; + return { mode, branch, model: null, stats, ctxPct: null, rawLine: line }; } _parseBlocks(lines) { @@ -285,9 +326,11 @@ class ClaudeParser { return ( /^\s*\u276F/.test(s) || // ❯ prompt /^\s*[\u23FA\u25CF\u2B24]/.test(s) || // ⏺●⬤ tool-use -/^\s*\u23BF/.test(s) || // ⎿ response + /^\s*\u23BF/.test(s) || // ⎿ response /^\s*\u256D/.test(s) || // ╭ tool-result - s.trimStart().startsWith('╭') + s.trimStart().startsWith('╭') || + this._isTableRowLine(line) || // │ table row + this._isTableBorderLine(line) // ┌├┤┼ table border ); } diff --git a/public/js/claude-renderer.js b/public/js/claude-renderer.js index fe53d6e..d30415e 100644 --- a/public/js/claude-renderer.js +++ b/public/js/claude-renderer.js @@ -477,24 +477,24 @@ class ClaudeRenderer { this.statusBarEl.innerHTML = ''; - if (statusBar.mode) { - const icons = { plan: '⏸', code: '●', bypass: '⏵' }; - const labels = { plan: 'Plan', code: 'Code', bypass: 'Bypass' }; - const modes = ['plan', 'code', 'bypass']; - - const modeGroup = document.createElement('div'); - modeGroup.className = 'cc-mode-group'; - for (const mode of modes) { - const pill = document.createElement('button'); - pill.className = `cc-mode-pill cc-mode-${mode}`; - if (mode === statusBar.mode) pill.classList.add('cc-mode-active'); - pill.textContent = `${icons[mode]} ${labels[mode]}`; - pill.addEventListener('click', () => { - if (this._onAction) this._onAction('mode-toggle', mode); - }); - modeGroup.appendChild(pill); - } - this.statusBarEl.appendChild(modeGroup); + const mode = statusBar.mode || 'ask'; + { + const pill = document.createElement('button'); + pill.className = `cc-mode-pill cc-mode-${mode}`; + const icons = { plan: '⏸', ask: '●', bypass: '⏵⏵', accept: '✓' }; + const labels = { plan: 'Plan', ask: 'Ask', bypass: 'Bypass', accept: 'Accept' }; + pill.textContent = `${icons[mode] || '●'} ${labels[mode] || mode}`; + pill.addEventListener('click', () => { + if (this._onAction) this._onAction('mode-toggle', mode); + }); + this.statusBarEl.appendChild(pill); + } + + if (statusBar.model) { + const model = document.createElement('span'); + model.className = 'cc-status-model'; + model.textContent = statusBar.model; + this.statusBarEl.appendChild(model); } if (statusBar.branch) { @@ -504,20 +504,19 @@ class ClaudeRenderer { this.statusBarEl.appendChild(branch); } + if (statusBar.ctxPct != null) { + const bar = document.createElement('div'); + bar.className = 'cc-context-bar'; + const fill = document.createElement('div'); + fill.className = 'cc-context-fill'; + fill.style.width = `${statusBar.ctxPct}%`; + if (statusBar.ctxPct < 30) fill.classList.add('cc-ctx-low'); + else if (statusBar.ctxPct < 60) fill.classList.add('cc-ctx-mid'); + bar.appendChild(fill); + this.statusBarEl.appendChild(bar); + } + if (statusBar.stats) { - const pctMatch = statusBar.stats.match(/(\d+)%/); - if (pctMatch) { - const pct = parseInt(pctMatch[1], 10); - const bar = document.createElement('div'); - bar.className = 'cc-context-bar'; - const fill = document.createElement('div'); - fill.className = 'cc-context-fill'; - fill.style.width = `${pct}%`; - if (pct > 80) fill.classList.add('cc-ctx-high'); - else if (pct > 50) fill.classList.add('cc-ctx-mid'); - bar.appendChild(fill); - this.statusBarEl.appendChild(bar); - } const stats = document.createElement('span'); stats.className = 'cc-status-stats'; stats.textContent = statusBar.stats; diff --git a/public/js/codex-parser.js b/public/js/codex-parser.js new file mode 100644 index 0000000..b0b9220 --- /dev/null +++ b/public/js/codex-parser.js @@ -0,0 +1,217 @@ +// Codex CLI terminal output parser +// Markers (confirmed from actual output): +// › U+203A — user prompt +// • U+2022 — working indicator / tool-use / assistant response +// ╭ U+256D — box-drawing box (splash screen) +// | pipe — markdown pipe table + +class CodexParser { + constructor() { + this._lastLines = null; + this._lastDoc = null; + } + + parse(lines) { + if (lines === this._lastLines) return this._lastDoc; + this._lastLines = lines; + this._lastDoc = this._doParse(lines); + return this._lastDoc; + } + + _doParse(lines) { + if (!lines || lines.length === 0) return { blocks: [] }; + const blocks = this._parseBlocks(lines); + return { blocks }; + } + + _parseBlocks(lines) { + const blocks = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + const s = this._stripAnsi(line); + + // Empty + if (/^\s*$/.test(s)) { + blocks.push({ type: 'empty', lines: [line] }); + i++; + continue; + } + + // Separator: long run of ─ (U+2500) — skip + if (this._isSeparatorLine(line)) { + i++; + continue; + } + + // Box-drawing box ╭...╰ (splash screen / info box) + if (/^\s*\u256D/.test(s) || s.trimStart().startsWith('╭')) { + const bl = [line]; i++; + while (i < lines.length) { + const l = lines[i]; + bl.push(l); i++; + if (/[\u2570\u256F]/.test(this._stripAnsi(l))) break; + } + blocks.push({ type: 'tool-result', lines: bl }); + continue; + } + + // Markdown pipe table: line starting with | + if (/^\s*\|/.test(s)) { + const bl = [line]; i++; + while (i < lines.length && /^\s*\|/.test(this._stripAnsi(lines[i]))) { + bl.push(lines[i++]); + } + blocks.push({ type: 'table', lines: bl }); + continue; + } + + // › Prompt (U+203A user input) + if (/^\s*\u203A/.test(s)) { + const bl = [line]; i++; + while (i < lines.length) { + const ns = this._stripAnsi(lines[i]); + if (/^\s*$/.test(ns)) break; + if (this._isBlockStart(lines[i])) break; + bl.push(lines[i++]); + } + // Skip empty prompts + const firstText = s.replace(/^\s*\u203A\s*/, '').trim(); + if (!firstText) { i = i; continue; } + blocks.push({ type: 'prompt', lines: bl }); + continue; + } + + // • Working indicator (U+2022 + "Working" pattern) + if (/^\s*\u2022/.test(s)) { + const content = s.replace(/^\s*\u2022\s*/, ''); + + // Thinking/working spinner: "Working(Xs • esc to interrupt)" + if (/^Working[\s(]/.test(content)) { + const bl = [line]; i++; + while (i < lines.length) { + const ns = this._stripAnsi(lines[i]); + if (/^\s*\u2022\s*Working[\s(]/.test(ns) || /^\s*$/.test(ns)) { + if (/^\s*$/.test(ns)) break; + bl.push(lines[i++]); + } else break; + } + blocks.push({ type: 'thinking', lines: bl }); + continue; + } + + // Tool-use completed: "• Explored" / "• Completed" etc. with └ sub-bullet + const isToolUse = /^(Explored|Completed|Applied|Created|Updated|Deleted|Read|Listed|Searched|Ran|Executed)\b/.test(content); + const nextLine = i + 1 < lines.length ? this._stripAnsi(lines[i + 1]) : ''; + const hasSubBullet = /^\s+[└├]/.test(nextLine); + + if (isToolUse || hasSubBullet) { + const bl = [line]; i++; + // Collect sub-bullets (indented └ ├ lines) + while (i < lines.length) { + const ns = this._stripAnsi(lines[i]); + if (/^\s+[└├─]/.test(ns)) { + bl.push(lines[i++]); + } else break; + } + blocks.push({ type: 'tool-use', lines: bl }); + continue; + } + + // Response text: • plain prose + const bl = [line]; i++; + while (i < lines.length) { + const ns = this._stripAnsi(lines[i]); + if (/^\s*$/.test(ns)) break; + if (this._isBlockStart(lines[i])) break; + // Continuation: indented lines (list items, etc.) + if (/^ {2,}/.test(ns) || /^\s*[-*]/.test(ns)) { + bl.push(lines[i++]); + continue; + } + // Another • line → new block + if (/^\s*\u2022/.test(ns)) break; + bl.push(lines[i++]); + } + blocks.push({ type: 'response', lines: bl }); + continue; + } + + // Diff: +/- lines + if (/^[+-](?![+-])/.test(s) || /^@@/.test(s)) { + const bl = [line]; i++; + while (i < lines.length) { + const cs = this._stripAnsi(lines[i]); + if (/^[+\- @]/.test(cs) && !this._isBlockStart(lines[i])) { + bl.push(lines[i++]); + } else break; + } + blocks.push({ type: 'diff', lines: bl }); + continue; + } + + // Code: 4+ space indent with code-like content + if (/^ {4,}/.test(s) && this._isCodeLike(s)) { + const bl = [line]; i++; + while (i < lines.length) { + const cs = this._stripAnsi(lines[i]); + if (/^ {4,}/.test(cs)) { + bl.push(lines[i++]); + } else if (/^\s*$/.test(lines[i]) && i + 1 < lines.length && /^ {4,}/.test(this._stripAnsi(lines[i + 1]))) { + bl.push(lines[i++]); + } else { + break; + } + } + while (bl.length > 0 && /^\s*$/.test(bl[bl.length - 1])) bl.pop(); + if (bl.length > 0) blocks.push({ type: 'code', lines: bl }); + continue; + } + + // Text: fallback + const bl = [line]; i++; + while (i < lines.length && !this._isBlockStart(lines[i]) && !/^\s*$/.test(this._stripAnsi(lines[i]))) { + bl.push(lines[i++]); + } + blocks.push({ type: 'text', lines: bl }); + } + + return blocks; + } + + _isBlockStart(line) { + if (!line || /^\s*$/.test(line)) return false; + const s = this._stripAnsi(line); + return ( + /^\s*\u203A/.test(s) || // › prompt + /^\s*\u2022/.test(s) || // • tool/response + /^\s*\u256D/.test(s) || // ╭ box + s.trimStart().startsWith('╭') || + /^\s*\|/.test(s) || // | table + /^[+-](?![+-])/.test(s) || // diff + /^@@/.test(s) + ); + } + + _stripAnsi(line) { + return line.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/g, ''); + } + + _isSeparatorLine(line) { + const stripped = this._stripAnsi(line).trim(); + if (stripped.length < 3) return false; + const sepChars = (stripped.match(/[\u2500\u2501\u2550\u254C\u254D]/g) || []).length; + if (sepChars / stripped.length > 0.5) return true; + const uniqueChars = new Set(stripped.replace(/\s/g, '')).size; + return uniqueChars === 1 && stripped.length >= 3 && /[^\w\s]/.test(stripped); + } + + _isCodeLike(line) { + return /[{}\[\]();=]/.test(line) || + /\b(const|let|var|function|class|import|export|if|else|for|while|return|def|async)\b/.test(line) || + /^\s+\w[\w.]*\s*[({]/.test(line); + } +} + +window.CodexParser = CodexParser; diff --git a/public/js/codex-renderer.js b/public/js/codex-renderer.js new file mode 100644 index 0000000..97aceaa --- /dev/null +++ b/public/js/codex-renderer.js @@ -0,0 +1,387 @@ +// Codex CLI renderer — renders CodexParser output to DOM +// Reuses cc-* CSS classes from claude.css (body.codex-mode scope) +// No status bar, no mode switching, no selection options + +class CodexRenderer { + constructor(outputEl) { + this.outputEl = outputEl; + this._lastSignature = null; + this._blockEls = null; + this._blockSigs = null; + } + + render(doc) { + const sig = this._signature(doc.blocks); + if (sig !== this._lastSignature) { + this._renderBlocks(doc.blocks); + this._lastSignature = sig; + } + } + + clear() { + this.outputEl.innerHTML = ''; + this._lastSignature = null; + this._blockEls = null; + this._blockSigs = null; + } + + // ── Signature-based DOM diffing ────────────────────────────────────────── + + _blockSig(block) { + return `${block.type}:${block.lines.length}:${block.lines[0] || ''}`; + } + + _signature(blocks) { + return blocks.map(b => this._blockSig(b)).join(','); + } + + _renderBlocks(blocks) { + const newSigs = blocks.map(b => this._blockSig(b)); + + if (!this._blockEls || this._blockEls.length !== blocks.length) { + this._fullRebuild(blocks, newSigs); + return; + } + + for (let i = 0; i < blocks.length; i++) { + if (newSigs[i] === this._blockSigs[i]) continue; + const newEl = this._renderBlock(blocks[i]); + const oldEl = this._blockEls[i]; + if (newEl && oldEl && oldEl.parentNode) { + oldEl.parentNode.replaceChild(newEl, oldEl); + this._blockEls[i] = newEl; + } else { + this._fullRebuild(blocks, newSigs); + return; + } + this._blockSigs[i] = newSigs[i]; + } + } + + _fullRebuild(blocks, sigs) { + const frag = document.createDocumentFragment(); + const els = []; + for (const block of blocks) { + const el = this._renderBlock(block); + els.push(el); + if (el) frag.appendChild(el); + } + this.outputEl.innerHTML = ''; + this.outputEl.appendChild(frag); + this._blockEls = els; + this._blockSigs = sigs; + } + + // ── Block dispatcher ───────────────────────────────────────────────────── + + _renderBlock(block) { + switch (block.type) { + case 'prompt': return this._renderPrompt(block); + case 'thinking': return this._renderThinking(block); + case 'tool-use': return this._renderToolUse(block); + case 'tool-result': return this._renderToolResult(block); + case 'response': return this._renderResponse(block); + case 'table': return this._renderTable(block); + case 'diff': return this._renderDiff(block); + case 'code': return this._renderCode(block); + case 'text': return this._renderText(block); + case 'empty': return this._renderEmpty(); + default: return null; + } + } + + // ── Shared utilities ───────────────────────────────────────────────────── + + _stripAnsi(s) { + return s.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/g, ''); + } + + _esc(text) { + return text.replace(/&/g, '&').replace(//g, '>'); + } + + _addCopyButton(container, text) { + container.classList.add('cc-copy-wrap'); + const btn = document.createElement('button'); + btn.className = 'cc-copy-btn'; + btn.innerHTML = '⎘'; + btn.title = 'Copy'; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(text).then(() => { + btn.classList.add('copied'); + btn.textContent = '\u2713'; + setTimeout(() => { + btn.classList.remove('copied'); + btn.innerHTML = '⎘'; + }, 1500); + }); + }); + container.appendChild(btn); + } + + // ── Block renderers ─────────────────────────────────────────────────────── + + _renderPrompt(block) { + const div = document.createElement('div'); + div.className = 'cc-prompt'; + + const firstLine = block.lines[0]; + const stripped = this._stripAnsi(firstLine); + // Remove › marker + const markerMatch = stripped.match(/^(\s*\u203A\s*)/); + const text = markerMatch ? firstLine.slice(markerMatch[1].length) : firstLine; + + const marker = document.createElement('span'); + marker.className = 'cc-prompt-marker'; + marker.textContent = '›'; + + const textEl = document.createElement('span'); + textEl.className = 'cc-prompt-text'; + textEl.innerHTML = ansiToHtml(text); + + div.appendChild(marker); + div.appendChild(textEl); + + for (let i = 1; i < block.lines.length; i++) { + const cont = document.createElement('div'); + cont.className = 'cc-prompt-cont'; + cont.innerHTML = ansiToHtml(block.lines[i]); + div.appendChild(cont); + } + + return div; + } + + _renderThinking(block) { + const div = document.createElement('div'); + div.className = 'cc-thinking'; + // Strip • prefix and show content + const text = this._stripAnsi(block.lines[0]).replace(/^\s*\u2022\s*/, ''); + div.textContent = text; + return div; + } + + _renderToolUse(block) { + const div = document.createElement('div'); + div.className = 'cc-tool-use'; + + const header = document.createElement('div'); + header.className = 'cc-tool-header'; + + const icon = document.createElement('span'); + icon.className = 'cc-tool-icon'; + icon.textContent = '•'; + + // Extract verb from "• Explored" etc. + const firstStripped = this._stripAnsi(block.lines[0]).replace(/^\s*\u2022\s*/, ''); + const verbMatch = firstStripped.match(/^(\w+)(.*)/); + + const name = document.createElement('span'); + name.className = 'cc-tool-name'; + name.textContent = verbMatch ? verbMatch[1] : firstStripped; + + const args = document.createElement('span'); + args.className = 'cc-tool-args'; + args.textContent = verbMatch ? (verbMatch[2] || '').trim() : ''; + + header.appendChild(icon); + header.appendChild(name); + if (args.textContent) header.appendChild(args); + div.appendChild(header); + + // Sub-bullets (└ ├ lines) + const subLines = block.lines.slice(1); + if (subLines.length > 0) { + const body = document.createElement('div'); + body.className = 'cc-tool-body'; + for (const line of subLines) { + const lineEl = document.createElement('div'); + lineEl.className = 'cc-line'; + lineEl.innerHTML = ansiToHtml(line); + body.appendChild(lineEl); + } + div.appendChild(body); + } + + return div; + } + + _renderToolResult(block) { + const div = document.createElement('div'); + div.className = 'cc-tool-result'; + + const scrollable = document.createElement('div'); + scrollable.className = 'cc-scrollable'; + + const pre = document.createElement('pre'); + + // Strip ╭/╰ border lines; for │ content lines, remove the │ markers + const contentLines = []; + for (const line of block.lines) { + const s = this._stripAnsi(line).trimStart(); + if (/^[│┃║]/.test(s)) { + let cleaned = line.replace(/^(?:\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.))*[│┃║]\s?/, ''); + cleaned = cleaned.replace(/\s*[│┃║](?:\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.))*\s*$/, ''); + contentLines.push(cleaned.trimEnd()); + } + // Skip ╭/╰ border lines + } + + pre.innerHTML = contentLines.map(l => ansiToHtml(l)).join('\n'); + scrollable.appendChild(pre); + div.appendChild(scrollable); + + if (contentLines.length > 8) { + div.classList.add('cc-collapsible'); + const toggle = document.createElement('button'); + toggle.className = 'cc-collapse-toggle'; + toggle.textContent = `Show all (${contentLines.length} lines)`; + toggle.addEventListener('click', () => { + const expanded = div.classList.toggle('cc-expanded'); + toggle.textContent = expanded ? 'Show less' : `Show all (${contentLines.length} lines)`; + }); + div.appendChild(toggle); + } + + this._addCopyButton(div, contentLines.map(l => this._stripAnsi(l)).join('\n')); + return div; + } + + _renderResponse(block) { + const div = document.createElement('div'); + div.className = 'cc-response'; + for (const line of block.lines) { + const lineEl = document.createElement('div'); + lineEl.className = 'cc-line'; + // Strip • prefix from first line + const content = line === block.lines[0] + ? line.replace(/^(\x1b\[[0-9;]*m)*\s*\u2022\s?/, '') + : line; + lineEl.innerHTML = ansiToHtml(content); + div.appendChild(lineEl); + } + return div; + } + + // Markdown pipe table: | col | col | + _renderTable(block) { + const div = document.createElement('div'); + div.className = 'cc-table-wrap'; + + const scrollable = document.createElement('div'); + scrollable.className = 'cc-scrollable'; + + const table = document.createElement('table'); + table.className = 'cc-table'; + + const thead = document.createElement('thead'); + const tbody = document.createElement('tbody'); + + // Detect separator row: |---|---:| or |:---:| + const isSepRow = s => /^\s*\|[\s|:=-]+\|\s*$/.test(s.replace(/[^|:\-= ]/g, ' ')); + + let headerDone = false; + for (let i = 0; i < block.lines.length; i++) { + const stripped = this._stripAnsi(block.lines[i]).trim(); + if (!stripped.startsWith('|')) continue; + + // Check if NEXT line is a separator → this is header + const nextStripped = i + 1 < block.lines.length + ? this._stripAnsi(block.lines[i + 1]).trim() + : ''; + const nextIsSep = isSepRow(nextStripped); + + if (isSepRow(stripped)) { + headerDone = true; + continue; + } + + const cells = stripped.split('|').slice(1, -1); + const isHeader = !headerDone && nextIsSep; + + const tr = document.createElement('tr'); + for (const cell of cells) { + const cellEl = document.createElement(isHeader ? 'th' : 'td'); + cellEl.innerHTML = ansiToHtml(cell.trim()); + tr.appendChild(cellEl); + } + + if (isHeader) { + thead.appendChild(tr); + } else { + tbody.appendChild(tr); + } + } + + if (thead.children.length > 0) table.appendChild(thead); + table.appendChild(tbody); + scrollable.appendChild(table); + div.appendChild(scrollable); + return div; + } + + _renderDiff(block) { + const div = document.createElement('div'); + div.className = 'cc-diff'; + + const scrollable = document.createElement('div'); + scrollable.className = 'cc-scrollable'; + + const content = document.createElement('div'); + content.className = 'cc-diff-content'; + + for (const line of block.lines) { + const span = document.createElement('span'); + span.className = 'cc-diff-line'; + if (/^\+(?!\+)/.test(line)) span.classList.add('cc-added'); + else if (/^-(?!-)/.test(line)) span.classList.add('cc-removed'); + else if (/^@@/.test(line)) span.classList.add('cc-hunk'); + span.innerHTML = ansiToHtml(line); + content.appendChild(span); + content.appendChild(document.createTextNode('\n')); + } + + scrollable.appendChild(content); + div.appendChild(scrollable); + this._addCopyButton(div, block.lines.map(l => this._stripAnsi(l)).join('\n')); + return div; + } + + _renderCode(block) { + const div = document.createElement('div'); + div.className = 'cc-code'; + + const scrollable = document.createElement('div'); + scrollable.className = 'cc-scrollable'; + + const pre = document.createElement('pre'); + pre.className = 'cc-code-content'; + pre.innerHTML = block.lines.map(l => ansiToHtml(l)).join('\n'); + + scrollable.appendChild(pre); + div.appendChild(scrollable); + this._addCopyButton(div, block.lines.map(l => this._stripAnsi(l)).join('\n')); + return div; + } + + _renderText(block) { + const div = document.createElement('div'); + div.className = 'cc-text'; + for (const line of block.lines) { + const lineEl = document.createElement('div'); + lineEl.className = 'cc-line'; + lineEl.innerHTML = ansiToHtml(line); + div.appendChild(lineEl); + } + return div; + } + + _renderEmpty() { + const div = document.createElement('div'); + div.className = 'cc-empty'; + return div; + } +} + +window.CodexRenderer = CodexRenderer; diff --git a/public/js/terminal-view.js b/public/js/terminal-view.js index 5aaac1e..5dead05 100644 --- a/public/js/terminal-view.js +++ b/public/js/terminal-view.js @@ -101,6 +101,9 @@ class TerminalView { this.claudeMode = false; this.claudeParser = null; this.claudeRenderer = null; + this.codexMode = false; + this.codexParser = null; + this.codexRenderer = null; // Track scroll position to determine auto-scroll const container = this.el.parentElement; @@ -123,11 +126,24 @@ class TerminalView { if (this.lines.length > 10000) { this.lines = this.lines.slice(this.lines.length - 10000); } - // Invalidate parser cache since lines were mutated in-place + // Invalidate parser caches since lines were mutated in-place if (this.claudeParser) this.claudeParser._lastLines = null; + if (this.codexParser) this.codexParser._lastLines = null; this.scheduleRender(); } + setCodexMode(active) { + const changed = this.codexMode !== active; + this.codexMode = active; + if (active) { + if (!this.codexParser) this.codexParser = new CodexParser(); + if (!this.codexRenderer) this.codexRenderer = new CodexRenderer(this.el); + if (changed && this.lines.length > 0) this.scheduleRender(); + } else { + if (this.codexRenderer) this.codexRenderer.clear(); + } + } + setClaudeMode(active, onAction) { const changed = this.claudeMode !== active; this.claudeMode = active; @@ -166,9 +182,18 @@ class TerminalView { } render() { + if (this.codexMode && this.codexParser && this.codexRenderer) { + const doc = this.codexParser.parse(this.lines); + this.codexRenderer.render(doc); + if (this.autoScroll) { + const container = this.el.parentElement; + container.scrollTop = container.scrollHeight; + } + return; + } + if (this.claudeMode && this.claudeParser && this.claudeRenderer) { const doc = this.claudeParser.parse(this.lines); - this.lastClaudeDoc = doc; this.claudeRenderer.render(doc); if (this.autoScroll) { const container = this.el.parentElement; diff --git a/public/js/virtual-keyboard.js b/public/js/virtual-keyboard.js index e25932a..aa94d38 100644 --- a/public/js/virtual-keyboard.js +++ b/public/js/virtual-keyboard.js @@ -10,7 +10,6 @@ class VirtualKeyboard { this.shiftLocked = false; this._repeatTimer = null; this._repeatInterval = null; - this._composingEnterPending = false; this.textInput = document.getElementById('text-input'); this.sendBtn = document.getElementById('send-btn'); @@ -29,24 +28,15 @@ class VirtualKeyboard { static get REPEAT_INTERVAL() { return 80; } init() { - // Text input + send + // Text input — Enter inserts newline (textarea default); only send button submits. + // Height is fixed via CSS so the keyboard doesn't shift; multi-line scrolls internally. + + // mousedown.preventDefault keeps focus on the textarea so the soft keyboard + // stays up; the click event still fires. Without this, on mobile the first + // tap blurs the textarea / dismisses the keyboard and the viewport reflow + // can swallow the click — the user has to tap a second time to submit. + this.sendBtn.addEventListener('mousedown', (e) => e.preventDefault()); this.sendBtn.addEventListener('click', () => this.submitText()); - this.textInput.addEventListener('compositionend', () => { - if (this._composingEnterPending) { - this._composingEnterPending = false; - setTimeout(() => this.submitText(), 0); - } - }); - this.textInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - if (e.isComposing || e.keyCode === 229) { - this._composingEnterPending = true; - return; - } - e.preventDefault(); - this.submitText(); - } - }); // Ctrl modifier this.ctrlKey.addEventListener('click', () => this.toggleModifier('ctrl')); @@ -60,6 +50,16 @@ class VirtualKeyboard { this.shiftKey.addEventListener('click', () => this.toggleModifier('shift')); this.shiftKey.addEventListener('dblclick', () => this.lockModifier('shift')); + // Text shortcut keys (e.g. /compact) — type the text, then send Enter + // separately so slash-command menus have a tick to populate before submit. + document.querySelectorAll('.key[data-text]').forEach((btn) => { + btn.addEventListener('click', () => { + this._haptic(); + this.onSendText(btn.dataset.text); + setTimeout(() => this.onSendKey('Enter'), 50); + }); + }); + // Special keys document.querySelectorAll('.key[data-key]').forEach((btn) => { if (btn.classList.contains('modifier')) return; @@ -101,8 +101,17 @@ class VirtualKeyboard { this._haptic(); const text = this.textInput.value; if (text) { - this.onSendText(text + '\n'); + // Send the text WITHOUT a trailing newline, then a discrete Enter key + // event. cmux delivers send_text as a bracketed paste, so a trailing + // "\n" becomes a literal newline in the TUI input box (Claude Code / + // Codex) instead of submitting — that's why a single press only filled + // the box and a second (empty) press was needed to actually send. + // This replicates that two-press sequence in one press. The delay lets + // the paste land before Enter; larger for multi-line/big pastes that + // take longer to render than the short data-text shortcut keys. + this.onSendText(text); this.textInput.value = ''; + setTimeout(() => this.onSendKey('Enter'), 100); } else { // Empty submit = Enter key this.onSendKey('Enter'); diff --git a/src/bridge/cmux-client.ts b/src/bridge/cmux-client.ts index 50c84ba..4f7ab20 100644 --- a/src/bridge/cmux-client.ts +++ b/src/bridge/cmux-client.ts @@ -224,34 +224,33 @@ export class CmuxClient { return this.socketQuery(method, params).then(() => {}); } - async getClaudeCodeSurfaceRefs(): Promise { - const cached = this.getCached('claude-surfaces'); + private async _getSurfaceRefsForProcess(processName: string, cacheKey: string): Promise { + const cached = this.getCached(cacheKey); if (cached) return cached; - // Step 1: find all claude process PIDs and extract their CMUX_SURFACE_ID - // Use ps -axo to find processes whose executable path ends with /claude - const claudeUuids = new Set(); + // Step 1: find all matching process PIDs and extract their CMUX_SURFACE_ID + const uuids = new Set(); try { const { stdout: psListOut } = await execFileAsync('ps', ['-axo', 'pid=,args='], { timeout: 5000, maxBuffer: 10 * 1024 * 1024 }); - const claudePids: string[] = []; + const pids: string[] = []; for (const line of psListOut.split('\n')) { const parts = line.trim().split(/\s+/); const cmd = parts[1] || ''; - if (cmd.endsWith('/claude') || cmd === 'claude') { - claudePids.push(parts[0]); + if (cmd.endsWith(`/${processName}`) || cmd === processName) { + pids.push(parts[0]); } } - for (const pid of claudePids) { + for (const pid of pids) { try { const { stdout: psOut } = await execFileAsync('ps', ['-E', '-p', pid], { timeout: 5000 }); const match = psOut.match(/CMUX_SURFACE_ID=([A-F0-9-]+)/i); - if (match) claudeUuids.add(match[1]); + if (match) uuids.add(match[1]); } catch { /* skip */ } } - } catch { /* no claude processes */ } + } catch { /* no matching processes */ } - if (claudeUuids.size === 0) { - this.setCache('claude-surfaces', []); + if (uuids.size === 0) { + this.setCache(cacheKey, []); return []; } @@ -264,15 +263,23 @@ export class CmuxClient { try { const surfResult = await this.socketQuery<{ surfaces: Array<{ id: string; ref: string }> }>('surface.list', { workspace_id: ws.id }); for (const s of surfResult.surfaces) { - if (claudeUuids.has(s.id)) refs.push(s.ref); + if (uuids.has(s.id)) refs.push(s.ref); } } catch { /* skip workspace */ } } - this.setCache('claude-surfaces', refs); + this.setCache(cacheKey, refs); return refs; } + async getClaudeCodeSurfaceRefs(): Promise { + return this._getSurfaceRefsForProcess('claude', 'claude-surfaces'); + } + + async getCodexSurfaceRefs(): Promise { + return this._getSurfaceRefsForProcess('codex', 'codex-surfaces'); + } + async sendText(workspace: string, surface: string, text: string): Promise { await this.sendViaSocket('surface.send_text', { workspace_id: workspace, surface_id: surface, text }); } diff --git a/src/server/app.ts b/src/server/app.ts index a0d0ac9..3d37bfb 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -34,6 +34,16 @@ export function createApp(auth: AuthManager, cmux: CmuxClient): express.Express } }); + // Codex CLI surface detection + app.get('/api/codex-surfaces', async (_req, res) => { + try { + const surfaces = await cmux.getCodexSurfaceRefs(); + res.json({ surfaces }); + } catch { + res.json({ surfaces: [] }); + } + }); + // Token verification endpoint app.get('/api/auth/verify', (req, res) => { if (auth.isAuthDisabled()) { @@ -60,6 +70,7 @@ export function createApp(auth: AuthManager, cmux: CmuxClient): express.Express // SPA fallback — serve index.html for non-API, non-file routes app.get('/{*splat}', (_req, res) => { + res.setHeader('Cache-Control', 'no-store'); res.sendFile(join(publicDir, 'index.html')); });