zeph 0.22.4

Lightweight AI agent with hybrid inference, skills-first architecture, and multi-channel I/O
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
---
aliases:
  - TUI Reducer
  - Action Decomposition
  - Mouse Mode
  - TUI Reducer Spec
tags:
  - sdd
  - spec
  - tui
  - config
  - contract
created: 2026-06-13
status: approved
related:
  - "[[MOC-specs]]"
  - "[[001-system-invariants/spec]]"
  - "[[011-tui/spec]]"
  - "[[026-tui-subagent-management/spec]]"
  - "[[030-tui-slash-autocomplete/spec]]"
---

# Spec: TUI Reducer / Action Decomposition + Opt-in Mouse Mode

> [!info]
> Defines the `Action` + `Effect` + `reduce` architecture for `zeph-tui` (#5076) and the
> opt-in mouse capture mode (#5103). This document is the prerequisite spec referenced by
> the TODO at `crates/zeph-tui/src/app/mod.rs:4-6`.

## Sources

### External
- [crossterm 0.29 mouse API]https://docs.rs/crossterm/0.29.0/crossterm/event/struct.MouseEvent.html
- [ratatui `Rect` geometry]https://docs.rs/ratatui/latest/ratatui/layout/struct.Rect.html

### Internal

| File | Contents |
|---|---|
| `crates/zeph-tui/src/app/mod.rs` | `App` struct (globals + session registry access) |
| `crates/zeph-tui/src/app/keys.rs` | Keyboard handler tree — the primary migration target |
| `crates/zeph-tui/src/app/events.rs` | Agent-event handlers (out of scope for this PR) |
| `crates/zeph-tui/src/event.rs` | `AppEvent` enum + `CrosstermEventSource::next_event` |
| `crates/zeph-tui/src/lib.rs` | `tui_loop`, `init_terminal`, `restore_terminal` |
| `crates/zeph-tui/src/app/draw.rs` | `draw()` — computes and (currently) discards `AppLayout` |
| `crates/zeph-tui/src/layout.rs` | `AppLayout` (ten `Rect`s, derives neither Clone nor Copy today) |
| `crates/zeph-tui/src/hyperlink.rs` | `HyperlinkSpan { url, row, start_col, end_col }` |
| `crates/zeph-tui/src/session.rs` | `SessionSlot` — per-session state |
| `crates/zeph-tui/src/command.rs` | `TuiCommand` enum + `build_app_commands()` |
| `crates/zeph-config/src/ui.rs` | `TuiConfig` — config entry point |
| `src/tui_bridge.rs` | Builder chain wiring (`with_motion`, `with_delights`, etc.) |

---

## 1. Overview

### Problem Statement

State mutation in the TUI is scattered across ~30 handler functions in `app/keys.rs`
(1749 lines) and `app/events.rs` (799 lines). Each handler mixes three concerns:
(1) decoding input, (2) mutating app state, and (3) triggering side-effects. This makes
the codebase untestable at the unit level and blocks the addition of mouse input — because
mouse events need to share the exact same state-mutation logic as keyboard events.

### Goal

- A single `reduce(&mut App, Action) -> Vec<Effect>` function is the **only** site that
  mutates keyboard/mouse-derived state.
- Handlers become pure decoders: `key/mouse event → Option<Action>`.
- Mouse events (wheel, click) produce `Action`s and flow through `reduce`, making
  keyboard and mouse paths provably consistent.
- `[tui] mouse: bool` (default `false`) enables an opt-in mouse capture mode with
  runtime toggling via `/mouse [on|off]`.

### Out of Scope

- Rewriting `app/events.rs` agent-event handlers — they are already a self-contained reducer
  over a disjoint state path. Migrating them risks a 2500-line big-bang refactor.
- Per-tool-group expand state (today `tool_expanded` is a single global bool).
- Phase-2 multi-session tab-bar mouse clicks (only one session slot exists today).
- Inventing a browser-spawn / URL-open effect for OSC8 mouse clicks — security and
  cross-platform surface; rely on terminal-native OSC8 handling.

---

## 2. Functional Requirements

| ID | Requirement | Priority |
|----|-------------|----------|
| FR-001 | WHEN a key or mouse event is handled THE SYSTEM SHALL route it through `reduce` as the sole state-mutation site | must |
| FR-002 | WHEN `reduce` is called THE SYSTEM SHALL perform no I/O (no channel sends, no clipboard, no spawn, no terminal escapes) | must |
| FR-003 | WHEN `reduce` returns effects containing a `oneshot::Sender` THE SYSTEM SHALL transfer it out of modal state inside the reducer so the sender is never left dangling | must |
| FR-004 | WHEN `[tui] mouse = false` (default) THE SYSTEM SHALL behave byte-for-byte identically to pre-PR (alternate-scroll enabled, no mouse capture, native selection works) | must |
| FR-005 | WHEN `[tui] mouse = true` or `/mouse on` is issued THE SYSTEM SHALL emit `DisableAlternateScroll` then `EnableMouseCapture` as a single atomic terminal write | must |
| FR-006 | WHEN `mouse = true`, wheel events in the chat region THE SYSTEM SHALL translate to `Action::ScrollLines(±3)` | must |
| FR-007 | WHEN `mouse = true`, a left-click in any panel THE SYSTEM SHALL focus that panel | must |
| FR-008 | WHEN `mouse = true`, a left-click on a tool-group header row THE SYSTEM SHALL toggle tool expansion | must |
| FR-009 | WHEN `restore_terminal` is called THE SYSTEM SHALL always emit `DisableMouseCapture` regardless of current state | must |
| FR-010 | WHEN `decode_mouse` is called with `last_layout = None` THE SYSTEM SHALL return `None` without panicking | must |
| FR-011 | WHEN `MouseEventKind::Moved` or `Drag(_)` arrives THE SYSTEM SHALL NOT forward it to the dirty-mark path (see §3.5, C6) | must |
| FR-012 | WHEN `/mouse on\|off` or the palette "Toggle mouse mode" entry is activated THE SYSTEM SHALL update `mouse_enabled` and display the status hint | must |
| FR-013 | WHEN `[tui] mouse = true` in config THE SYSTEM SHALL enable capture only AFTER the first successful `terminal.draw` | should |
| FR-014 | WHEN writing OSC8 sequences THE SYSTEM SHALL read hyperlink spans from a non-draining accessor (borrow, not drain) so spans persist until the next draw replaces them | must |
| FR-015 | THE SYSTEM SHALL expose `ElicitationEdit` and `PaletteEdit` variants that cover all sub-mutations (PushChar, PopChar, NextField, PrevField, ToggleBool, EnumNext, EnumPrev) or explicitly delegate to an opaque sub-reducer | must |
| FR-016 | THE SYSTEM SHALL provide a panic hook (or equivalent teardown guard) that calls `restore_terminal` so a crash mid-session cannot leave the terminal in mouse-capture mode | must |

---

## 3. Architecture

### 3.1 Reducer boundary

```
                ┌──────────────────────────────────────────────┐
crossterm event │  HANDLER (decode only — keys.rs / mouse.rs)  │
──────────────► │  "Ctrl+L in Main view"  →  Action::ClearTranscript
                └──────────────────────┬───────────────────────┘
                                       │ Action
                ┌──────────────────────────────────────────────┐
                │  reduce(&mut App, Action) -> Vec<Effect>      │  PURE w.r.t. outside world:
                │  — sole key/mouse state-mutation site         │  no channel sends,
                │  — returns Effects it cannot run itself       │  no clipboard, no spawn
                └──────────────────────┬───────────────────────┘
                                       │ Vec<Effect>
                ┌──────────────────────────────────────────────┐
                │  run_effects(&mut App, Vec<Effect>)           │  performs side-effects
                │  — user_input_tx.try_send(...)               │
                │  — command_tx.try_send(...)                   │
                │  — clipboard.copy(...)                        │
                │  — parks SetMouseCapture in pending field     │
                └──────────────────────────────────────────────┘
                                       │ pending_mouse_capture
                ┌──────────────────────────────────────────────┐
                │  tui_loop post-select block                   │
                │  — drains app.take_mouse_capture_request()   │
                │  — runs crossterm::execute! on backend        │
                └──────────────────────────────────────────────┘
```

`reduce` is `pub(crate) fn reduce(app: &mut App, action: Action) -> Vec<Effect>` in a
new module `app/reducer.rs`. It takes `&mut App` (not a narrower `AppState`) because
state is split across `App` globals + `SessionRegistry` + widget sub-states; relocating
~40 fields is out of scope. Purity = "no I/O", enforced by convention + tests + an
optional grep-based CI lint (see §5).

### 3.2 Effect enum

```rust
/// A side-effect the reducer requests but does not perform.
#[derive(Debug)]
pub(crate) enum Effect {
    /// Forward user input to the agent via `user_input_tx`.
    SendUserInput(String),
    /// Dispatch a structured TUI command to the agent via `command_tx`.
    SendCommand(TuiCommand),
    /// Copy text to the OS clipboard.
    CopyToClipboard(String),
    /// Answer a pending confirm dialog (consumes the one-shot sender from modal state).
    ResolveConfirm(bool),
    /// Answer a pending elicitation dialog (consumes the one-shot sender from modal state).
    ResolveElicitation(zeph_core::channel::ElicitationResponse),
    /// Kick off the background file-index build for the `@` file picker.
    StartFileIndex,
    /// Enable or disable crossterm mouse capture at runtime (#5103).
    /// run_effects parks this in `pending_mouse_capture`; tui_loop drains it.
    SetMouseCapture(bool),
    /// Quit the application.
    Quit,
}
```

Effects carrying `oneshot::Sender`s (`ResolveConfirm`, `ResolveElicitation`) take the
sender out of modal state *inside the reducer* (INV-R3). The reducer leaves
`confirm_state = None`; `run_effects` calls `tx.send(answer)`.

### 3.3 Action enum

`Action` lives in `app/action.rs`. Flat and semantic — variants describe intent,
not mechanism. Supporting enums (`CursorMove`, `ScrollDir`, `VertDir`, `PaletteEdit`,
`ElicitationEdit`) live alongside it.

```rust
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Action {
    // ---- Input composer ----
    InsertChar(char),
    InsertNewline,
    InsertText(String),          // bracketed paste
    DeleteCharBackward,
    DeleteCharForward,
    DeleteWordBackward,
    MoveCursor(CursorMove),
    ClearInput,
    SubmitInput,
    SetInputMode(InputMode),

    // ---- Input history ----
    HistoryPrev,
    HistoryNext,

    // ---- Transcript scroll ----
    ScrollLines(i32),            // positive = up
    ScrollPage(ScrollDir),
    ScrollToTop,
    ScrollToBottom,
    ClearTranscript,

    // ---- Layout / panel toggles ----
    ToggleSidePanels,
    SetActivePanel(Panel),
    CyclePanelForward,
    ToggleTaskPanel,
    TogglePanelCollapse(usize),
    ToggleHelp,
    SetHelp(bool),
    TogglePlanView,
    ToggleToolExpanded,
    CycleToolDensity,

    // ---- View target ----
    SetViewTarget(AgentViewTarget),
    SubAgentSelectNext,
    SubAgentSelectPrev,
    OpenSelectedSubAgent,

    // ---- Theme / motion / mouse runtime toggles ----
    CycleTheme,
    SetTheme(String),
    SetMotion(zeph_config::Motion),
    SetMouse(bool),

    // ---- Modal lifecycle ----
    OpenCommandPalette,
    CloseCommandPalette,
    PaletteMove(VertDir),
    PaletteInput(PaletteEdit),
    PaletteAccept,

    OpenFilePicker,
    CloseFilePicker,
    FilePickerMove(VertDir),
    FilePickerInput(PaletteEdit),
    FilePickerAccept,

    // Note: The FilePicker* action set above is superseded by spec [[084-tui-mention-picker/spec]],
    // which transforms `@` from a modal file picker into an inline non-modal popup supporting
    // files/skills/agents. The FilePicker* variants (and Effect::StartFileIndex's modal-open path)
    // are REMOVED when 084 is implemented, replaced by the MentionPicker* variants below.
    // The reducer invariants (INV-R1/INV-R2) apply unchanged.

    OpenMentionPicker,
    CloseMentionPicker,
    MentionPickerMove(VertDir),
    MentionPickerTabChange(Direction),
    MentionPickerInput(PaletteEdit),
    MentionPickerAccept,

    SlashAutocompleteMove(VertDir),
    SlashAutocompleteInput(PaletteEdit),
    SlashAutocompleteAccept,
    CloseSlashAutocomplete,

    OpenReverseSearch,
    ReverseSearchInput(PaletteEdit),
    ReverseSearchNext,
    ReverseSearchPrev,
    ReverseSearchAccept,
    CloseReverseSearch,

    ConfirmRespond(bool),
    ElicitationField(ElicitationEdit),
    ElicitationSubmit,
    ElicitationCancel,

    // ---- Clipboard ----
    CopyLastAssistant,
    CopyLastCodeBlock(usize),

    // ---- Lifecycle ----
    Quit,

    // ---- Escape hatch for agent pass-through commands ----
    Dispatch(TuiCommand),
}
```

`PaletteEdit` and `ElicitationEdit` must cover all sub-mutations (C4):

```rust
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum PaletteEdit {
    PushChar(char),
    PopChar,
}

#[derive(Debug, Clone, PartialEq)]
pub(crate) enum ElicitationEdit {
    PushChar(char),
    PopChar,
    NextField,
    PrevField,
    ToggleBool,
    EnumNext,
    EnumPrev,
}
```

> **Rationale for `&mut App`:** state contains non-`Clone` handles (channels,
> `oneshot::Sender`s in modal state). A value-returning reducer would clone the transcript
> per keystroke. In-place mutation is the pragmatic Rust idiom (cf. `iced::update`).
> Purity is scoped to "no I/O", not "no mutation".

> **Minimality:** No `Action` per agent event — agent events stay in `events.rs`. No
> `Action::Tick` or `Action::Resize` — those are animation/cache concerns handled inline.

### 3.4 Handler → Action flow

Handlers shrink to translation tables. Example:

```rust
// BEFORE (keys.rs handle_normal_key)
KeyCode::Char('e') => { self.tool_expanded = !self.tool_expanded; self.render_cache_clear(); }
KeyCode::Up | KeyCode::Char('k') => { self.scroll_up_one(); }

// AFTER
KeyCode::Char('e') => Some(Action::ToggleToolExpanded),
KeyCode::Up | KeyCode::Char('k') => Some(Action::ScrollLines(1)),
```

`handle_key` becomes:

```rust
pub fn handle_key(&mut self, key: KeyEvent) {
    if let Some(action) = self.decode_key(key) {
        let effects = reducer::reduce(self, action);
        self.run_effects(effects);
    }
}
```

### 3.5 Mouse mode (#5103)

#### Native-scroll tension (critical design constraint)

`init_terminal` (`lib.rs:251`) enables **alternate-scroll mode** (`\x1b[?1007h`,
`EnableAlternateScroll`). This converts wheel events to arrow keys and preserves native
text selection (documented `lib.rs:219-223`). `EnableMouseCapture` is mutually exclusive
with this — enabling it steals drag events, breaking native selection.

**Resolution:**
- `mouse = false` (default): alternate-scroll on, no `MouseEvent`s, native selection works.
- `mouse = true`: emit `DisableAlternateScroll` + `EnableMouseCapture` together; wheel
  arrives as `MouseEventKind::ScrollUp/Down`; selection requires Shift+drag.

#### Event plumbing

Add `Mouse(crossterm::event::MouseEvent)` to `AppEvent`:

```rust
// event.rs CrosstermEventSource::next_event
Ok(CrosstermEvent::Mouse(m)) => Some(AppEvent::Mouse(m)),
```

`handle_event` gains:

```rust
AppEvent::Mouse(m) => self.handle_mouse(m),
```

`handle_mouse` lives in new `app/mouse.rs` and is a pure decoder:

```rust
pub fn handle_mouse(&mut self, m: MouseEvent) {
    if !self.mouse_enabled { return; }
    if let Some(action) = self.decode_mouse(m) {
        let effects = reducer::reduce(self, action);
        self.run_effects(effects);
    }
}
```

**C6 — Moved/Drag flood filter:** `decode_mouse` returns `None` for
`MouseEventKind::Moved` and `Drag(_)`. The event source (`event.rs`) should additionally
map these to `Tick` (not `AppEvent::Mouse`) so they never set `dirty = Full` in the main
loop — preventing a full-redraw storm on mouse hover when capture is on.

#### Hit-testing model

`App` gains:

```rust
/// Layout Rects from the last `draw()`, retained for mouse hit-testing.
/// `None` until the first frame is drawn.
pub(crate) last_layout: Option<AppLayout>,
```

`AppLayout` (`layout.rs:70`) must derive `Clone, Copy` (it is ten `Rect`s, ~80 bytes).

`decode_mouse` guards `None` immediately (FR-010) and returns `None` without panicking.

Hit-test table:

| Region | `MouseEventKind` | `Action` |
|--------|-----------------|---------|
| `chat` | `ScrollDown` | `ScrollLines(-3)` |
| `chat` | `ScrollUp` | `ScrollLines(3)` |
| `chat` | `Down(Left)` on hyperlink | hit-test scan → `Dispatch(open-url)` / OSC8 fallback |
| `chat` | `Down(Left)` on tool-group header | `ToggleToolExpanded` |
| `chat` | `Down(Left)` elsewhere | `SetActivePanel(Panel::Chat)` |
| `skills` | `Down(Left)` | `SetActivePanel(Panel::Skills)` |
| `memory` | `Down(Left)` | `SetActivePanel(Panel::Memory)` |
| `resources` | `Down(Left)` | `SetActivePanel(Panel::Resources)` |
| `subagents` | `Down(Left)` | `SetActivePanel(Panel::SubAgents)` + select row |
| `subagents` | `ScrollUp/Down` | `SubAgentSelectPrev/Next` |
| `input` | `Down(Left)` | `SetInputMode(Insert)` |
| `status` | `Down(Left)` | ignored in v1 |
| any panel | `ScrollUp/Down` | scroll that panel's list (fallback to `ScrollLines` on chat) |
| any | `Moved` / `Drag(_)` | `None` — filtered at source (see C6) |

`rect_contains(r: Rect, col: u16, row: u16) -> bool` is a small helper in `app/mouse.rs`.

#### OSC8 hyperlink activation (C1 — corrected semantics)

> [!warning] C1 Correction
> The OSC8 spans are NOT simply "drained every frame." `take_hyperlinks()` is only called
> inside the `if should_draw { … }` block in `tui_loop` (`lib.rs:204-210`), so on idle
> (non-redraw) frames the spans already persist. On redraw frames, `set_hyperlinks()` is
> called from `widgets/chat.rs:97` **during draw**, which replaces the whole vec — so
> spans are always "as of the last drawn frame," exactly what hit-testing wants.

The correct fix: change `take_hyperlinks()` to a borrowing accessor for the OSC8 write.
Keep writing OSC8 from a borrow; let `set_hyperlinks()` at draw time replace the vec.
The developer MUST verify the OSC8 escape is still emitted on every redraw after
switching `take_` → borrow (regression risk: links stop being natively clickable).

#### `SetMouseCapture` effect path (C2 — drain placement)

`run_effects` handles all effects except `SetMouseCapture`, which it parks in
`pending_mouse_capture: Option<bool>` on `App`. `tui_loop`'s post-select block
(the shared block after the `tokio::select!` that already calls `app.poll_*`) drains
it via `app.take_mouse_capture_request() -> Option<bool>`.

This single drain site covers both runtime toggle (slash command on any select arm) and
startup enable — they converge on the same path. Do **not** place the drain inside
the event arm only.

#### Startup capture ordering (C3 — after first draw)

If `config.tui.mouse = true`, enable capture **after the first successful
`terminal.draw`**, not before the loop. This guarantees `last_layout` is populated
before any mouse event can arrive. Document in `decode_mouse` that `None`-guard is
correct behavior (first-scroll-after-boot is silently dropped), and cover it with a
unit test.

#### Panic hook / teardown guard (C7 — INV-M4 precondition)

INV-M4 ("a crash never leaves the terminal in capture mode") is only satisfied if
`restore_terminal` runs on panic. Before shipping: confirm an existing panic hook or
`Drop` guard calls `restore_terminal`, or add one. Check `lib.rs`/bootstrap.
`DisableMouseCapture` when not enabled is idempotent (terminal ignores `?1000l`), so
always emitting it in `restore_terminal` is safe.

### 3.6 Config and command surface

**Config field** (`crates/zeph-config/src/ui.rs`, `TuiConfig` ~line 505):

```rust
/// Enable opt-in mouse mode (wheel scroll, click-to-focus, OSC8 click fallback).
/// Default `false`. When `true`, text selection requires Shift+drag.
#[serde(default)]
pub mouse: bool,
```

**App fields** (`app/mod.rs`):

```rust
pub(crate) mouse_enabled: bool,      // default false
pub(crate) last_layout: Option<AppLayout>,  // default None
pub(crate) pending_mouse_capture: Option<bool>, // default None
```

**Builder** (`app/state.rs`):

```rust
#[must_use]
pub fn with_mouse(mut self, enabled: bool) -> Self {
    self.mouse_enabled = enabled;
    self
}
```

**`/mouse` command** (`command.rs`, `keys.rs`):

```rust
// TuiCommand additions:
SetMouse(bool),    // explicit on/off from slash parse
ToggleMouse,       // palette entry (reads current state at accept time)
```

Slash parse (`parse_session_slash` ~line 606):

```rust
[cmd] if cmd.eq_ignore_ascii_case("/mouse") => Some(TuiCommand::ToggleMouse),
[cmd, s] if cmd.eq_ignore_ascii_case("/mouse")
    && matches!(s.to_ascii_lowercase().as_str(), "on" | "off") =>
    Some(TuiCommand::SetMouse(s.eq_ignore_ascii_case("on"))),
```

**Status hint** (`widgets/status.rs`): when `mouse_enabled`, display
`mouse on — text selection via Shift+drag` (truncate on narrow widths).

**Wizard + migration** (mandatory per CLAUDE.md):
- `--init`: yes/no prompt for `[tui] mouse` in the TUI section (default No).
- `--migrate-config`: insert `mouse = false` under `[tui]` for pre-existing configs
  (step number: next after the `tui.theme` step 66, i.e. step 67).

---

## 4. Implementation Plan

Eight steps, each compiling with tests green before the next begins.

**Step 1 — Action/Effect skeleton.**
Add `app/action.rs` and `app/reducer.rs`. Wire a first slice: `ScrollLines`,
`ScrollPage`, `ScrollToTop/Bottom`, `ToggleToolExpanded`, `CycleToolDensity`,
`ToggleSidePanels`, `ToggleHelp`. Convert matching `handle_normal_key` leaves. Unit-test
each Action in the reducer.

**Step 2 — Normal-mode + command dispatch.**
Convert remaining `handle_normal_key` and `execute_command`. Pass-through agent commands
become `Action::Dispatch(cmd)` → `Effect::SendUserInput/SendCommand`. Clipboard →
`Effect::CopyToClipboard`.

**Step 3 — Insert-mode + modals (largest step, split per modal).**
Convert `handle_insert_*`, palette, file picker, slash autocomplete, reverse search,
confirm, elicitation. C4 compliance: `ElicitationEdit` must enumerate PushChar, PopChar,
NextField, PrevField, ToggleBool, EnumNext, EnumPrev before declaring this step done.
C5 compliance: conversion is all-or-nothing per leaf — a converted sub-handler performs
no mutation by any path (direct or via helper), verified by reading (not by grepping
`self.\w+ =`). One-shot senders move into `Effect::ResolveConfirm/ResolveElicitation`.

**Step 4 — Cache layout + OSC8 accessor fix (C1).**
Derive `Clone, Copy` on `AppLayout`. Store `last_layout` at end of `draw`. Switch
`take_hyperlinks` to a borrowing accessor; verify OSC8 sequences still emit on redraw.

**Step 5 — Mouse plumbing (C6).**
Add `AppEvent::Mouse`, extend `CrosstermEventSource`. Add `app/mouse.rs` with
`decode_mouse` + `rect_contains`. Route wheel/click → Action. Filter `Moved`/`Drag` at
source (return `Tick`). Unit-test `decode_mouse` with synthetic events against a fixed
`AppLayout`.

**Step 6 — Config + runtime toggle (C2, C3, C7).**
Add `TuiConfig.mouse`, `with_mouse`, tui_bridge wiring. Implement
`pending_mouse_capture` + `take_mouse_capture_request()`. Drain in post-select block.
Enable capture only after first draw (C3). Always disable in `restore_terminal`.
Confirm or add panic hook for INV-M4 (C7).

**Step 7 — Command + UX.**
`TuiCommand::SetMouse/ToggleMouse`, palette entry, slash parse, status hint, wizard
prompt, migration step 67.

**Step 8 — Docs/tests/playbook.**
mdBook `[tui] mouse` doc. `.local/testing/playbooks/tui-reducer-mouse.md`.
`coverage-status.md` rows for `TUI Reducer` and `TUI Mouse Mode` → `Untested`.

---

## 5. Key Invariants

### Always

- **INV-R1 (single mutation site):** After migration, no key/mouse handler mutates `App`
  or `SessionSlot` directly. All such mutation flows through `reduce`. Conversion is
  all-or-nothing per leaf: a "converted" handler performs no mutation by any path —
  direct or via helper (C5). Agent-event handlers in `events.rs` are exempt in this PR.
- **INV-R2 (reducer purity):** `reduce` performs no I/O — no channel send, no clipboard,
  no task spawn, no terminal escape. It only mutates `App` state and returns `Effect`s.
- **INV-R3 (effect ownership):** Any `oneshot::Sender` (confirm/elicitation) is taken
  out of modal state by the reducer and carried in the `Effect`. The reducer never
  leaves a dangling sender. `run_effects` never reaches back into modal state.
- **INV-M1 (mouse ⇒ reducer):** Mouse events produce `Action`s via `decode_mouse` and
  apply through `reduce`. Hit-testing lives in `decode_mouse`; no mouse handler mutates
  state directly.
- **INV-M2 (scroll-mode exclusivity):** Alternate-scroll and mouse capture are never
  both enabled. Turning mouse on disables alternate-scroll first; turning it off
  re-enables alternate-scroll.
- **INV-M3 (selection preserved when off):** With `mouse = false` (default), terminal
  behaviour is byte-for-byte unchanged from before this PR.
- **INV-M4 (teardown safety):** `restore_terminal` always disables mouse capture,
  regardless of whether it was enabled. A panic hook or `Drop` guard ensures this runs
  even on unclean exit. Violation: crash mid-session leaves the terminal in capture mode.

### Ask First

- Changing `reduce` to take ownership of `App` (would require cloning channels/senders).
- Adding a second field or channel through which mouse/key handlers communicate
  state changes outside `reduce` + `Effect`.
- Merging agent-event handlers into the same `reduce` function in this PR.
- Adding a browser-spawn or OS `open` effect for OSC8 clicks (security review required).

### Never

- **NEVER** call `user_input_tx`, `command_tx`, `clipboard`, `tokio::spawn`, or
  `crossterm::execute!` from inside `reduce`. Those are `Effect`s.
- **NEVER** enable `EnableMouseCapture` without first emitting `DisableAlternateScroll`
  in the same `crossterm::execute!` call, and vice versa. They conflict over the wheel
  and over selection.
- **NEVER** mutate `scroll_offset`, `input`, `cursor_position`, panel/view/modal state
  from a key or mouse handler after migration — emit an `Action`.
- **NEVER** invent a browser/URL-spawn side-effect for OSC8 mouse clicks in this PR.
  Ship only the hit-test scaffold; rely on terminal-native OSC8 for link opening.
- **NEVER** place the `pending_mouse_capture` drain inside only one `tokio::select!` arm.
  It must be in the shared post-select block so it fires regardless of which event arm
  matched (C2).
- **NEVER** set `dirty = Full` for `Moved`/`Drag` events — filter them at the event
  source to prevent a full-redraw storm on mouse hover (C6).
- **NEVER** enable mouse capture before the first successful `terminal.draw``last_layout`
  is `None` until then (C3).

---

## 6. Edge Cases and Error Handling

| Scenario | Expected Behavior |
|----------|-------------------|
| Mouse event arrives before first draw (`last_layout = None`) | `decode_mouse` returns `None`; event silently dropped. Document as known v1 limitation; covered by unit test. |
| `EnableMouseCapture` issued while alternate-scroll is active | NEVER rule; the toggle path always emits `DisableAlternateScroll` first in the same `execute!` |
| `DisableMouseCapture` when capture was never enabled | Idempotent — terminal ignores `?1000l`; no error |
| `ConfirmRespond` or `ElicitationSubmit` with `modal_state = None` | Reducer returns empty `Vec<Effect>`; no panic (sender already taken or dialog already closed) |
| Rapid `/mouse on` + `/mouse off` before first drain | `pending_mouse_capture` is `Option<bool>` — last write wins; correct because `tui_loop` drains once per post-select cycle |
| Scroll/click while `mouse_enabled = false` | `handle_mouse` returns early; no Action emitted |
| `Moved`/`Drag` floods with mouse=on | Mapped to `Tick` at event source; `dirty` not set to `Full`; no storm |
| Panic mid-session with mouse=on | Panic hook / `Drop` guard calls `restore_terminal` which always emits `DisableMouseCapture` |
| OSC8 borrow after switching `take_` → borrow | OSC8 sequences still emitted on every redraw; regression covered by integration test |
| `ElicitationEdit` sub-mutation not covered by enum | Compile error — enum is exhaustive; no fallback to opaque mutation |
| Partial-migration: converted handler calls a helper that mutates | Caught in code review — INV-R1 definition requires zero-mutation path including via helpers |

---

## 7. Testing & Acceptance Criteria

### Unit tests

- **Reducer tests** (`app/reducer.rs` `#[cfg(test)]`): for each `Action`, build a
  minimal `App` (via `App::new` + dummy channels), apply `reduce`, assert targeted
  field changed + unrelated fields did not, and assert returned `Effect`s.
- **Decode tests:** table-driven `(KeyEvent, mode/modal) → Option<Action>` for
  `decode_key`; synthetic `MouseEvent { column, row, kind }` against fixed `AppLayout`
  for `decode_mouse` (wheel in chat → `ScrollLines`, click in skills →
  `SetActivePanel(Skills)`, drag → `None`).
- **`decode_mouse` None-guard test:** verify no panic when `last_layout = None` (C3).
- **One-shot sender test:** `ConfirmRespond(true)` yields exactly `[ResolveConfirm(true)]`
  and leaves `confirm_state = None`.

### Cross-mode consistency

A test applies `Action::ScrollLines(-3)` reached via (a) Down-arrow key and (b) a chat-
region `ScrollDown` wheel event, and asserts identical resulting `App` state. Proves
keyboard and mouse share the reducer (INV-M1).

### Purity guard

Optional but recommended: a grep-based CI lint that fails if `reduce`'s function body
contains `try_send`, `.send(`, `spawn`, `execute!`, or `clipboard`. Comment-mark
`// PURITY:` at the function signature.

### Snapshot tests

Existing `insta` snapshots in `app/snapshots/` must remain unchanged. Run:
`cargo insta test --workspace --features full --check --lib --bins`.
Any snapshot diff in Steps 1–3 is a regression to investigate, not to accept blindly.

### Acceptance criteria (all must pass before PR merge)

- [ ] All existing TUI tests + snapshots pass unchanged (keyboard behaviour preserved).
- [ ] `reduce` is the sole state-mutation site for key + mouse paths (INV-R1, INV-M1).
- [ ] `mouse = false` (default): terminal behaviour identical to pre-PR (INV-M3).
- [ ] `/mouse on`: wheel scrolls chat, click focuses panels, status hint shown, selection requires Shift.
- [ ] `/mouse off` restores alternate-scroll + native selection.
- [ ] Config `[tui] mouse = true` enables capture after first draw (C3).
- [ ] `restore_terminal` always disables capture (INV-M4).
- [ ] Panic hook confirmed to call `restore_terminal` (C7 / INV-M4).
- [ ] OSC8 sequences still emitted on redraw after `take_` → borrow switch (C1).
- [ ] `ElicitationEdit` covers all 7 sub-mutations (C4).
- [ ] `Moved`/`Drag` events do not trigger `dirty = Full` (C6).
- [ ] `pending_mouse_capture` drains in post-select block, not inside an event arm (C2).
- [ ] New unit tests: reducer per-Action, decode_key table, decode_mouse, None-guard, cross-mode scroll-equivalence.
- [ ] Playbook at `.local/testing/playbooks/tui-reducer-mouse.md` authored.
- [ ] `coverage-status.md` rows added for `TUI Reducer` and `TUI Mouse Mode`.

---

## 8. Migration Risks

| Risk | Mitigation |
|------|-----------|
| Partial-migration double-apply (leaf emits Action AND mutates) | Delete inline mutation in the same edit; INV-R1 says "zero mutation by any path"; review checks helpers too (C5) |
| One-shot sender leak (dialog cleared but agent blocks forever) | INV-R3 + unit test that `ConfirmRespond(true)` → exactly one `ResolveConfirm(true)` + `confirm_state = None` |
| OSC8 regression (links stop firing after `take_` → borrow switch) | Integration test; check `lib.rs:204-210` should_draw path manually (C1) |
| Scroll-mode toggle ordering (wheel double-interpreted) | NEVER rule; `DisableAlternateScroll + EnableMouseCapture` in one `execute!` |
| First-frame mouse event dropped | Documented v1 limitation; unit test guards `None` (C3) |
| Moved/Drag redraw storm | Filter at event source to `Tick`; no `Full` dirty on `None` decode return (C6) |
| Panic leaves terminal in capture mode | Panic hook / Drop guard confirmed before merge (C7) |
| Snapshot churn during Steps 1–3 | Treat any diff as regression; investigate before accepting |

---

## 9. Integration Points Checklist (CLAUDE.md Development Rules)

1. **config.toml**`[tui] mouse` field (§3.6). Designed.
2. **CLI** — no new subcommand; mouse is TUI-only. N/A.
3. **TUI command**`/mouse [on|off]` + palette "Toggle mouse mode" entry (§3.6). Designed.
4. **Wizard (`--init`)**`[tui]` mouse yes/no prompt (§3.6). Designed.
5. **Migration (`--migrate-config`)** — step 67: insert `mouse = false` under `[tui]` (§3.6). Designed.
6. **Playbook**`.local/testing/playbooks/tui-reducer-mouse.md` (developer to author in Step 8).
7. **coverage-status.md** — rows `TUI Reducer` and `TUI Mouse Mode``Untested` (developer, Step 8).

---

## 10. See Also

- [[MOC-specs]] — Map of all specifications
- [[constitution]] — Project-wide non-negotiable rules
- [[001-system-invariants/spec]] — Cross-cutting architectural invariants
- [[011-tui/spec]] — TUI dashboard baseline spec
- [[039-background-task-supervisor/spec]] — TaskSupervisor contract (async/spawn rules)