| 389 | } |
| 390 | |
| 391 | func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 392 | switch msg := msg.(type) { |
| 393 | case tea.FocusMsg, tea.BlurMsg: |
| 394 | // Terminal focus reports (CSI I / CSI O) arrive as these typed msgs |
| 395 | // under tea.WithReportFocus. Swallow them so they never reach |
| 396 | // textarea.Update; otherwise the escape fragments get parsed as |
| 397 | // printable runes, inserted into the prompt, and bloat textarea height |
| 398 | // on every focus switch. |
| 399 | return m, nil |
| 400 | |
| 401 | case tea.KeyMsg: |
| 402 | // An empty-runes key can surface when the parser chokes mid-escape. |
| 403 | // Drop it before recomputeLayout wastes cycles. |
| 404 | if msg.Type == tea.KeyRunes && len(msg.Runes) == 0 { |
| 405 | return m, nil |
| 406 | } |
| 407 | // Pre-grow the textarea before bubbles processes the key. bubbles' |
| 408 | // repositionView() runs at the end of textarea.Update and scrolls the |
| 409 | // viewport down whenever the cursor crosses below current Height, but |
| 410 | // our recomputeLayout() grows Height only AFTER handleKey returns. So |
| 411 | // a char that wraps to a new visual row leaves YOffset>0 with the first |
| 412 | // wrap row clipped off the top, which recomputeLayout can't reclaim. |
| 413 | // Inflating Height to the screen-cap first keeps the cursor inside the |
| 414 | // visible band for any normal keystroke, so repositionView doesn't |
| 415 | // scroll and YOffset stays 0; recomputeLayout then trims Height back to |
| 416 | // visualPromptLines so the live region doesn't bloat empty rows. |
| 417 | m.preGrowTextarea() |
| 418 | next, cmd := m.handleKey(msg) |
| 419 | nm := next.(Model) |
| 420 | nm.recomputeLayout() |
| 421 | return nm, cmd |
| 422 | |
| 423 | case tea.WindowSizeMsg: |
| 424 | return m.handleWindowSize(msg) |
| 425 | |
| 426 | case resizeSettleMsg: |
| 427 | return m.handleResizeSettle(msg) |
| 428 | |
| 429 | case pingMsg: |
| 430 | // Drop stale pings from a prior backend (a /models switch while a ping |
| 431 | // was in flight). The live client's URL is the source of truth. |
| 432 | if msg.baseURL != m.cli.BaseURL { |
| 433 | return m, nil |
| 434 | } |
| 435 | m.connected = msg.ok |
| 436 | return m, nil |
| 437 | |
| 438 | case probeMsg: |
| 439 | return m.handleProbe(msg) |
| 440 | |
| 441 | case spinner.TickMsg: |
| 442 | var cmd tea.Cmd |
| 443 | m.spinner, cmd = m.spinner.Update(msg) |
| 444 | return m, cmd |
| 445 | |
| 446 | case streamEventMsg: |
| 447 | // Stale event from a stream the current turn no longer owns (Ctrl+C → |
| 448 | // fresh submit while the prior readEvent was in flight). Keep draining |