feat: fix one-press send, add Codex parsed view

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 <noreply@anthropic.com>
main
I Luk Kim 3 months ago
parent efb0be7896
commit 577b4d749b

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

@ -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); }
.virtual-keyboard.collapsed .key-row:not(.input-row) { display: none; }

@ -194,6 +194,7 @@ body {
/* ── Terminal container ──────────────────────────── */
.terminal-container {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;

@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-store">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>cmux-remote</title>
<link rel="manifest" href="/manifest.json">
@ -10,11 +11,11 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/css/themes.css?v=2">
<link rel="stylesheet" href="/css/main.css?v=8">
<link rel="stylesheet" href="/css/main.css?v=9">
<link rel="stylesheet" href="/css/terminal.css?v=5">
<link rel="stylesheet" href="/css/keyboard.css?v=7">
<link rel="stylesheet" href="/css/keyboard.css?v=14">
<link rel="stylesheet" href="/css/sidebar.css?v=6">
<link rel="stylesheet" href="/css/claude.css?v=8">
<link rel="stylesheet" href="/css/claude.css?v=17">
</head>
<body>
<!-- Sidebar -->
@ -69,10 +70,11 @@
<button class="key modifier" data-modifier="ctrl" id="ctrl-key">Ctrl</button>
<button class="key modifier" data-modifier="alt" id="alt-key">Alt</button>
<button class="key modifier" data-modifier="shift" id="shift-key">&#x21E7;</button>
<button class="key" data-key="ShiftTab"></button>
<button class="key" data-key="ShiftTab">Shift+Tab</button>
<button class="key shortcut" data-key="Backspace"></button>
<button class="key shortcut" data-key="Ctrl-c">^C</button>
<button class="key shortcut" data-key="/">/</button>
<button class="key shortcut claude-codex-only" data-text="/compact">/compact</button>
<button class="key" data-key="ArrowLeft"></button>
<button class="key" data-key="ArrowDown"></button>
<button class="key" data-key="ArrowUp"></button>
@ -82,8 +84,8 @@
<button class="keyboard-toggle" id="keyboard-toggle" aria-label="Toggle keyboard">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M6 9h.01M10 9h.01M14 9h.01M18 9h.01M8 13h.01M12 13h.01M16 13h.01M6 17h12"/></svg>
</button>
<input type="text" class="text-input" id="text-input" placeholder="Type here..."
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<textarea class="text-input" id="text-input" placeholder="Type here..." rows="1"
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"></textarea>
<button class="key send-key" id="send-btn">&crarr;</button>
</div>
</footer>
@ -117,15 +119,17 @@
</aside>
<script src="/js/websocket-client.js?v=4"></script>
<script src="/js/terminal-view.js?v=8"></script>
<script src="/js/virtual-keyboard.js?v=3"></script>
<script src="/js/terminal-view.js?v=9"></script>
<script src="/js/virtual-keyboard.js?v=9"></script>
<script src="/js/sidebar.js?v=5"></script>
<script src="/js/gestures.js?v=2"></script>
<script src="/js/theme.js?v=4"></script>
<script src="/js/claude-parser.js?v=11"></script>
<script src="/js/claude-renderer.js?v=12"></script>
<script src="/js/claude-parser.js?v=23"></script>
<script src="/js/claude-renderer.js?v=21"></script>
<script src="/js/claude-keyboard.js?v=5"></script>
<script src="/js/app.js?v=11"></script>
<script src="/js/codex-parser.js?v=1"></script>
<script src="/js/codex-renderer.js?v=1"></script>
<script src="/js/app.js?v=14"></script>
<script src="/js/debug-safe-area.js"></script>
<script src="/js/sw-unregister.js"></script>
</body>

@ -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))

@ -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;

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

@ -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;

@ -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;

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
_addCopyButton(container, text) {
container.classList.add('cc-copy-wrap');
const btn = document.createElement('button');
btn.className = 'cc-copy-btn';
btn.innerHTML = '&#x2398;';
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 = '&#x2398;';
}, 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;

@ -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;

@ -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');

@ -224,34 +224,33 @@ export class CmuxClient {
return this.socketQuery<void>(method, params).then(() => {});
}
async getClaudeCodeSurfaceRefs(): Promise<string[]> {
const cached = this.getCached<string[]>('claude-surfaces');
private async _getSurfaceRefsForProcess(processName: string, cacheKey: string): Promise<string[]> {
const cached = this.getCached<string[]>(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<string>();
// Step 1: find all matching process PIDs and extract their CMUX_SURFACE_ID
const uuids = new Set<string>();
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<string[]> {
return this._getSurfaceRefsForProcess('claude', 'claude-surfaces');
}
async getCodexSurfaceRefs(): Promise<string[]> {
return this._getSurfaceRefsForProcess('codex', 'codex-surfaces');
}
async sendText(workspace: string, surface: string, text: string): Promise<void> {
await this.sendViaSocket('surface.send_text', { workspace_id: workspace, surface_id: surface, text });
}

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

Loading…
Cancel
Save