| 389 | |
| 390 | // eraseScrollback wipes the terminal's saved-lines buffer (DECSED 3); no |
| 391 | // tea.ClearScreen equivalent clears scrollback. |
| 392 | var eraseScrollback tea.Cmd = func() tea.Msg { |
| 393 | os.Stdout.WriteString(ansi.EraseDisplay(3)) |
| 394 | return nil |
| 395 | } |
| 396 | |
| 397 | // pingMsg carries a backend-reachability result. baseURL is the URL probed; |
| 398 | // Update drops the message when it no longer matches the live client's URL, |
| 399 | // else a stale ping from the prior profile (a mid-flight /models switch) would |
| 400 | // overwrite connected state with the wrong endpoint's reachability. |
| 401 | type pingMsg struct { |
| 402 | ok bool |
| 403 | baseURL string |
| 404 | } |
| 405 | |
| 406 | // quitArmResetMsg fires ~3s after Ctrl+C arms the quit: if not already quit or |
| 407 | // re-armed, clear the hint from the status bar. |
| 408 | type quitArmResetMsg struct{} |
| 409 | |
| 410 | func (m Model) Init() tea.Cmd { |
| 411 | // Keyed (cloud) profiles get a silent Probe at startup so the status bar |
| 412 | // renders the live budget / context window from the first frame. Keyless |
| 413 | // (local Ollama) profiles get the cheaper Reachable ping: no headers to |
| 414 | // harvest, so a full probe would buy nothing. |
| 415 | connectivity := pingBackend(m.cli.BaseURL) |
| 416 | if p := m.cfg.ActiveProfile(); p != nil && p.ResolvedKey() != "" { |
| 417 | connectivity = probeBackend(m.cli, m.cfg.Active, true) |
| 418 | } |
| 419 | return tea.Batch( |
| 420 | textarea.Blink, |
| 421 | m.spinner.Tick, |
| 422 | connectivity, |
| 423 | ) |
| 424 | } |
| 425 | |
| 426 | // Update is the bubbletea entry point: it dispatches to update()'s typed |
| 427 | // handlers then drains the outbox into a single tea.Println, so lines land in |
| 428 | // scrollback in the exact order appendLine / flushStreaming queued them. One |
| 429 | // Println per cycle, never a Batch; Batch runs children concurrently, leaving |
| 430 | // arrival order undefined, so splash lines and tool-call banners would shuffle. |
| 431 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 432 | next, cmd := m.update(msg) |
| 433 | nm := next.(Model) |
| 434 | if len(nm.outbox) > 0 { |
| 435 | printCmd := tea.Println(wrapForScrollback(strings.Join(nm.outbox, "\n"), nm.width)) |
| 436 | nm.outbox = nil |
| 437 | cmd = tea.Batch(printCmd, cmd) |
| 438 | } |
| 439 | return nm, cmd |
| 440 | } |
| 441 | |
| 442 | func (m Model) update(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 443 | switch msg := msg.(type) { |
| 444 | case tea.FocusMsg, tea.BlurMsg: |
| 445 | // Terminal focus reports (CSI I / CSI O) arrive as these typed msgs |
| 446 | // under tea.WithReportFocus. Swallow them so they never reach |
| 447 | // textarea.Update; otherwise the escape fragments get parsed as |
| 448 | // printable runes, inserted into the prompt, and bloat textarea height |