Files
correx/apps/tui-go/internal/app/update.go
T
kami d247b19608 tui: migrate Bubble Tea v1 → v2 (charm.land), enable Shift+Enter
Move the Go TUI from github.com/charmbracelet/{bubbletea,lipgloss} to the v2
ecosystem at charm.land/{bubbletea,lipgloss}/v2 (Go 1.25). v2's kitty keyboard
disambiguation is on by default, so Shift+Enter now reliably inserts a newline
in the composer (Ctrl+J / Alt+Enter kept as fallbacks for non-kitty terminals).

Approach: rather than rewrite ~190 key-match sites to v2's (Code, Mod) idiom, a
small shim (internal/app/key.go) converts a v2 KeyPressMsg into the v1-shaped
keyMsg the handlers already expect, at the single Update boundary. The rest is
mechanical:
- key constants tea.Key* → shim consts; tea.KeyMsg → keyMsg.
- lipgloss.Color is now a func returning color.Color, not a type → fields/params
  retyped to image/color.Color (theme/diff/overlays/view).
- v2 View() returns tea.View: render() builds the string, View() wraps it and
  carries AltScreen (alt-screen is a per-frame View field now, not a program opt).
- WithWhitespaceBackground/Foreground → WithWhitespaceStyle(Style).
- preview: drop lipgloss.SetColorProfile (v2 renders truecolor by default).

Build, vet, gofmt, and the full test suite are green; preview renders truecolor
across kinds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:59:55 +00:00

1645 lines
44 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"fmt"
"os"
"strings"
"time"
tea "charm.land/bubbletea/v2"
osc52 "github.com/aymanbagabas/go-osc52/v2"
"github.com/correx/tui-go/internal/protocol"
"github.com/correx/tui-go/internal/ws"
)
// --- Bubble Tea messages bridging the ws channels ---
type serverMsg struct{ m protocol.ServerMessage }
type connMsg struct{ s ws.Status }
type tickMsg struct{}
// copiedFlashFrames is how many ticks the "✓ copied" footer note lingers after a yank.
const copiedFlashFrames = 12
// fileListMax caps how many @-picker rows are filtered/rendered at once.
const fileListMax = 200
func readServer(c *ws.Client) tea.Cmd {
return func() tea.Msg { return serverMsg{<-c.Incoming()} }
}
func readConn(c *ws.Client) tea.Cmd {
return func() tea.Msg { return connMsg{<-c.Conn()} }
}
func tick() tea.Cmd {
return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return tickMsg{} })
}
// Init starts the channel readers. The animation tick is NOT started here — it is
// kicked lazily by tickKick once something actually needs to animate (an active
// session's spinner, a blinking caret), so an idle screen never auto-redraws.
func (m Model) Init() tea.Cmd {
return tea.Batch(readServer(m.client), readConn(m.client))
}
// animating reports whether anything on screen needs the frame counter to advance:
// a spinning session, a blinking caret (any typing surface), or the reconnect state.
// When false, the tick loop stops and the screen holds still — so a native terminal
// text selection survives instead of being wiped by the next redraw.
func (m Model) animating() bool {
if m.editMode == ModeInsert || m.steering || m.clarTyping || m.proposeTyping || m.configEditing {
return true
}
if m.reconnecting {
return true
}
if m.overlay == OverlayPalette || m.overlay == OverlayFiles { // blinking filter caret
return true
}
if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames {
return true // keep ticking briefly so the "copied" note animates out
}
for i := range m.sessions {
if m.sessions[i].Active {
return true
}
}
return false
}
// tickKick starts the tick loop iff animation is needed and no loop is already
// running. Returns nil otherwise. The single-loop invariant: ticking is true exactly
// while a loop is scheduled (set here, cleared by tickMsg when it stops), so this
// never spawns a second concurrent loop.
func (m *Model) tickKick() tea.Cmd {
if m.animating() && !m.ticking {
m.ticking = true
return tick()
}
return nil
}
// Update is the single reducer. View renders purely from the returned model.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case tickMsg:
m.frame++
if m.animating() {
return m, tick()
}
m.ticking = false // loop stops; tickKick will restart it when animation resumes
return m, nil
case connMsg:
m.applyConn(msg.s)
return m, tea.Batch(readConn(m.client), m.tickKick())
case serverMsg:
m.applyServerPhased(msg.m)
return m, tea.Batch(readServer(m.client), m.tickKick())
case sessionsLoadedMsg:
m.applySessionsLoaded(msg)
return m, nil
case sessionResumeMsg:
if msg.err != "" {
m.sessionListErr = "resume: " + msg.err
}
return m, nil
case tea.KeyPressMsg:
next, cmd := m.handleKey(toKeyMsg(msg))
// A keypress may have entered a typing/active state (insert mode, steering, …);
// kick the tick so the caret/spinner animates again.
if nm, ok := next.(Model); ok {
return nm, tea.Batch(cmd, nm.tickKick())
}
return next, cmd
}
return m, nil
}
// applySessionsLoaded folds a GET /sessions result into the browser, clamping the
// cursor and surfacing any fetch error in place of a crash.
func (m *Model) applySessionsLoaded(msg sessionsLoadedMsg) {
m.sessionListLoading = false
if msg.err != "" {
m.sessionListErr = msg.err
return
}
m.sessionListErr = ""
m.sessionList = msg.sessions
if m.sessionListIndex >= len(m.sessionList) {
m.sessionListIndex = 0
}
}
func (m *Model) applyConn(s ws.Status) {
switch {
case s.Connected:
m.connected = true
m.reconnecting = false
m.snapshotPhase = true
m.pendingEvents = nil
case s.Reconnecting:
m.connected = false
m.reconnecting = true
m.snapshotPhase = true
}
}
// applyServerPhased buffers event-bearing messages during the snapshot phase and
// drains them atomically on snapshot_complete.
func (m *Model) applyServerPhased(msg protocol.ServerMessage) {
if msg.Type == protocol.TypeSnapshotComplete {
m.snapshotPhase = false
buffered := m.pendingEvents
m.pendingEvents = nil
for _, e := range buffered {
m.applyServer(e)
}
return
}
if m.snapshotPhase && msg.IsEventBearing() {
m.pendingEvents = append(m.pendingEvents, msg)
return
}
m.applyServer(msg)
}
// --- key handling ---
func (m Model) handleKey(k keyMsg) (tea.Model, tea.Cmd) {
debugLog("KEY type=%v runes=%q alt=%v | ds=%s input=%s edit=%s overlay=%d steering=%v steerBuf=%q dismissed=%v entered=%v sel=%s",
k.Type, string(k.Runes), k.Alt, m.displayState(), m.inputMode, m.editMode, m.overlay,
m.steering, m.steerBuffer, m.approvalDismissed, m.sessionEntered, m.selectedID)
// Ctrl+C is a universal hard-quit safety; everything else is bare-key modal.
if k.Type == keyCtrlC {
m.quitting = true
return m, tea.Quit
}
if m.overlay != OverlayNone {
return m.handleOverlayKey(k)
}
if m.displayState() == StateClarification {
m.clarEnsureState()
return m.handleClarificationKey(k)
}
if m.displayState() == StateWorkflowPropose {
return m.handleProposeKey(k)
}
// Transcript message-jump works in any edit mode while in-session, so you can scroll
// back through your sent messages without leaving the input. ctrl+↑ older, ctrl+↓ newer.
if m.displayState() == StateInSession {
switch k.Type {
case keyCtrlUp:
m.transcriptNavUser(-1)
return m, nil
case keyCtrlDown:
m.transcriptNavUser(1)
return m, nil
}
}
// On the idle launcher, Tab cycles the launch target (chat → workflows → chat) in any
// edit mode, so it works whether or not the input is focused.
if m.displayState() == StateIdle && k.Type == keyTab {
m.cycleLauncherWf()
return m, nil
}
if m.editMode == ModeInsert {
return m.handleInsertKey(k)
}
return m.handleNormalKey(k)
}
// handleNormalKey processes vim-style bare-key commands.
func (m Model) handleNormalKey(k keyMsg) (tea.Model, tea.Cmd) {
ds := m.displayState()
// The approval band owns its keys: single-key y/n/e for low tiers, an arm→confirm
// gate for T3+, and ↑/↓ to walk the pending queue. Handling it up front keeps the
// general bindings (e=events, t=tools, …) from shadowing the decision keys.
if ds == StateApproval {
return m.handleApprovalKey(k)
}
switch k.Type {
case keyUp:
m.navUp()
return m, nil
case keyDown:
m.navDown()
return m, nil
case keyEnter:
return m.normalEnter()
case keyEsc:
// In-session, esc first drops a transcript selection or output scroll (back to
// tail-follow); otherwise it clears the session-list filter.
if m.transcriptSel >= 0 {
m.transcriptSel = -1
return m, nil
}
if m.outputScroll != 0 {
m.outputScroll = 0
return m, nil
}
m.filter = ""
return m, nil
case keyPgUp:
m.scrollOutput(m.outputViewportPage())
return m, nil
case keyPgDown:
m.scrollOutput(-m.outputViewportPage())
return m, nil
case keyCtrlU:
m.scrollOutput(m.outputViewportPage() / 2)
return m, nil
case keyCtrlD:
m.scrollOutput(-m.outputViewportPage() / 2)
return m, nil
case keyCtrlX:
if m.currentDiff() != "" {
m.overlay = OverlayDiff
m.diffScrollOffset = 0
}
return m, nil
}
if k.Type != keyRunes {
return m, nil
}
switch string(k.Runes) {
case "i":
m.enterInsert(ModeRouter)
case "?":
m.overlay = OverlayHelp
m.modalScroll = 0
case "/":
m.enterInsert(ModeFilter)
case "j":
m.navDown()
case "k":
m.navUp()
case "p":
m.openPalette()
case "e":
m.openEventInspector()
case "t":
m.overlay = OverlayToolPalette
case "v":
m.openArtifacts()
case "I":
m.openIdeas()
case "R":
// Resume browser: recent sessions over HTTP, reachable even after a server
// restart + TUI reopen (the live stream only carries running sessions).
return m, m.openSessions()
case "S":
m.openStats()
case "G":
m.openGrants()
case "g":
m.openConfig()
case "m":
m.openModelsOverlay()
case "w":
// Launcher: w is an alias for Tab — cycle the launch target (chat / workflow…).
if ds == StateIdle {
m.cycleLauncherWf()
}
case "d":
// In-session: cycle the right panel (events → changes → off).
if ds == StateInSession {
m.cycleRightPanel()
}
case "l":
if ds != StateIdle {
m.sessionEntered = false
m.approvalDismissed = false
}
case "s":
m.cycleChatMode()
case "y":
// Copy the selected message (or the latest assistant turn if none is selected)
// to the system clipboard over OSC52 — SSH-safe, unlike a mouse drag.
if cmd := m.copyMessage(); cmd != nil {
m.copiedFlash = m.frame
return m, cmd
}
case "c":
if m.selectedID != "" {
m.client.Send(protocol.CancelSession(m.selectedID))
}
case "a":
// In-session (gate peeked away with esc): `a` brings the band back.
if ds == StateInSession {
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
m.approvalDismissed = false
}
}
case "q":
m.quitting = true
return m, tea.Quit
}
return m, nil
}
// handleApprovalKey owns the approval band's bare keys. Low tiers (T0T2) act on a
// single keypress; a T3+ approve arms first and only a second confirming key sends
// it (any other key disarms). ↑/↓ walk the pending queue without resolving anything.
func (m Model) handleApprovalKey(k keyMsg) (tea.Model, tea.Cmd) {
s := m.session(m.selectedID)
if s == nil || s.Pending == nil {
return m, nil
}
// Queue navigation never resolves a gate; it just changes which one is shown.
switch {
case k.Type == keyUp || runeIs(k, "k"):
s.navApproval(-1)
m.approvalArmed = false
return m, nil
case k.Type == keyDown || runeIs(k, "j"):
s.navApproval(1)
m.approvalArmed = false
return m, nil
}
// Approve: y / a / enter / ^a. For a high tier this arms the confirm instead of
// sending; the same key pressed again (while armed) confirms.
approveKey := k.Type == keyEnter || k.Type == keyCtrlA ||
runeIs(k, "y") || runeIs(k, "a")
if approveKey {
if s.Pending.HighTier() && !m.approvalArmed {
m.approvalArmed = true
return m, nil
}
return m.decide("APPROVE")
}
// Any other key cancels a pending T3 arm rather than acting on it.
m.approvalArmed = false
switch {
case k.Type == keyCtrlR || runeIs(k, "n") || runeIs(k, "r"):
return m.decide("REJECT")
case runeIs(k, "e") || runeIs(k, "s"):
// Steer/edit flows into the existing steering-note input path.
m.steering = true
m.editMode = ModeInsert
return m, nil
case runeIs(k, "A"):
return m.autoApprove()
case runeIs(k, "l"):
m.sessionEntered = false
m.approvalDismissed = false
return m, nil
case k.Type == keyEsc:
m.approvalDismissed = true
return m, nil
case k.Type == keyCtrlX:
if m.currentDiff() != "" {
m.overlay = OverlayDiff
m.diffScrollOffset = 0
}
return m, nil
case runeIs(k, "q"):
m.quitting = true
return m, tea.Quit
}
// A bare disarm (e.g. an unrelated key while armed) just falls through.
return m, nil
}
// handleInsertKey processes typing (chat/filter/steer note).
func (m Model) handleInsertKey(k keyMsg) (tea.Model, tea.Cmd) {
if m.steering {
switch k.Type {
case keyEsc:
m.steering = false
m.editMode = ModeNormal
case keyEnter:
return m.submitApproval()
case keyBackspace:
if n := len(m.steerBuffer); n > 0 {
m.steerBuffer = m.steerBuffer[:n-1]
}
case keyRunes, keySpace:
m.steerBuffer += string(k.Runes)
}
return m, nil
}
switch k.Type {
case keyEsc:
m.editMode = ModeNormal
if m.inputMode == ModeFilter {
m.inputMode = ModeRouter
}
if m.inputMode == ModeIntent {
m.wfPendingID = ""
m.wfPendingName = ""
m.clearInput()
m.inputMode = ModeRouter
}
case keyEnter:
// Shift+Enter (and Alt+Enter) insert a newline; a bare Enter submits. Shift+Enter
// is now distinguishable thanks to bubbletea v2's kitty keyboard disambiguation;
// Ctrl+J below stays as a fallback for terminals without kitty support.
if (k.Shift || k.Alt) && m.inputMode != ModeFilter {
m.appendRunes("\n")
return m, nil
}
return m.submit()
case keyCtrlJ:
// Reliable "newline without sending" (Ctrl+J is the literal LF key) for the
// single-line filter excluded.
if m.inputMode != ModeFilter {
m.appendRunes("\n")
}
return m, nil
case keyBackspace:
m.backspace()
m.syncFilter()
case keyLeft:
if m.inputCursor > 0 {
m.inputCursor--
}
case keyRight:
if m.inputCursor < len(m.inputBuffer) {
m.inputCursor++
}
case keyUp:
if m.inputMode == ModeFilter {
m.listNav(-1)
} else {
m.historyPrev()
}
case keyDown:
if m.inputMode == ModeFilter {
m.listNav(1)
} else {
m.historyNext()
}
case keyRunes, keySpace:
// opencode-style slash commands: `/` as the first char of a chat line opens the
// command palette inline instead of typing a literal slash. Mid-line `/` is literal.
if m.inputMode == ModeRouter && m.inputBuffer == "" && string(k.Runes) == "/" {
m.editMode = ModeNormal
m.clearInput()
m.openPalette()
return m, nil
}
// `@` at a token boundary opens the workspace file picker; the selection inserts
// `@path`. Mid-word `@` (e.g. an email) is literal.
if m.inputMode == ModeRouter && string(k.Runes) == "@" && m.atTokenBoundary() {
m.openFiles()
return m, nil
}
m.appendRunes(string(k.Runes))
m.syncFilter()
}
return m, nil
}
// openPalette opens the command palette with a cleared filter. Shared by the `p`
// normal-mode key and the inline `/` trigger in the chat input.
func (m *Model) openPalette() {
m.overlay = OverlayPalette
m.paletteFilter = ""
m.paletteIndex = 0
}
// atTokenBoundary reports whether the input cursor sits at the start of a word (start of
// line or just after a space) — where `@` should begin a file reference rather than be literal.
func (m Model) atTokenBoundary() bool {
if m.inputCursor <= 0 {
return true
}
if m.inputCursor <= len(m.inputBuffer) {
return m.inputBuffer[m.inputCursor-1] == ' '
}
return false
}
// openFiles opens the `@` file picker for the selected session and requests its workspace
// paths (cached per session). Editing mode is left in Insert so closing returns to typing.
func (m *Model) openFiles() {
m.overlay = OverlayFiles
m.fileFilter = ""
m.fileIndex = 0
if m.filesFor != m.selectedID {
m.files = nil
m.filesLoading = true
}
m.client.Send(protocol.ListFiles(m.selectedID))
}
// insertFileRef inserts `@path ` at the input cursor (the `@` was consumed to open the picker).
func (m *Model) insertFileRef(path string) {
ref := "@" + path + " "
c := m.inputCursor
if c > len(m.inputBuffer) {
c = len(m.inputBuffer)
}
m.inputBuffer = m.inputBuffer[:c] + ref + m.inputBuffer[c:]
m.inputCursor = c + len(ref)
m.fileFilter = ""
}
// filteredFiles narrows the workspace paths by the typed filter (case-insensitive substring),
// capped for render.
func (m Model) filteredFiles() []string {
if m.fileFilter == "" {
if len(m.files) > fileListMax {
return m.files[:fileListMax]
}
return m.files
}
f := strings.ToLower(m.fileFilter)
out := make([]string, 0, fileListMax)
for _, p := range m.files {
if strings.Contains(strings.ToLower(p), f) {
out = append(out, p)
if len(out) >= fileListMax {
break
}
}
}
return out
}
// beginIntent stashes the chosen workflow and switches to a text-entry prompt for the
// freeform request. Submitting sends StartSession(id, intent); an empty line starts with no
// intent (fixed-task workflows). Esc cancels.
func (m *Model) beginIntent(wf Workflow) {
m.wfPendingID = wf.ID
m.wfPendingName = wf.ID
m.wfVisible = false
m.wfIndex = -1
m.clearInput()
m.editMode = ModeInsert
m.inputMode = ModeIntent
}
func (m *Model) enterInsert(mode InputMode) {
m.editMode = ModeInsert
m.inputMode = mode
if mode == ModeFilter {
m.inputBuffer = m.filter
m.inputCursor = len(m.inputBuffer)
}
}
// syncFilter keeps the live session filter in step with the input buffer.
func (m *Model) syncFilter() {
if m.inputMode == ModeFilter {
m.filter = m.inputBuffer
}
}
func (m Model) normalEnter() (tea.Model, tea.Cmd) {
if m.displayState() != StateIdle {
return m, nil
}
if m.wfVisible && m.wfIndex >= 0 && m.wfIndex < len(m.workflows) {
m.beginIntent(m.workflows[m.wfIndex])
return m, nil
}
if m.selectedID != "" {
m.sessionEntered = true
}
return m, nil
}
// autoApprove (A) opens the scope picker so the operator chooses how broadly to stop being
// asked: this session (the old behaviour), this project, or globally. The actual grant +
// approval are sent by applyGrantScope once a scope is picked.
func (m Model) autoApprove() (tea.Model, tea.Cmd) {
s := m.session(m.selectedID)
if s == nil || s.Pending == nil {
return m, nil
}
m.grantFor = s.Pending
m.grantScopeIndex = 0
m.overlay = OverlayGrantScope
return m, nil
}
func (m Model) handleOverlayKey(k keyMsg) (tea.Model, tea.Cmd) {
// Config overlay owns all its keys (esc cancels an in-progress edit rather than closing).
if m.overlay == OverlayConfig {
return m.handleConfigKey(k)
}
// Sessions overlay owns all its keys: enter resumes (emits a cmd), so it can't
// share the generic no-cmd esc path below.
if m.overlay == OverlaySessions {
return m.handleSessionsKey(k)
}
if k.Type == keyEsc {
// In the event inspector, esc first stops an in-progress filter edit (keeping the
// query) rather than closing the overlay.
if m.overlay == OverlayEventInspector && m.eventFilterTyping {
m.eventFilterTyping = false
return m, nil
}
m.overlay = OverlayNone
return m, nil
}
switch m.overlay {
case OverlayPalette:
return m.handlePaletteKey(k)
case OverlayDiff:
// A long diff/preview is unreadable one line at a time, so the fullscreen
// viewer pages like the OUTPUT transcript: ↑↓/jk by line, PgUp/PgDn by a
// full body, ^u/^d by half, g/G to the ends. All clamp to the diff bounds.
page := m.diffBodyHeight()
switch {
case k.Type == keyUp || runeIs(k, "k"):
m.scrollDiff(-1)
case k.Type == keyDown || runeIs(k, "j"):
m.scrollDiff(1)
case k.Type == keyPgUp:
m.scrollDiff(-page)
case k.Type == keyPgDown:
m.scrollDiff(page)
case k.Type == keyCtrlU:
m.scrollDiff(-page / 2)
case k.Type == keyCtrlD:
m.scrollDiff(page / 2)
case runeIs(k, "g"):
m.diffScrollOffset = 0
case runeIs(k, "G"):
m.diffScrollOffset = m.diffMaxScroll()
case k.Type == keyCtrlX:
m.overlay = OverlayNone
}
case OverlayEventInspector:
evs := m.currentEvents()
if m.eventFilterTyping {
switch {
case k.Type == keyEnter:
m.eventFilterTyping = false
case k.Type == keyBackspace:
if n := len(m.eventFilter); n > 0 {
m.eventFilter = m.eventFilter[:n-1]
m.overlayEventIdx = 0
}
case k.Type == keyRunes || k.Type == keySpace:
m.eventFilter += string(k.Runes)
m.overlayEventIdx = 0
}
return m, nil
}
switch {
case runeIs(k, "/"):
m.eventFilterTyping = true
case runeIs(k, "c"):
m.eventFilter = ""
m.overlayEventIdx = 0
case k.Type == keyUp || runeIs(k, "k"):
if m.overlayEventIdx > 0 {
m.overlayEventIdx--
}
case k.Type == keyDown || runeIs(k, "j"):
if m.overlayEventIdx < len(evs)-1 {
m.overlayEventIdx++
}
}
case OverlayHelp:
// The cheat-sheet scrolls when it's taller than the screen; any non-scroll key
// dismisses it (esc handled above), keeping the quick-glance feel.
page := m.modalBodyRows()
switch {
case k.Type == keyUp || runeIs(k, "k"):
m.scrollModal(-1)
case k.Type == keyDown || runeIs(k, "j"):
m.scrollModal(1)
case k.Type == keyPgUp:
m.scrollModal(-page)
case k.Type == keyPgDown:
m.scrollModal(page)
case k.Type == keyCtrlU:
m.scrollModal(-page / 2)
case k.Type == keyCtrlD:
m.scrollModal(page / 2)
case runeIs(k, "g"):
m.modalScroll = 0
case runeIs(k, "G"):
m.modalScroll = m.scrollableModalMax(len(m.activeModalBody()))
default:
m.overlay = OverlayNone
}
case OverlayToolPalette:
if runeIs(k, "t") {
m.overlay = OverlayNone
}
case OverlayArtifacts:
switch {
case k.Type == keyUp || runeIs(k, "k"):
if m.artifactsIndex > 0 {
m.artifactsIndex--
m.artifactScroll = 0
}
case k.Type == keyDown || runeIs(k, "j"):
if m.artifactsIndex < len(m.artifacts)-1 {
m.artifactsIndex++
m.artifactScroll = 0
}
case k.Type == keyPgUp:
m.scrollArtifact(-m.artifactContentBodyH())
case k.Type == keyPgDown:
m.scrollArtifact(m.artifactContentBodyH())
case k.Type == keyCtrlU:
m.scrollArtifact(-m.artifactContentBodyH() / 2)
case k.Type == keyCtrlD:
m.scrollArtifact(m.artifactContentBodyH() / 2)
case runeIs(k, "g"):
m.artifactScroll = 0
case runeIs(k, "G"):
m.artifactScroll = m.artifactContentMaxScroll(m.modalWidth() - 6)
case runeIs(k, "v"):
m.overlay = OverlayNone
}
case OverlayModels:
switch {
case k.Type == keyUp || runeIs(k, "k"):
if m.modelsIndex > 0 {
m.modelsIndex--
}
case k.Type == keyDown || runeIs(k, "j"):
if m.modelsIndex < len(m.availableModels)-1 {
m.modelsIndex++
}
case k.Type == keyEnter:
if m.modelsIndex >= 0 && m.modelsIndex < len(m.availableModels) {
m.client.Send(protocol.SwapModel(m.availableModels[m.modelsIndex]))
m.overlay = OverlayNone
}
case runeIs(k, "c"):
m.client.Send(protocol.ClearModelPin())
m.overlay = OverlayNone
case runeIs(k, "m"):
m.overlay = OverlayNone
}
case OverlayStats:
page := m.modalBodyRows()
switch {
case k.Type == keyUp || runeIs(k, "k"):
m.scrollModal(-1)
case k.Type == keyDown || runeIs(k, "j"):
m.scrollModal(1)
case k.Type == keyPgUp:
m.scrollModal(-page)
case k.Type == keyPgDown:
m.scrollModal(page)
case k.Type == keyCtrlU:
m.scrollModal(-page / 2)
case k.Type == keyCtrlD:
m.scrollModal(page / 2)
case runeIs(k, "g"):
m.modalScroll = 0
case runeIs(k, "G"):
m.modalScroll = m.scrollableModalMax(len(m.activeModalBody()))
case runeIs(k, "S"):
m.overlay = OverlayNone
}
case OverlayIdeas:
switch {
case k.Type == keyUp || runeIs(k, "k"):
if m.ideasIndex > 0 {
m.ideasIndex--
}
case k.Type == keyDown || runeIs(k, "j"):
if m.ideasIndex < len(m.ideas)-1 {
m.ideasIndex++
}
case runeIs(k, "x") || runeIs(k, "d"):
if m.ideasIndex >= 0 && m.ideasIndex < len(m.ideas) {
m.client.Send(protocol.DiscardIdea(m.ideas[m.ideasIndex].IdeaID))
// Optimistic removal; the server's fresh idea.list reply reconciles.
m.ideas = append(m.ideas[:m.ideasIndex], m.ideas[m.ideasIndex+1:]...)
if m.ideasIndex >= len(m.ideas) && m.ideasIndex > 0 {
m.ideasIndex--
}
}
case runeIs(k, "I"):
m.overlay = OverlayNone
}
case OverlayFiles:
files := m.filteredFiles()
switch {
case k.Type == keyUp:
if m.fileIndex > 0 {
m.fileIndex--
}
case k.Type == keyDown:
if m.fileIndex < len(files)-1 {
m.fileIndex++
}
case k.Type == keyEnter:
if m.fileIndex >= 0 && m.fileIndex < len(files) {
m.insertFileRef(files[m.fileIndex])
}
m.overlay = OverlayNone // back to typing (still in insert mode)
case k.Type == keyBackspace:
if n := len(m.fileFilter); n > 0 {
m.fileFilter = m.fileFilter[:n-1]
m.fileIndex = 0
}
case k.Type == keyRunes || k.Type == keySpace:
m.fileFilter += string(k.Runes)
m.fileIndex = 0
}
case OverlayStatusbar:
switch {
case k.Type == keyUp || runeIs(k, "k"):
if m.sbIndex > 0 {
m.sbIndex--
}
case k.Type == keyDown || runeIs(k, "j"):
if m.sbIndex < len(statusSegments)-1 {
m.sbIndex++
}
case k.Type == keySpace || k.Type == keyEnter || runeIs(k, "x"):
if m.sbIndex >= 0 && m.sbIndex < len(statusSegments) {
m.toggleStatusSegment(statusSegments[m.sbIndex].id)
}
case runeIs(k, "g"):
m.overlay = OverlayNone
}
case OverlayGrants:
switch {
case k.Type == keyUp || runeIs(k, "k"):
if m.grantIndex > 0 {
m.grantIndex--
}
case k.Type == keyDown || runeIs(k, "j"):
if m.grantIndex < len(m.grants)-1 {
m.grantIndex++
}
case k.Type == keyEnter || runeIs(k, "x"):
if m.grantIndex >= 0 && m.grantIndex < len(m.grants) {
g := m.grants[m.grantIndex]
m.client.Send(protocol.RevokeGrant(g.GrantID))
m.appendAction(m.selectedID, "⊟", "revoked "+g.ToolName+" · "+strings.ToLower(g.Scope))
// The server replies with a fresh grant.list, which refreshes m.grants.
}
case runeIs(k, "G"):
m.overlay = OverlayNone
}
case OverlayGrantScope:
return m.handleGrantScopeKey(k)
}
return m, nil
}
// grantScopeOption is one breadth choice in the A (approve-always) scope picker. wire is the
// CreateGrant scope name: SESSION is per-session (dies with it), PROJECT covers any session on
// the same repo, GLOBAL every session on this machine.
type grantScopeOption struct{ key, wire, title, hint string }
func grantScopeOptions() []grantScopeOption {
return []grantScopeOption{
{"s", "SESSION", "this session", "until this session ends"},
{"p", "PROJECT", "this project", "any session on this repo"},
{"g", "GLOBAL", "everywhere", "any session on this machine"},
}
}
// handleGrantScopeKey drives the scope picker: ↑/↓ move, s/p/g jump-and-confirm, enter
// confirms the highlighted scope. esc (handled upstream) cancels without granting.
func (m Model) handleGrantScopeKey(k keyMsg) (tea.Model, tea.Cmd) {
opts := grantScopeOptions()
switch {
case k.Type == keyUp || runeIs(k, "k"):
if m.grantScopeIndex > 0 {
m.grantScopeIndex--
}
case k.Type == keyDown || runeIs(k, "j"):
if m.grantScopeIndex < len(opts)-1 {
m.grantScopeIndex++
}
case runeIs(k, "s"):
m.grantScopeIndex = 0
return m.applyGrantScope()
case runeIs(k, "p"):
m.grantScopeIndex = 1
return m.applyGrantScope()
case runeIs(k, "g"):
m.grantScopeIndex = 2
return m.applyGrantScope()
case k.Type == keyEnter:
return m.applyGrantScope()
}
return m, nil
}
// applyGrantScope creates the chosen-scope grant for the pending tool, approves the current
// call, and closes the picker. The SESSION choice is identical to the old A behaviour.
func (m Model) applyGrantScope() (tea.Model, tea.Cmd) {
p := m.grantFor
if p == nil {
m.overlay = OverlayNone
return m, nil
}
opts := grantScopeOptions()
idx := m.grantScopeIndex
if idx < 0 || idx >= len(opts) {
idx = 0
}
scope := opts[idx].wire
m.client.Send(protocol.CreateGrant(p.SessionID, scope, []string{p.Tier}, "granted via TUI ("+scope+")", p.ToolName))
m.client.Send(protocol.ApprovalResponse(p.RequestID, "APPROVE", nil))
m.appendAction(p.SessionID, "⊞", "granted "+p.ToolName+" · "+strings.ToLower(scope))
if s := m.session(p.SessionID); s != nil {
s.removeApproval(p.RequestID)
}
m.grantFor = nil
m.overlay = OverlayNone
m.steerBuffer = ""
m.steering = false
m.approvalArmed = false
m.approvalDismissed = false
return m, nil
}
// openGrants opens the standing-grants viewer and requests the active PROJECT/GLOBAL grants.
// The board is global (not session-scoped), so it opens from anywhere.
func (m *Model) openGrants() {
m.overlay = OverlayGrants
m.grantIndex = 0
m.grantsLoading = true
m.client.Send(protocol.ListGrants())
}
// toggleStatusSegment flips a status-bar segment's visibility and persists the change.
func (m *Model) toggleStatusSegment(id string) {
if m.sbHidden == nil {
m.sbHidden = map[string]bool{}
}
if m.sbHidden[id] {
delete(m.sbHidden, id)
} else {
m.sbHidden[id] = true
}
saveStatusbarHidden(m.sbHidden)
}
// sbShow reports whether a status-bar segment is currently visible.
func (m Model) sbShow(id string) bool { return !m.sbHidden[id] }
// openArtifacts opens the artifact viewer for the selected session and requests its
// artifacts from the server. No-op when no session is selected.
func (m *Model) openArtifacts() {
if m.selectedID == "" {
return
}
m.overlay = OverlayArtifacts
m.artifactsIndex = 0
m.artifactScroll = 0
// Reuse a cached listing only if it's for this session; otherwise show a loading state.
if m.artifactsFor != m.selectedID {
m.artifacts = nil
m.artifactsLoading = true
}
m.client.Send(protocol.ListArtifacts(m.selectedID))
}
// openIdeas opens the cross-session idea board and requests it from the server. The board
// is global (not session-scoped), so it opens from anywhere — including the idle list.
func (m *Model) openIdeas() {
m.overlay = OverlayIdeas
m.ideasIndex = 0
if m.ideas == nil {
m.ideasLoading = true
}
m.client.Send(protocol.ListIdeas())
}
// openStats opens the session-stats pane for the selected session and requests its
// metrics from the server. No-op when no session is selected.
func (m *Model) openStats() {
if m.selectedID == "" {
return
}
m.overlay = OverlayStats
m.modalScroll = 0
// Reuse a cached report only if it's for this session; otherwise show a loading state.
if m.statsFor != m.selectedID {
m.stats = nil
m.statsLoading = true
}
m.client.Send(protocol.GetSessionStats(m.selectedID))
}
// openModelsOverlay opens the model picker, pre-selecting the resident model.
func (m *Model) openModelsOverlay() {
m.overlay = OverlayModels
m.modelsIndex = 0
for i, id := range m.availableModels {
if id == m.currentModel {
m.modelsIndex = i
break
}
}
}
func runeIs(k keyMsg, s string) bool {
return k.Type == keyRunes && string(k.Runes) == s
}
func (m Model) handlePaletteKey(k keyMsg) (tea.Model, tea.Cmd) {
cmds := m.filteredPalette()
switch k.Type {
case keyUp:
if m.paletteIndex > 0 {
m.paletteIndex--
}
case keyDown:
if m.paletteIndex < len(cmds)-1 {
m.paletteIndex++
}
case keyEnter:
if m.paletteIndex >= 0 && m.paletteIndex < len(cmds) {
return m.execPalette(cmds[m.paletteIndex].id)
}
case keyBackspace:
if n := len(m.paletteFilter); n > 0 {
m.paletteFilter = m.paletteFilter[:n-1]
m.paletteIndex = 0
}
case keyRunes, keySpace:
m.paletteFilter += string(k.Runes)
m.paletteIndex = 0
}
return m, nil
}
// paletteCmd is one command-palette row. key is the bare-key shortcut that runs the same
// action directly (shown in the palette so the shortcuts are discoverable); empty when the
// command has no direct key.
type paletteCmd struct{ id, key, title, hint string }
func paletteCommands() []paletteCmd {
return []paletteCmd{
{"workflows", "w", "start workflow", "open the workflow picker"},
{"tools", "t", "tool palette", "tools for the current stage"},
{"models", "m", "swap model", "pick / pin the local model"},
{"events", "e", "event inspector", "browse the event stream"},
{"artifacts", "v", "view artifacts", "browse this session's artifacts"},
{"stats", "S", "session stats", "metrics for the selected session"},
{"sessions", "R", "resume session", "browse / resume recent sessions"},
{"config", "g", "edit config", "view / change correx settings"},
{"grants", "G", "grants", "view / revoke standing grants"},
{"statusbar", "", "status bar", "show / hide status-bar segments"},
{"rail", "", "idle rail", "show / hide the idle status + keys rail"},
{"panel", "d", "right panel", "in-session: events → changes → off"},
{"actions", "", "inline actions", "show / hide tool & action rows in output"},
{"help", "?", "help", "keybinding cheat-sheet"},
{"mode", "s", "toggle mode", "switch chat / steering"},
{"cancel", "c", "cancel session", "stop the selected session"},
{"back", "l", "back to list", "leave the current session"},
{"quit", "q", "quit", "exit correx"},
}
}
func (m Model) filteredPalette() []paletteCmd {
f := strings.ToLower(m.paletteFilter)
if f == "" {
return paletteCommands()
}
var out []paletteCmd
for _, c := range paletteCommands() {
if strings.Contains(strings.ToLower(c.key+" "+c.title+" "+c.hint), f) {
out = append(out, c)
}
}
return out
}
func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
m.overlay = OverlayNone
switch id {
case "workflows":
m.wfVisible = true
m.wfIndex = 0
case "tools":
m.overlay = OverlayToolPalette
case "models":
m.openModelsOverlay()
case "events":
m.openEventInspector()
case "artifacts":
m.openArtifacts()
case "stats":
m.openStats()
case "sessions":
return m, m.openSessions()
case "config":
m.openConfig()
case "grants":
m.openGrants()
case "statusbar":
m.overlay = OverlayStatusbar
m.sbIndex = 0
case "rail":
m.railHidden = !m.railHidden
case "panel":
m.cycleRightPanel()
case "actions":
m.toggleInlineActions()
case "help":
m.overlay = OverlayHelp
m.modalScroll = 0
case "mode":
m.cycleChatMode()
case "cancel":
if m.selectedID != "" {
m.client.Send(protocol.CancelSession(m.selectedID))
}
case "back":
m.sessionEntered = false
m.approvalDismissed = false
case "quit":
m.quitting = true
return m, tea.Quit
}
return m, nil
}
// --- input editing ---
func (m *Model) appendRunes(s string) {
b := m.inputBuffer
c := m.inputCursor
if c > len(b) {
c = len(b)
}
m.inputBuffer = b[:c] + s + b[c:]
m.inputCursor = c + len(s)
}
func (m *Model) backspace() {
if m.inputCursor == 0 || len(m.inputBuffer) == 0 {
return
}
c := m.inputCursor
m.inputBuffer = m.inputBuffer[:c-1] + m.inputBuffer[c:]
m.inputCursor = c - 1
}
// cycleLauncherWf advances the idle launch target: 0 = chat, 1..N = workflows[i-1], wrapping.
func (m *Model) cycleLauncherWf() {
m.launcherWf = (m.launcherWf + 1) % (len(m.workflows) + 1)
}
// cycleRightPanel advances the in-session right panel: events → changes → off → events.
func (m *Model) cycleRightPanel() {
m.rightPanel = (m.rightPanel + 1) % 3
}
func (m *Model) cycleChatMode() {
if m.chatMode == ChatModeChat {
m.chatMode = ChatModeSteering
} else {
m.chatMode = ChatModeChat
}
}
func (m *Model) clearInput() {
m.inputBuffer = ""
m.inputCursor = 0
m.historyIndex = -1
}
// --- submit (IDLE -> StartChat / StartSession, IN_SESSION -> ChatInput) ---
func (m Model) submit() (tea.Model, tea.Cmd) {
ds := m.displayState()
if m.inputMode == ModeFilter {
m.filter = m.inputBuffer
m.clearInput()
m.inputMode = ModeRouter
m.editMode = ModeNormal
return m, nil
}
text := strings.TrimSpace(m.inputBuffer)
switch ds {
case StateIdle:
// Intent line for a workflow being started.
if m.wfPendingID != "" {
id := m.wfPendingID
m.recordInput(text)
m.client.Send(protocol.StartSession(id, text))
m.wfPendingID = ""
m.wfPendingName = ""
m.clearInput()
m.inputMode = ModeRouter
m.editMode = ModeNormal
m.pendingWorkflowFocus = true
return m, nil
}
// Workflow picker selection → prompt for the request first.
if m.wfIndex >= 0 && m.wfIndex < len(m.workflows) {
m.beginIntent(m.workflows[m.wfIndex])
return m, nil
}
// Launcher: a workflow is Tab-selected → start it with the typed text as its brief
// (empty brief is allowed, like the intent flow). The server makes the session; we
// focus it when its events arrive (pendingWorkflowFocus).
if m.launcherWf > 0 && m.launcherWf <= len(m.workflows) {
wf := m.workflows[m.launcherWf-1]
m.recordInput(text)
m.client.Send(protocol.StartSession(wf.ID, text))
m.clearInput()
m.inputMode = ModeRouter
m.editMode = ModeNormal
m.pendingWorkflowFocus = true
m.launcherWf = 0
return m, nil
}
// Entering an already-selected session (blank submit).
if text == "" && m.selectedID != "" {
m.sessionEntered = true
m.clearInput()
return m, nil
}
if text == "" {
return m, nil
}
// New chat session. The client owns the id, so create the entry locally and
// focus it; the USER/ROUTER turns arrive as chat.turn events (no optimistic echo).
id := newSessionID()
s := m.ensureSession(id)
s.WorkflowID = "chat"
s.Name = "chat"
m.selectedID = id
m.sessionEntered = true
m.recordInput(text)
m.client.Send(protocol.StartChatSession(id, text))
m.clearInput()
return m, nil
case StateInSession:
if text == "" || m.selectedID == "" {
return m, nil
}
sid := m.selectedID
m.recordInput(text)
// No optimistic echo — the USER turn arrives as a chat.turn event.
m.client.Send(protocol.ChatInput(sid, text, m.chatMode))
m.clearInput()
return m, nil
}
return m, nil
}
func (m Model) decide(decision string) (tea.Model, tea.Cmd) {
s := m.session(m.selectedID)
if s == nil || s.Pending == nil {
return m, nil
}
var note *string
if strings.TrimSpace(m.steerBuffer) != "" {
n := m.steerBuffer
note = &n
}
m.client.Send(protocol.ApprovalResponse(s.Pending.RequestID, decision, note))
s.removeApproval(s.Pending.RequestID)
m.steerBuffer = ""
m.steering = false
m.editMode = ModeNormal
m.approvalArmed = false
m.approvalDismissed = false
return m, nil
}
func (m Model) submitApproval() (tea.Model, tea.Cmd) {
// Enter approves by default; steering note (if any) rides along.
return m.decide("APPROVE")
}
// --- navigation (list / history) ---
func (m *Model) navUp() {
if m.displayState() == StateInSession && m.inputMode == ModeRouter {
m.historyPrev()
return
}
if m.wfVisible {
m.wfNav(-1)
return
}
if m.displayState() == StateIdle {
m.listNav(-1)
}
}
func (m *Model) navDown() {
if m.displayState() == StateInSession && m.inputMode == ModeRouter {
m.historyNext()
return
}
if m.wfVisible {
m.wfNav(1)
return
}
if m.displayState() == StateIdle {
m.listNav(1)
}
}
func (m *Model) wfNav(dir int) {
n := len(m.workflows)
if n == 0 {
return
}
m.wfIndex = (m.wfIndex + dir + n) % n
}
func (m *Model) listNav(dir int) {
list := m.filteredSessions()
if len(list) == 0 {
return
}
idx := -1
for i, s := range list {
if s.ID == m.selectedID {
idx = i
break
}
}
// No current selection (e.g. nothing entered yet): start at the first
// session rather than wrapping off the end into an arbitrary entry.
if idx < 0 {
m.selectedID = list[0].ID
m.wfIndex = -1
return
}
n := idx + dir
if n < 0 {
n = len(list) - 1
}
if n >= len(list) {
n = 0
}
if m.selectedID != list[n].ID {
m.bgUpdates = 0
m.transcriptSel = -1 // a different session's transcript: drop the stale selection
}
m.selectedID = list[n].ID
m.wfIndex = -1
}
// recordInput appends a submitted line to the global history ring (shared across
// free-chat, the workflow-intent line, and in-session chat), deduping the immediate
// repeat and capping the ring. Called from every submit path so ↑/↓ recalls anything
// you've sent, in any input context.
func (m *Model) recordInput(text string) {
text = strings.TrimSpace(text)
if text == "" {
return
}
if n := len(m.inputHistory); n > 0 && m.inputHistory[n-1] == text {
return
}
m.inputHistory = append(m.inputHistory, text)
if len(m.inputHistory) > 100 {
m.inputHistory = m.inputHistory[len(m.inputHistory)-100:]
}
}
func (m *Model) historyPrev() {
hist := m.inputHistory
if len(hist) == 0 {
return
}
switch m.historyIndex {
case -1:
m.savedBuffer = m.inputBuffer
m.historyIndex = len(hist) - 1
case 0:
return
default:
m.historyIndex--
}
m.inputBuffer = hist[m.historyIndex]
m.inputCursor = len(m.inputBuffer)
}
func (m *Model) historyNext() {
hist := m.inputHistory
switch {
case m.historyIndex == -1:
return
case m.historyIndex == len(hist)-1:
m.historyIndex = -1
m.inputBuffer = m.savedBuffer
default:
m.historyIndex++
m.inputBuffer = hist[m.historyIndex]
}
m.inputCursor = len(m.inputBuffer)
}
func (m *Model) appendRouter(sid string, e RouterEntry) {
m.routerMessages[sid] = append(m.routerMessages[sid], e)
}
// appendAction adds an inline "external feedback" row (a tool call, write/edit, approval, or
// grant) to a session's OUTPUT transcript, interleaved with chat turns by arrival order. The
// rows are always recorded; rendering is gated on actionsHidden so toggling reveals history.
func (m *Model) appendAction(sid, icon, text string) {
if sid == "" {
return
}
m.routerMessages[sid] = append(m.routerMessages[sid], RouterEntry{Role: "action", Icon: icon, Content: text})
}
// toggleInlineActions flips the inline-action-rows visibility and persists it.
func (m *Model) toggleInlineActions() {
m.actionsHidden = !m.actionsHidden
saveInlineActionsHidden(m.actionsHidden)
}
// scrollOutput moves the OUTPUT transcript by delta rows (positive = up / back through history),
// clamped to the transcript height against the same geometry the renderer uses. A 0 delta or a
// transcript shorter than the panel is a no-op (stays tail-following).
func (m *Model) scrollOutput(delta int) {
if delta == 0 {
return
}
w, h := m.outputViewport()
rows, _ := m.buildTranscriptRows(w)
max := len(rows) - h
if max < 0 {
max = 0
}
m.outputScroll += delta
if m.outputScroll < 0 {
m.outputScroll = 0
}
if m.outputScroll > max {
m.outputScroll = max
}
}
// outputViewport mirrors View/renderMain to give the OUTPUT transcript's inner (w, h), so the
// scroll handler clamps against the exact geometry the renderer windows to.
func (m Model) outputViewport() (w, h int) {
bottomH := inputH
if m.displayState() == StateApproval {
bottomH = m.approvalBandHeight()
}
mainH := m.height - statusH - footerH - bottomH
if mainH < 3 {
mainH = 3
}
if m.narrow() {
outH := mainH * 55 / 100
if outH < 3 {
outH = 3
}
evH := mainH - outH
if evH < 3 {
evH = 3
outH = mainH - evH
}
return m.width - 4, outH - 2
}
leftW := m.width * 63 / 100
return leftW - 4, mainH - 2
}
// outputViewportPage is the PgUp/PgDn step (a panel minus one row of overlap for context).
func (m Model) outputViewportPage() int {
_, h := m.outputViewport()
if h < 2 {
return 1
}
return h - 1
}
// transcriptNavUser moves the transcript selection to the previous (dir<0) or next
// (dir>0) USER message — "go back to my previous message". From no selection, ctrl+↑
// grabs the most recent user message; ctrl+↓ past the last one drops back to tail-follow.
// Messages only ever append, so a stored index stays pointing at the same message.
func (m *Model) transcriptNavUser(dir int) {
msgs := m.routerMessages[m.selectedID]
n := len(msgs)
if n == 0 {
return
}
if dir < 0 {
start := m.transcriptSel
if start < 0 || start > n {
start = n // searching upward from the bottom
}
for i := start - 1; i >= 0; i-- {
if msgs[i].Role == "user" {
m.transcriptSel = i
return
}
}
return // already at the oldest user message
}
if m.transcriptSel < 0 {
return // already tailing the bottom
}
for i := m.transcriptSel + 1; i < n; i++ {
if msgs[i].Role == "user" {
m.transcriptSel = i
return
}
}
m.transcriptSel = -1 // past the last user message → resume tail-follow
}
// copyMessage returns a command that copies the selected message — or, with no
// selection, the latest assistant/router turn — to the system clipboard via OSC52.
// Returns nil when there is nothing to copy.
func (m Model) copyMessage() tea.Cmd {
if text := m.copyText(); strings.TrimSpace(text) != "" {
return osc52Copy(text)
}
return nil
}
// copyText is the content `y` will yank: the selected message, or — with no selection —
// the most recent user/router turn. Empty when there is nothing to copy.
func (m Model) copyText() string {
msgs := m.routerMessages[m.selectedID]
if len(msgs) == 0 {
return ""
}
if m.transcriptSel >= 0 && m.transcriptSel < len(msgs) {
return msgs[m.transcriptSel].Content
}
for i := len(msgs) - 1; i >= 0; i-- {
if r := msgs[i].Role; r == "router" || r == "user" {
return msgs[i].Content
}
}
return ""
}
// osc52Copy writes the clipboard sequence to stderr (separate from the alt-screen on
// stdout). Wrapped for tmux passthrough when running inside tmux so the copy still
// reaches the outer terminal (e.g. over SSH / Termius).
func osc52Copy(text string) tea.Cmd {
return func() tea.Msg {
seq := osc52.New(text)
if os.Getenv("TMUX") != "" {
seq = seq.Tmux()
}
fmt.Fprint(os.Stderr, seq)
return nil
}
}
// currentDiff returns the most recent tool diff in the selected transcript.
func (m Model) currentDiff() string {
if s := m.session(m.selectedID); s != nil && s.Pending != nil && s.Pending.Preview != "" {
return s.Pending.Preview
}
msgs := m.routerMessages[m.selectedID]
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == "tool" {
return msgs[i].Content
}
}
return ""
}
// openEventInspector opens the event inspector with a fresh (cleared) filter.
func (m *Model) openEventInspector() {
m.overlay = OverlayEventInspector
m.overlayEventIdx = 0
m.eventFilter = ""
m.eventFilterTyping = false
}
// currentEvents is the selected session's events, narrowed by the inspector filter (a
// case-insensitive substring of type/detail) when one is set.
func (m Model) currentEvents() []EventEntry {
s := m.session(m.selectedID)
if s == nil {
return nil
}
if m.eventFilter == "" {
return s.Events
}
q := strings.ToLower(m.eventFilter)
out := make([]EventEntry, 0, len(s.Events))
for _, e := range s.Events {
if strings.Contains(strings.ToLower(e.Type+" "+e.Detail), q) {
out = append(out, e)
}
}
return out
}