diff --git a/public/css/claude.css b/public/css/claude.css
index 05689d3..27397c6 100644
--- a/public/css/claude.css
+++ b/public/css/claude.css
@@ -360,6 +360,65 @@ body.claude-mode .cc-empty {
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;
+}
+
+/* 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-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; }
+}
+
+/* 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);
+}
+
/* ────────────────────────────────────── */
/* Action bar (Claude keyboard row) */
/* ────────────────────────────────────── */
@@ -405,3 +464,163 @@ body.claude-mode .cc-empty {
@media (max-height: 500px) {
.cc-action-btn { height: 30px; }
}
+
+/* ────────────────────────────────────── */
+/* Copy button */
+/* ────────────────────────────────────── */
+.cc-copy-wrap { position: relative; }
+.cc-copy-btn {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ z-index: 2;
+ width: 28px;
+ height: 28px;
+ border: 1px solid var(--border-subtle, rgba(255,255,255,0.1));
+ border-radius: 4px;
+ background: var(--bg-secondary, #1a1a2e);
+ color: var(--text-dimmed, #888);
+ font-size: 0.8rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.15s, background 0.15s, color 0.15s;
+ line-height: 1;
+}
+.cc-copy-btn:hover { background: var(--bg-tertiary, #252540); }
+.cc-copy-btn.copied { color: var(--green, #98c379); }
+@media (hover: hover) {
+ .cc-copy-wrap:hover .cc-copy-btn { opacity: 1; }
+}
+@media (hover: none) {
+ .cc-copy-btn { opacity: 0.6; }
+}
+
+/* ────────────────────────────────────── */
+/* Collapsible tool results */
+/* ────────────────────────────────────── */
+.cc-tool-result.cc-collapsible .cc-scrollable {
+ max-height: 12em;
+ overflow: hidden;
+ transition: max-height 0.25s ease;
+}
+.cc-tool-result.cc-collapsible.cc-expanded .cc-scrollable {
+ max-height: none;
+}
+.cc-collapse-toggle {
+ display: block;
+ width: 100%;
+ padding: 4px 8px;
+ border: none;
+ border-top: 1px solid var(--border-subtle, rgba(255,255,255,0.1));
+ border-radius: 0 0 6px 6px;
+ background: var(--bg-tertiary, #1a1a2e);
+ color: var(--text-dimmed, #888);
+ font-size: 0.75rem;
+ cursor: pointer;
+ text-align: center;
+ transition: color 0.15s, background 0.15s;
+}
+.cc-collapse-toggle:hover {
+ color: var(--text-secondary, #ccc);
+ background: var(--bg-secondary, #252540);
+}
+
+/* ────────────────────────────────────── */
+/* Collapsible tool-use body */
+/* ────────────────────────────────────── */
+.cc-tool-use.cc-collapsible .cc-tool-body {
+ max-height: 7.5em;
+ overflow: hidden;
+ transition: max-height 0.25s ease;
+}
+.cc-tool-use.cc-collapsible.cc-expanded .cc-tool-body {
+ max-height: none;
+}
+
+/* ────────────────────────────────────── */
+/* Thinking block pulse animation */
+/* ────────────────────────────────────── */
+body.claude-mode .cc-thinking {
+ animation: cc-thinking-pulse 2s ease-in-out infinite;
+}
+@keyframes cc-thinking-pulse {
+ 0%, 100% { opacity: 0.5; }
+ 50% { opacity: 0.9; }
+}
+
+/* ────────────────────────────────────── */
+/* Prompt turn boundaries */
+/* ────────────────────────────────────── */
+body.claude-mode .cc-prompt ~ .cc-prompt {
+ margin-top: 16px;
+ padding-top: 10px;
+}
+
+/* ────────────────────────────────────── */
+/* Selection option UX */
+/* ────────────────────────────────────── */
+body.claude-mode .cc-option.cc-option-focused {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+}
+
+.cc-option-hint {
+ margin-left: auto;
+ font-size: 0.72em;
+ color: var(--text-dimmed);
+ 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 47be917..1f9178b 100644
--- a/public/css/keyboard.css
+++ b/public/css/keyboard.css
@@ -39,7 +39,7 @@
padding: 0 7px;
-webkit-user-select: none;
user-select: none;
- transition: background var(--transition-fast), transform var(--transition-fast), border-color var(--transition-fast);
+ transition: background var(--transition-fast), transform var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.key:active {
diff --git a/public/css/terminal.css b/public/css/terminal.css
index fffaaaf..12ce27a 100644
--- a/public/css/terminal.css
+++ b/public/css/terminal.css
@@ -134,6 +134,26 @@
will-change: transform;
}
+/* Scrollback loading spinner */
+.scrollback-loading[hidden] { display: none; }
+.scrollback-loading {
+ position: sticky;
+ top: 0;
+ z-index: 5;
+ text-align: center;
+ padding: 8px 0;
+}
+.scrollback-spinner {
+ width: 18px;
+ height: 18px;
+ border: 2px solid var(--border-color, #333);
+ border-top-color: var(--accent, #61afef);
+ border-radius: 50%;
+ animation: scrollback-spin 0.6s linear infinite;
+ display: inline-block;
+}
+@keyframes scrollback-spin { to { transform: rotate(360deg); } }
+
.terminal-output {
margin: 0;
padding: 14px 16px;
diff --git a/public/index.html b/public/index.html
index 2e6d08e..c19242e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -11,10 +11,10 @@
-
-
+
+
-
+
@@ -44,6 +44,9 @@
+
@@ -113,16 +116,16 @@
-
-
+
+
-
-
-
-
+
+
+
+
diff --git a/public/js/app.js b/public/js/app.js
index b16f73d..7a1fa9c 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -164,10 +164,12 @@
let scrollbackLines = 0; // 0 = live view
let inScrollMode = false;
let scrollbackPending = false;
+ const scrollbackLoading = document.getElementById('scrollback-loading');
function requestScrollback(lines) {
if (!currentWorkspace || !currentSurface) return;
scrollbackPending = true;
+ if (scrollbackLoading) scrollbackLoading.hidden = false;
ws.requestScroll(currentWorkspace, currentSurface, lines);
}
@@ -175,6 +177,7 @@
inScrollMode = false;
scrollbackLines = 0;
scrollbackPending = false;
+ if (scrollbackLoading) scrollbackLoading.hidden = true;
terminal.autoScroll = true;
// Re-subscribe to get fresh live content
if (currentWorkspace && currentSurface) {
@@ -394,6 +397,7 @@
terminal.setContent(msg.lines);
terminal.autoScroll = false;
scrollbackPending = false;
+ if (scrollbackLoading) scrollbackLoading.hidden = true;
requestAnimationFrame(() => {
const newScrollHeight = terminalContainer.scrollHeight;
@@ -404,11 +408,17 @@
} else {
terminal.setContent(msg.lines);
}
+ if (terminal.claudeMode && terminal.lastClaudeDoc) {
+ claudeKeyboard.updateContext(terminal.lastClaudeDoc);
+ }
});
ws.on('screen-diff', (msg) => {
if (msg.surface === currentSurface && !inScrollMode) {
terminal.applyDiff(msg.patches);
+ if (terminal.claudeMode && terminal.lastClaudeDoc) {
+ claudeKeyboard.updateContext(terminal.lastClaudeDoc);
+ }
}
});
@@ -452,6 +462,8 @@
if (currentSurface) ws.unsubscribe(currentSurface);
inScrollMode = false;
scrollbackLines = 0;
+ scrollbackPending = false;
+ if (scrollbackLoading) scrollbackLoading.hidden = true;
currentWorkspace = wsRef;
currentSurface = surfaceRef;
diff --git a/public/js/claude-keyboard.js b/public/js/claude-keyboard.js
index b18bd21..81ea624 100644
--- a/public/js/claude-keyboard.js
+++ b/public/js/claude-keyboard.js
@@ -3,17 +3,139 @@ class ClaudeKeyboard {
this.onSendText = onSendText;
this.onSendKey = onSendKey;
this._active = false;
- this._actionBar = null;
+ this._actionRow = null;
+ this._allKeysRow = null;
+ this._lastMode = null;
+ this._lastIsThinking = false;
+ this._lastSelectionOptions = 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) {
@@ -24,12 +146,15 @@ class ClaudeKeyboard {
iconEl.className = 'cc-action-icon';
iconEl.textContent = icon;
- const labelEl = document.createElement('span');
- labelEl.className = 'cc-action-label';
- labelEl.textContent = label;
-
btn.appendChild(iconEl);
- btn.appendChild(labelEl);
+
+ if (label) {
+ const labelEl = document.createElement('span');
+ labelEl.className = 'cc-action-label';
+ labelEl.textContent = label;
+ btn.appendChild(labelEl);
+ }
+
btn.addEventListener('click', () => {
if (navigator.vibrate) navigator.vibrate(8);
onClick();
@@ -37,7 +162,6 @@ class ClaudeKeyboard {
return btn;
}
-
}
window.ClaudeKeyboard = ClaudeKeyboard;
diff --git a/public/js/claude-parser.js b/public/js/claude-parser.js
index 79a825e..babf226 100644
--- a/public/js/claude-parser.js
+++ b/public/js/claude-parser.js
@@ -215,6 +215,19 @@ class ClaudeParser {
continue;
}
+ // Thinking: lines with only spinner/dingbat characters (✻✳✢✦ U+2700-U+27BF)
+ if (/^\s*[\u2700-\u27BF]+\s*$/.test(s)) {
+ const bl = [line]; i++;
+ while (i < lines.length) {
+ const ns = this._stripAnsi(lines[i]);
+ if (/^\s*[\u2700-\u27BF]+\s*$/.test(ns) || /^\s*$/.test(ns)) {
+ bl.push(lines[i++]);
+ } else break;
+ }
+ blocks.push({ type: 'thinking', lines: bl, toolName: null, options: null });
+ continue;
+ }
+
// Diff: +/- lines (only when clearly a diff context)
if (/^[+-](?![+-])/.test(s) || /^@@/.test(s)) {
const bl = [line]; i++;
@@ -373,8 +386,10 @@ class ClaudeParser {
_parseOptions(lines) {
const opts = [];
for (const line of lines) {
- const m = this._stripAnsi(line).match(/^\s*\u276F?\s*(\d+|[a-z])[.)]\s+(.+)/);
- if (m) opts.push({ index: m[1], label: m[2].trim(), key: m[1] });
+ const s = this._stripAnsi(line);
+ const focused = /^\s*\u276F/.test(s);
+ const m = s.match(/^\s*\u276F?\s*(\d+|[a-z])[.)]\s+(.+)/);
+ if (m) opts.push({ index: m[1], label: m[2].trim(), key: m[1], focused });
}
return opts;
}
diff --git a/public/js/claude-renderer.js b/public/js/claude-renderer.js
index fbc9b14..fe53d6e 100644
--- a/public/js/claude-renderer.js
+++ b/public/js/claude-renderer.js
@@ -18,6 +18,26 @@ class ClaudeRenderer {
return s.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|.)/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);
+ }
+
render(doc) {
const sig = this._signature(doc.blocks);
if (sig !== this._lastSignature) {
@@ -97,6 +117,7 @@ class ClaudeRenderer {
case 'table': return this._renderTable(block);
case 'diff': return this._renderDiff(block);
case 'selection': return this._renderSelection(block);
+ case 'thinking': return this._renderThinking(block);
case 'text': return this._renderText(block);
case 'empty': return this._renderEmpty();
default: return null;
@@ -200,6 +221,19 @@ class ClaudeRenderer {
body.appendChild(line);
}
div.appendChild(body);
+
+ const bodyLineCount = block.lines.length - 1;
+ if (bodyLineCount > 5) {
+ div.classList.add('cc-collapsible');
+ const toggle = document.createElement('button');
+ toggle.className = 'cc-collapse-toggle';
+ toggle.textContent = `Show all (${bodyLineCount} lines)`;
+ toggle.addEventListener('click', () => {
+ const expanded = div.classList.toggle('cc-expanded');
+ toggle.textContent = expanded ? 'Show less' : `Show all (${bodyLineCount} lines)`;
+ });
+ div.appendChild(toggle);
+ }
}
return div;
@@ -230,6 +264,21 @@ class ClaudeRenderer {
pre.innerHTML = contentLines.map(l => ansiToHtml(l)).join('\n');
scrollable.appendChild(pre);
div.appendChild(scrollable);
+
+ // Collapsible for long results
+ 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;
}
@@ -246,7 +295,9 @@ class ClaudeRenderer {
for (const line of block.lines) {
const lineEl = document.createElement('div');
lineEl.className = 'cc-line';
- lineEl.innerHTML = this._renderLine(line);
+ // Strip ⎿ prefix marker, preserve ANSI colors
+ const content = line.replace(/^(\x1b\[[0-9;]*m)*\s*\u23BF\s?/, '');
+ lineEl.innerHTML = ansiToHtml(content);
div.appendChild(lineEl);
}
return div;
@@ -258,7 +309,7 @@ class ClaudeRenderer {
for (const line of block.lines) {
const lineEl = document.createElement('div');
lineEl.className = 'cc-line';
- lineEl.innerHTML = this._renderLine(line);
+ lineEl.innerHTML = ansiToHtml(line);
div.appendChild(lineEl);
}
return div;
@@ -277,6 +328,7 @@ class ClaudeRenderer {
scrollable.appendChild(pre);
div.appendChild(scrollable);
+ this._addCopyButton(div, block.lines.map(l => this._stripAnsi(l)).join('\n'));
return div;
}
@@ -367,6 +419,7 @@ class ClaudeRenderer {
scrollable.appendChild(content);
div.appendChild(scrollable);
+ this._addCopyButton(div, block.lines.map(l => this._stripAnsi(l)).join('\n'));
return div;
}
@@ -377,7 +430,7 @@ class ClaudeRenderer {
if (block.options && block.options.length > 0) {
for (const opt of block.options) {
const btn = document.createElement('button');
- btn.className = 'cc-option';
+ btn.className = 'cc-option' + (opt.focused ? ' cc-option-focused' : '');
btn.dataset.key = opt.key;
const indexEl = document.createElement('span');
@@ -388,8 +441,13 @@ class ClaudeRenderer {
labelEl.className = 'cc-option-label';
labelEl.textContent = opt.label;
+ const hint = document.createElement('span');
+ hint.className = 'cc-option-hint';
+ hint.textContent = `⌨ ${opt.key}`;
+
btn.appendChild(indexEl);
btn.appendChild(labelEl);
+ btn.appendChild(hint);
btn.addEventListener('click', () => {
if (this._onAction) this._onAction('select', opt.key);
});
@@ -420,15 +478,23 @@ class ClaudeRenderer {
this.statusBarEl.innerHTML = '';
if (statusBar.mode) {
- const pill = document.createElement('button');
- pill.className = `cc-mode-pill cc-mode-${statusBar.mode}`;
const icons = { plan: '⏸', code: '●', bypass: '⏵' };
const labels = { plan: 'Plan', code: 'Code', bypass: 'Bypass' };
- pill.textContent = `${icons[statusBar.mode] || ''} ${labels[statusBar.mode] || statusBar.mode}`;
- pill.addEventListener('click', () => {
- if (this._onAction) this._onAction('mode-toggle', statusBar.mode);
- });
- this.statusBarEl.appendChild(pill);
+ 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);
}
if (statusBar.branch) {
@@ -439,6 +505,19 @@ class ClaudeRenderer {
}
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/terminal-view.js b/public/js/terminal-view.js
index aa7fff6..5aaac1e 100644
--- a/public/js/terminal-view.js
+++ b/public/js/terminal-view.js
@@ -112,7 +112,7 @@ class TerminalView {
}
setContent(lines) {
- this.lines = lines;
+ this.lines = lines.length > 10000 ? lines.slice(lines.length - 10000) : lines;
this.scheduleRender();
}
@@ -120,6 +120,9 @@ class TerminalView {
for (const patch of patches) {
this.lines.splice(patch.startLine, patch.deleteCount, ...patch.insertLines);
}
+ if (this.lines.length > 10000) {
+ this.lines = this.lines.slice(this.lines.length - 10000);
+ }
// Invalidate parser cache since lines were mutated in-place
if (this.claudeParser) this.claudeParser._lastLines = null;
this.scheduleRender();
@@ -165,6 +168,7 @@ class TerminalView {
render() {
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/websocket-client.js b/public/js/websocket-client.js
index f1b2370..2ca3502 100644
--- a/public/js/websocket-client.js
+++ b/public/js/websocket-client.js
@@ -8,6 +8,8 @@ class WebSocketClient {
this.shouldReconnect = true;
this.connected = false;
this.reconnectAttempt = 0;
+ this._heartbeatCheck = null;
+ this._lastServerPing = 0;
}
connect(authRequired) {
@@ -27,6 +29,8 @@ class WebSocketClient {
this.connected = true;
this.reconnectAttempt = 0;
this.currentDelay = this.reconnectDelay;
+ this._lastServerPing = Date.now();
+ this._startHeartbeatCheck();
this.emit('connected');
// Authenticate (send token or empty for no-auth mode)
@@ -36,6 +40,11 @@ class WebSocketClient {
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
+ if (msg.type === 'ping') {
+ this._lastServerPing = Date.now();
+ this.send({ type: 'pong' });
+ return;
+ }
this.emit(msg.type, msg);
// Redirect to login on auth failure (only if auth is required)
@@ -50,6 +59,7 @@ class WebSocketClient {
this.ws.onclose = () => {
this.connected = false;
+ this._stopHeartbeatCheck();
this.emit('disconnected');
if (this.shouldReconnect) {
@@ -120,8 +130,25 @@ class WebSocketClient {
this.send({ type: 'scroll-request', workspace, surface, lines });
}
+ _startHeartbeatCheck() {
+ this._stopHeartbeatCheck();
+ this._heartbeatCheck = setInterval(() => {
+ if (Date.now() - this._lastServerPing > 15000) {
+ this.ws?.close();
+ }
+ }, 5000);
+ }
+
+ _stopHeartbeatCheck() {
+ if (this._heartbeatCheck) {
+ clearInterval(this._heartbeatCheck);
+ this._heartbeatCheck = null;
+ }
+ }
+
// Force immediate reconnection (reset backoff, close stale socket)
reconnectNow() {
+ this._stopHeartbeatCheck();
if (this.ws) {
// Temporarily disable auto-reconnect so onclose doesn't double-connect
this.shouldReconnect = false;
@@ -134,6 +161,7 @@ class WebSocketClient {
}
disconnect() {
+ this._stopHeartbeatCheck();
this.shouldReconnect = false;
if (this.ws) this.ws.close();
}
diff --git a/src/bridge/cmux-client.ts b/src/bridge/cmux-client.ts
index bd8b2c7..50c84ba 100644
--- a/src/bridge/cmux-client.ts
+++ b/src/bridge/cmux-client.ts
@@ -198,13 +198,25 @@ export class CmuxClient {
const conn = createConnection(this.socketPath, () => {
conn.write(JSON.stringify({ id, method, params }) + '\n');
});
+ const timeout = setTimeout(() => {
+ conn.destroy();
+ reject(new Error(`Socket timed out after 10s (method: ${method})`));
+ }, 10000);
conn.on('data', (data) => {
+ clearTimeout(timeout);
conn.destroy();
- const msg = JSON.parse(data.toString());
- if (msg.ok) resolve(msg.result as T);
- else reject(new Error(msg.error?.message || 'socket error'));
+ try {
+ const msg = JSON.parse(data.toString());
+ if (msg.ok) resolve(msg.result as T);
+ else reject(new Error(msg.error?.message || 'socket error'));
+ } catch {
+ reject(new Error('Invalid response from cmux socket'));
+ }
+ });
+ conn.on('error', (err) => {
+ clearTimeout(timeout);
+ reject(err);
});
- conn.on('error', reject);
});
}
diff --git a/src/index.ts b/src/index.ts
index 396a528..45c0081 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -89,7 +89,9 @@ program
console.log('');
console.log(` Local: http://localhost:${config.port}`);
if (config.password) {
- console.log(` Password: ${config.password}`);
+ const p = config.password;
+ const masked = p.length <= 4 ? '****' : p[0] + '*'.repeat(p.length - 2) + p[p.length - 1];
+ console.log(` Password: ${masked}`);
} else {
console.log(` Auth: disabled (use -P to set password)`);
}
@@ -124,6 +126,7 @@ program
if (shuttingDown) return;
shuttingDown = true;
console.log('\nShutting down...');
+ auth.destroy();
cmux.shutdown();
poller.destroy();
wsServer.close();
diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts
index cc9c490..74ac2f4 100644
--- a/src/protocol/messages.ts
+++ b/src/protocol/messages.ts
@@ -62,13 +62,18 @@ export interface BrowserScreenshotMessage {
mime: string; // e.g. 'image/jpeg'
}
+export interface PingMessage {
+ type: 'ping';
+}
+
export type ServerMessage =
| ScreenMessage
| ScreenDiffMessage
| WorkspacesMessage
| AuthResultMessage
| ErrorMessage
- | BrowserScreenshotMessage;
+ | BrowserScreenshotMessage
+ | PingMessage;
// Client -> Server messages
export interface AuthMessage {
@@ -117,6 +122,10 @@ export interface ScrollRequestMessage {
lines: number;
}
+export interface PongMessage {
+ type: 'pong';
+}
+
export type ClientMessage =
| AuthMessage
| SubscribeMessage
@@ -125,4 +134,5 @@ export type ClientMessage =
| SendKeyMessage
| ListWorkspacesMessage
| ListSurfacesMessage
- | ScrollRequestMessage;
+ | ScrollRequestMessage
+ | PongMessage;
diff --git a/src/server/auth.ts b/src/server/auth.ts
index 13ab99a..97016ad 100644
--- a/src/server/auth.ts
+++ b/src/server/auth.ts
@@ -18,9 +18,25 @@ export class AuthManager {
private jwtSecret: string;
private attempts = new Map();
private authDisabled = false;
+ private cleanupInterval: ReturnType;
constructor() {
this.jwtSecret = randomBytes(32).toString('hex');
+ this.cleanupInterval = setInterval(() => this.sweepExpiredAttempts(), 5 * 60 * 1000);
+ this.cleanupInterval.unref();
+ }
+
+ destroy(): void {
+ clearInterval(this.cleanupInterval);
+ }
+
+ private sweepExpiredAttempts(): void {
+ const now = Date.now();
+ for (const [ip, record] of this.attempts) {
+ if (now - record.firstAttempt > RATE_WINDOW_MS) {
+ this.attempts.delete(ip);
+ }
+ }
}
disableAuth(): void {
diff --git a/src/server/websocket.ts b/src/server/websocket.ts
index 420eba2..e9e838f 100644
--- a/src/server/websocket.ts
+++ b/src/server/websocket.ts
@@ -11,6 +11,7 @@ import type { ClientMessage, ServerMessage } from '../protocol/messages.js';
interface ClientInfo {
id: string;
ws: WebSocket;
+ lastPong: number;
}
export class WsServer {
@@ -22,6 +23,7 @@ export class WsServer {
private input: InputHandler;
private cmux: CmuxClient;
private verbose: boolean;
+ private heartbeatInterval: ReturnType;
constructor(opts: {
server: Server;
@@ -42,9 +44,11 @@ export class WsServer {
this.wss = new WebSocketServer({
server: opts.server,
perMessageDeflate: true,
+ maxPayload: 1024 * 1024, // 1MB limit
});
this.wss.on('connection', (ws) => this.handleConnection(ws));
+ this.heartbeatInterval = setInterval(() => this.checkHeartbeats(), 5000);
// Listen for screen updates from poller
this.poller.on('update', (update) => this.broadcastUpdate(update));
@@ -53,7 +57,7 @@ export class WsServer {
private handleConnection(ws: WebSocket): void {
const clientId = nanoid(12);
- this.clients.set(clientId, { id: clientId, ws });
+ this.clients.set(clientId, { id: clientId, ws, lastPong: Date.now() });
this.sessions.addClient(clientId);
if (this.verbose) console.log(`[ws] Client connected: ${clientId}`);
@@ -83,6 +87,13 @@ export class WsServer {
}
private async handleMessage(clientId: string, msg: ClientMessage): Promise {
+ // Heartbeat pong — handle before auth check
+ if (msg.type === 'pong') {
+ const client = this.clients.get(clientId);
+ if (client) client.lastPong = Date.now();
+ return;
+ }
+
// Auth messages don't require prior authentication
if (msg.type === 'auth') {
const valid = this.auth.verifyToken(msg.token);
@@ -218,7 +229,20 @@ export class WsServer {
return this.clients.size;
}
+ private checkHeartbeats(): void {
+ const now = Date.now();
+ for (const [clientId, client] of this.clients) {
+ if (now - client.lastPong > 15000) {
+ if (this.verbose) console.log(`[ws] Heartbeat timeout: ${clientId}`);
+ client.ws.terminate();
+ } else {
+ this.sendTo(clientId, { type: 'ping' });
+ }
+ }
+ }
+
close(): void {
+ clearInterval(this.heartbeatInterval);
this.wss.close();
}
}