feat: Claude mode UI improvements and stability enhancements

Claude mode view:
- Context-aware action row replaces all-keys row in Claude mode
  (mode pill, stop button, arrows; selection→option buttons; thinking→pulsing stop)
- Thinking block detection and pulse animation
- Response/text blocks preserve ANSI colors via ansiToHtml()
- Tool-use body collapsible for long outputs (>5 lines)
- Context usage progress bar in status bar (green/yellow/red)
- Mode pill group shows all 3 modes with active highlight + pop animation
- Selection options show focused state and keyboard shortcut hint
- Prompt turn boundaries with visual spacing

Stability:
- WebSocket heartbeat (app-level ping/pong, 15s timeout)
- WS max payload 1MB limit
- Auth rate-limit map periodic cleanup
- Socket connection timeout (10s)
- Terminal line buffer cap (10,000 lines)
- Password masking in console output

UX:
- Scrollback loading indicator
- Code/diff/tool-result copy buttons
- Collapsible tool results (>8 lines)
- Modifier key box-shadow transition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent a3a607d1f4
commit efb0be7896

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

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

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

@ -11,10 +11,10 @@
<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/terminal.css?v=4">
<link rel="stylesheet" href="/css/keyboard.css?v=6">
<link rel="stylesheet" href="/css/terminal.css?v=5">
<link rel="stylesheet" href="/css/keyboard.css?v=7">
<link rel="stylesheet" href="/css/sidebar.css?v=6">
<link rel="stylesheet" href="/css/claude.css?v=6">
<link rel="stylesheet" href="/css/claude.css?v=8">
</head>
<body>
<!-- Sidebar -->
@ -44,6 +44,9 @@
<!-- Terminal area -->
<main class="terminal-container" id="terminal-container">
<div class="scrollback-loading" id="scrollback-loading" hidden>
<div class="scrollback-spinner"></div>
</div>
<pre class="terminal-output" id="terminal-output"></pre>
<img class="browser-view" id="browser-view" hidden alt="browser">
<button class="browser-zoom-btn" id="browser-zoom-btn" hidden aria-label="Zoom"></button>
@ -113,16 +116,16 @@
</div>
</aside>
<script src="/js/websocket-client.js?v=3"></script>
<script src="/js/terminal-view.js?v=7"></script>
<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/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=10"></script>
<script src="/js/claude-renderer.js?v=10"></script>
<script src="/js/claude-keyboard.js?v=4"></script>
<script src="/js/app.js?v=9"></script>
<script src="/js/claude-parser.js?v=11"></script>
<script src="/js/claude-renderer.js?v=12"></script>
<script src="/js/claude-keyboard.js?v=5"></script>
<script src="/js/app.js?v=11"></script>
<script src="/js/debug-safe-area.js"></script>
<script src="/js/sw-unregister.js"></script>
</body>

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

@ -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;
btn.appendChild(iconEl);
if (label) {
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();
@ -37,7 +162,6 @@ class ClaudeKeyboard {
return btn;
}
}
window.ClaudeKeyboard = ClaudeKeyboard;

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

@ -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 = '&#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);
}
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}`;
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', statusBar.mode);
if (this._onAction) this._onAction('mode-toggle', mode);
});
this.statusBarEl.appendChild(pill);
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;

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

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

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

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

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

@ -18,9 +18,25 @@ export class AuthManager {
private jwtSecret: string;
private attempts = new Map<string, AttemptRecord>();
private authDisabled = false;
private cleanupInterval: ReturnType<typeof setInterval>;
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 {

@ -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<typeof setInterval>;
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<void> {
// 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();
}
}

Loading…
Cancel
Save