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