# Design: Dictation Focus Management Redesign

**Date**: 2026-04-18
**Status**: Approved (user approved the direction)
**Problem**: Dictation text goes to the wrong target. On Sessions tab, clicking mic auto-opens Queue Builder and types into `qp-inline-input` instead of the terminal. No way to dictate directly into terminal sessions.

## Core Principle: Dictation Follows Focus

Text always goes where the user's cursor/attention is. This matches macOS dictation (inserts at insertion point), VS Code Speech (adapts to focus context), and user expectations.

## Behavior Matrix

| Tab | Focus on... | Mic click / Cmd+Shift+R | Target |
|-----|------------|------------------------|--------|
| Sessions | Terminal (xterm) | Dictate into terminal | PTY input via WebSocket |
| Sessions | Queue input (`qp-inline-input`) | Dictate into queue input | textarea `.value` |
| Sessions | Wall-E session input (`.walle-input`) | Dictate into that input | textarea `.value` |
| Sessions | Nothing / non-text | Dictate into active terminal | PTY input (fallback) |
| Prompts | Prompt editor (`pe-editor`) | Dictate into editor | contentEditable `.innerText` |
| Prompts | Template textarea (`pe-tpl-content`) | Dictate into template | textarea `.value` |
| Prompts | Nothing / no prompt open | Toast: "Open a prompt first" | (none) |
| Wall-E | Chat input (`walle-chat-input`) | Dictate into chat | textarea `.value` |
| Wall-E | Any visible text field | Dictate into that field | `.value` or `.innerText` |
| Any | Focus on excluded element | Use tab default from above | (varies) |

## Design 1: Terminal Dictation (New Capability)

### Architecture

```
Click mic (terminal focused)
  -> getUserMedia(MacBook Pro mic)
  -> MediaRecorder (2s chunks -> server)
  -> SpeechRecognition (parallel, best-effort)
     -> FINAL text -> window._ctmSendToTerminal(text)  [PTY input]
     -> INTERIM text -> floating overlay (preview only)
  -> Recording saved server-side (audio + transcript)
```

**Critical**: Only FINAL SR results are sent to the terminal. Interim/partial text is shown in a floating preview overlay. This prevents garbled half-words from corrupting terminal input.

### Bridge Pattern (listening.js <-> index.html)

`listening.js` is an IIFE with no access to `state` or `send()` in `index.html`. We bridge via `window` functions:

```javascript
// index.html - expose these globals
window._ctmGetActiveTerminal = function() {
  // Returns { id, label } if an xterm session is active, null otherwise
  if (state.activeTab && state.sessions.has(state.activeTab)) {
    var s = state.sessions.get(state.activeTab);
    return { id: state.activeTab, label: s?.meta?.label || state.activeTab };
  }
  return null;
};

window._ctmSendToTerminal = function(text) {
  // Sends text as PTY input to the active terminal session
  // Same mechanism used by Queue Builder (index.html:14513)
  var info = window._ctmGetActiveTerminal();
  if (info) {
    send({ type: 'input', id: info.id, data: text });
    return true;
  }
  return false;
};
```

### Virtual Terminal Target

`_findDictationTarget()` returns a lightweight proxy object when the active context is a terminal:

```javascript
// Created once, reused
var _terminalProxy = {
  isTerminalProxy: true,
  tagName: 'TERMINAL',
  id: 'li-terminal-proxy',
  classList: {
    add: function() {},
    remove: function() {},
    contains: function() { return false; }
  },
  offsetParent: true, // always "visible"
  readOnly: false,
  isContentEditable: false
};
```

### Text Insertion for Terminal

The `_getTargetText` / `_setTargetText` / `_scrollTargetToEnd` helpers gain terminal support:

```javascript
function _getTargetText(el) {
  if (!el) return '';
  if (el.isTerminalProxy) return ''; // Terminal has no readable "value"
  if (el.isContentEditable) return el.innerText || '';
  return el.value || '';
}

function _setTargetText(el, text) {
  if (!el) return;
  if (el.isTerminalProxy) return; // Terminal text set via _sendFinalToTerminal
  if (el.isContentEditable) { el.innerText = text; }
  else { el.value = text; }
  el.dispatchEvent(new Event('input', { bubbles: true }));
}
```

For terminal, the `onresult` handler has a special path:

```javascript
// Inside recognition.onresult:
if (final && target && target.isTerminalProxy) {
  // Send final text directly to terminal PTY
  if (window._ctmSendToTerminal) {
    window._ctmSendToTerminal(final);
  }
  // Update interim state
  _state.interimText = '';
  _state._committedLen = 0; // Terminal has no "committed" concept
  // Update overlay preview
  _updateTerminalPreview('');
  return; // skip normal textarea path
}
```

### Visual Feedback: Terminal Mode

Terminal indicator is a **fixed overlay inside the terminal container**, not position-absolute on a parent:

```
+--[ resume ]----[ Copy Command ]---[ Split ]---[ Reflow ]---+
|  [*] Listening * MacBook Pro  0:05     "implement the..."  |  <-- indicator bar
+------------------------------------------------------------+
|  $ some previous command output                            |
|  $ implement the following plan|                           |  <-- final text in terminal
|                                                            |
+------------------------------------------------------------+
```

The indicator is a full-width bar inserted at the top of the terminal container (`.term-container`), showing:
- Recording dot + "Listening" label + mic name + timer (left-aligned)
- Interim text preview (right-aligned, truncated)

CSS class: `.li-terminal-indicator` (new)

```css
.li-terminal-indicator {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 12px;
  font-size: 11px;
  color: #f7768e;
  background: rgba(247, 118, 142, 0.06);
  border-bottom: 1px solid rgba(247, 118, 142, 0.15);
  animation: li-pill-fadein 0.2s ease;
  z-index: 5;
}

.li-terminal-indicator .li-interim-text {
  margin-left: auto;
  color: #565f89;
  font-style: italic;
  max-width: 50%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
```

## Design 2: Stop Recording on Tab Switch

### Behavior

When the user navigates away from the current tab while dictation is active, recording stops automatically. A toast confirms: "Dictation stopped (switched tabs)".

### Implementation

Hook into `navTo()` function in `index.html`:

```javascript
// index.html — inside navTo(), near the top
if (typeof LI !== 'undefined' && LI.isRecording()) {
  LI.stopRecording();
  // Show brief toast (LI handles this internally via _showToast)
}
```

This also handles:
- Clicking sidebar session items (which call `activateTab()`)
- Keyboard shortcuts (Alt+Tab nav cycling)
- Hash routing changes

### Edge Case: Switching Between Sessions on Same Tab

Clicking a different session in the sidebar while dictating into a terminal should also stop dictation, because the terminal target changes. Hook into `activateTab()`:

```javascript
// index.html — inside activateTab(), near the top
if (typeof LI !== 'undefined' && LI.isRecording()) {
  LI.stopRecording();
}
```

## Design 3: Revised _findDictationTarget()

### Algorithm (priority order)

```
1. Active element check:
   - Is it a textarea/input? (not xterm-helper-textarea) -> use it
   - Is it contentEditable? -> use it
   - Is it xterm-helper-textarea? -> terminal mode (see step 4)

2. Pre-mic target (captured before click stole focus):
   - Same checks as step 1

3. Named candidates (visible only):
   a. walle-chat-input (Wall-E chat)
   b. pe-editor (Prompts editor, contentEditable)
   c. pe-tpl-content (Prompts template textarea)
   d. .walle-input (Wall-E session inputs)

4. Terminal fallback (Sessions tab):
   - If hash contains 'session' OR activeElement is xterm-helper-textarea
   - AND window._ctmGetActiveTerminal() returns non-null
   - -> return _terminalProxy

5. No target found:
   - Return null (shows toast: "Focus an input field first")
```

### What We Remove

- **Remove**: Auto-opening Queue Builder panel. This was the root cause of the bug. The Queue panel should only open when the user clicks "Queue".

## Design 4: Consistent Keyboard Shortcut (Cmd+Shift+R)

Currently `Cmd+Shift+R` calls `LI.toggleRecording()` which calls `LI.startRecording('dictation')` which calls `_findDictationTarget()`. With the revised target algorithm, this automatically works correctly on every tab:
- Sessions tab -> terminal dictation
- Prompts tab -> pe-editor or pe-tpl-content
- Wall-E tab -> walle-chat-input

No changes needed to the shortcut handler itself.

## Design 5: Focus Lock During Recording

Once dictation starts, the target is "locked in". Clicking elsewhere (e.g., scrolling terminal output, clicking sidebar) does NOT change the dictation target. Text keeps going to the original target.

This matches VS Code's behavior where dictation continues in the editor even when you click elsewhere.

Exception: Tab/session switches stop recording entirely (Design 2).

## Files to Modify

### `claude-task-manager/public/js/listening.js`

| Section | Change |
|---------|--------|
| `_findDictationTarget()` | Rewrite: remove Queue auto-open, add terminal proxy detection |
| `_terminalProxy` | New: lightweight proxy object for terminal targets |
| `_getTargetText()` | Add terminal proxy case (returns '') |
| `_setTargetText()` | Add terminal proxy case (no-op) |
| `_startDictationSR()` / `onresult` | Add terminal path: final text -> `_ctmSendToTerminal()`, interim -> overlay |
| `_showDictationFeedback()` | Add terminal case: insert bar at top of `.term-container` |
| `_hideDictationFeedback()` | Add terminal case: remove `.li-terminal-indicator` |
| `_updateInterimPreview()` | Add terminal case: update `.li-interim-text` inside indicator bar |
| `_stopDictation()` | Terminal cleanup (no `.value` commit, just stop) |

### `claude-task-manager/public/index.html`

| Section | Change |
|---------|--------|
| After `send()` function (~line 3975) | Add `window._ctmGetActiveTerminal` and `window._ctmSendToTerminal` bridge functions |
| `navTo()` (~line 5099) | Add `LI.stopRecording()` call at top |
| `activateTab()` | Add `LI.stopRecording()` call at top |
| Cache-bust | Bump `listening.js?v=4` and `listening.css?v=4` |

### `claude-task-manager/public/css/listening.css`

| Section | Change |
|---------|--------|
| New section | `.li-terminal-indicator` styles |
| New section | `.li-terminal-indicator .li-interim-text` styles |

### No Backend Changes

The existing endpoints handle everything. Terminal dictation still creates a server-side recording (audio + transcript) via the same `/listening/start`, `/listening/upload`, `/listening/interim`, `/listening/stop` flow.

## Testing Plan

### 1. Playwright (staging server)

```
Test: Sessions tab - terminal dictation
  1. Navigate to Sessions tab with active xterm session
  2. Click mic -> verify: no Queue panel opens, terminal indicator appears
  3. Simulate SR final result -> verify: text sent to terminal via bridge
  4. Stop recording -> verify: indicator removed, recording finalized

Test: Sessions tab - Queue input (when panel already open)
  1. Open Queue panel manually
  2. Focus qp-inline-input
  3. Click mic -> verify: dictation goes to qp-inline-input (not terminal)

Test: Prompts tab - editor dictation
  1. Navigate to Prompts tab, open a prompt
  2. Click mic -> verify: pe-editor found, indicator appears above editor
  3. Simulate SR final -> verify: text appears in pe-editor

Test: Wall-E tab - regression
  1. Navigate to Wall-E chat
  2. Click mic -> verify: walle-chat-input is target, text insertion works

Test: Tab switch stops recording
  1. Start dictation on Sessions tab
  2. Click "Prompts" nav -> verify: recording stops, toast shown
  3. Start dictation on Wall-E tab
  4. Click a session in sidebar -> verify: recording stops

Test: Cmd+Shift+R shortcut
  1. On each tab, press Cmd+Shift+R -> verify: correct target selected
  2. Press again -> verify: recording stops
```

### 2. Unit Tests

Existing 1415 tests must still pass. No new unit tests needed (dictation target logic is browser-only, tested via Playwright).

### 3. Manual Testing

User tests on staging at `http://localhost:$DEV_CTM_PORT`:
- [ ] Sessions: click mic while terminal focused -> text goes to terminal
- [ ] Sessions: interim text visible in overlay, not in terminal
- [ ] Sessions: click mic while Queue input focused -> text goes to Queue
- [ ] Prompts: click mic while editing prompt -> text goes to editor
- [ ] Wall-E: click mic -> text goes to chat (regression)
- [ ] Tab switch -> recording stops
- [ ] Cmd+Shift+R works on all tabs

## Implementation Order

1. Add bridge functions to `index.html` (`_ctmGetActiveTerminal`, `_ctmSendToTerminal`)
2. Add stop-on-tab-switch hooks to `navTo()` and `activateTab()`
3. Create `_terminalProxy` object in `listening.js`
4. Rewrite `_findDictationTarget()` — remove Queue auto-open, add terminal detection
5. Update `_getTargetText` / `_setTargetText` for terminal proxy
6. Update `onresult` handler with terminal-specific path
7. Update `_showDictationFeedback` / `_hideDictationFeedback` for terminal
8. Update `_updateInterimPreview` for terminal
9. Update `_stopDictation` for terminal cleanup
10. Add `.li-terminal-indicator` CSS
11. Bump cache-bust versions
12. Test on staging with Playwright
13. Run unit tests
