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