package app import ( "strings" "time" tea "github.com/charmbracelet/bubbletea" "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{} 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 and the cursor-blink tick. func (m Model) Init() tea.Cmd { return tea.Batch(readServer(m.client), readConn(m.client), tick()) } // 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++ return m, tick() case connMsg: m.applyConn(msg.s) return m, readConn(m.client) case serverMsg: m.applyServerPhased(msg.m) return m, readServer(m.client) case tea.KeyMsg: return m.handleKey(msg) } return m, nil } 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 tea.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 == tea.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) } if m.editMode == ModeInsert { return m.handleInsertKey(k) } return m.handleNormalKey(k) } // handleNormalKey processes vim-style bare-key commands. func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { ds := m.displayState() switch k.Type { case tea.KeyUp: m.navUp() return m, nil case tea.KeyDown: m.navDown() return m, nil case tea.KeyEnter: return m.normalEnter() case tea.KeyEsc: if ds == StateApproval { m.approvalDismissed = true return m, nil } m.filter = "" return m, nil case tea.KeyCtrlA: if ds == StateApproval { return m.decide("APPROVE") } return m, nil case tea.KeyCtrlR: if ds == StateApproval { return m.decide("REJECT") } return m, nil case tea.KeyCtrlX: if m.currentDiff() != "" { m.overlay = OverlayDiff m.diffScrollOffset = 0 } return m, nil } if k.Type != tea.KeyRunes { return m, nil } switch string(k.Runes) { case "i": m.enterInsert(ModeRouter) case "/": m.enterInsert(ModeFilter) case "j": m.navDown() case "k": m.navUp() case "p": m.overlay = OverlayPalette m.paletteFilter = "" m.paletteIndex = 0 case "e": m.overlay = OverlayEventInspector m.overlayEventIdx = 0 case "t": m.overlay = OverlayToolPalette case "v": m.openArtifacts() case "I": m.openIdeas() case "H": m.openHealth() case "S": m.openStats() case "g": m.openConfig() case "m": m.openModelsOverlay() case "w": if ds == StateIdle { m.wfVisible = !m.wfVisible if m.wfVisible { m.wfIndex = 0 } else { m.wfIndex = -1 } } case "l": if ds != StateIdle { m.sessionEntered = false m.approvalDismissed = false } case "s": if ds == StateApproval { m.steering = true m.editMode = ModeInsert } else { m.cycleChatMode() } case "c": if m.selectedID != "" { m.client.Send(protocol.CancelSession(m.selectedID)) } case "a": if ds == StateApproval { return m.decide("APPROVE") } if ds == StateInSession { if s := m.session(m.selectedID); s != nil && s.Pending != nil { m.approvalDismissed = false } } case "r": if ds == StateApproval { return m.decide("REJECT") } case "A": if ds == StateApproval { return m.autoApprove() } case "q": m.quitting = true return m, tea.Quit } return m, nil } // handleInsertKey processes typing (chat/filter/steer note). func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { if m.steering { switch k.Type { case tea.KeyEsc: m.steering = false m.editMode = ModeNormal case tea.KeyEnter: return m.submitApproval() case tea.KeyBackspace: if n := len(m.steerBuffer); n > 0 { m.steerBuffer = m.steerBuffer[:n-1] } case tea.KeyRunes, tea.KeySpace: m.steerBuffer += string(k.Runes) } return m, nil } switch k.Type { case tea.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 tea.KeyEnter: return m.submit() case tea.KeyBackspace: m.backspace() m.syncFilter() case tea.KeyLeft: if m.inputCursor > 0 { m.inputCursor-- } case tea.KeyRight: if m.inputCursor < len(m.inputBuffer) { m.inputCursor++ } case tea.KeyUp: if m.inputMode == ModeFilter { m.listNav(-1) } else { m.historyPrev() } case tea.KeyDown: if m.inputMode == ModeFilter { m.listNav(1) } else { m.historyNext() } case tea.KeyRunes, tea.KeySpace: m.appendRunes(string(k.Runes)) m.syncFilter() } return m, nil } // 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 } func (m Model) autoApprove() (tea.Model, tea.Cmd) { s := m.session(m.selectedID) if s == nil || s.Pending == nil { return m, nil } p := s.Pending m.client.Send(protocol.CreateGrant(p.SessionID, "SESSION", []string{p.Tier}, "auto-approved via TUI", p.ToolName)) m.client.Send(protocol.ApprovalResponse(p.RequestID, "APPROVE", nil)) s.Pending = nil m.steerBuffer = "" m.steering = false m.approvalDismissed = false return m, nil } func (m Model) handleOverlayKey(k tea.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) } if k.Type == tea.KeyEsc { m.overlay = OverlayNone return m, nil } switch m.overlay { case OverlayPalette: return m.handlePaletteKey(k) case OverlayDiff: switch { case k.Type == tea.KeyUp || runeIs(k, "k"): if m.diffScrollOffset > 0 { m.diffScrollOffset-- } case k.Type == tea.KeyDown || runeIs(k, "j"): if m.diffScrollOffset < m.diffMaxScroll() { m.diffScrollOffset++ } case k.Type == tea.KeyCtrlX: m.overlay = OverlayNone } case OverlayEventInspector: evs := m.currentEvents() switch { case k.Type == tea.KeyUp || runeIs(k, "k"): if m.overlayEventIdx > 0 { m.overlayEventIdx-- } case k.Type == tea.KeyDown || runeIs(k, "j"): if m.overlayEventIdx < len(evs)-1 { m.overlayEventIdx++ } } case OverlayToolPalette: if runeIs(k, "t") { m.overlay = OverlayNone } case OverlayArtifacts: switch { case k.Type == tea.KeyUp || runeIs(k, "k"): if m.artifactsIndex > 0 { m.artifactsIndex-- m.artifactScroll = 0 } case k.Type == tea.KeyDown || runeIs(k, "j"): if m.artifactsIndex < len(m.artifacts)-1 { m.artifactsIndex++ m.artifactScroll = 0 } case k.Type == tea.KeyPgUp: if m.artifactScroll > 0 { m.artifactScroll-- } case k.Type == tea.KeyPgDown: contentW := m.modalWidth() - 6 if m.artifactScroll < m.artifactContentMaxScroll(contentW) { m.artifactScroll++ } case runeIs(k, "v"): m.overlay = OverlayNone } case OverlayModels: switch { case k.Type == tea.KeyUp || runeIs(k, "k"): if m.modelsIndex > 0 { m.modelsIndex-- } case k.Type == tea.KeyDown || runeIs(k, "j"): if m.modelsIndex < len(m.availableModels)-1 { m.modelsIndex++ } case k.Type == tea.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: if runeIs(k, "S") { m.overlay = OverlayNone } case OverlayHealth: if runeIs(k, "H") { m.overlay = OverlayNone } case OverlayIdeas: switch { case k.Type == tea.KeyUp || runeIs(k, "k"): if m.ideasIndex > 0 { m.ideasIndex-- } case k.Type == tea.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 } } return m, nil } // 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 // 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)) } // openHealth opens the system health-checks pane and fetches the current report. // Health is system-scoped (not session-scoped), so it opens from anywhere. func (m *Model) openHealth() { m.overlay = OverlayHealth // Always re-fetch and show a loading state: health changes out from under us, so a // cached report would be stale (TUI spec §2 — render from the fetched report). m.health = nil m.healthLoading = true m.client.Send(protocol.GetHealthChecks()) } // 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 tea.KeyMsg, s string) bool { return k.Type == tea.KeyRunes && string(k.Runes) == s } func (m Model) handlePaletteKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { cmds := m.filteredPalette() switch k.Type { case tea.KeyUp: if m.paletteIndex > 0 { m.paletteIndex-- } case tea.KeyDown: if m.paletteIndex < len(cmds)-1 { m.paletteIndex++ } case tea.KeyEnter: if m.paletteIndex >= 0 && m.paletteIndex < len(cmds) { return m.execPalette(cmds[m.paletteIndex].id) } case tea.KeyBackspace: if n := len(m.paletteFilter); n > 0 { m.paletteFilter = m.paletteFilter[:n-1] m.paletteIndex = 0 } case tea.KeyRunes, tea.KeySpace: m.paletteFilter += string(k.Runes) m.paletteIndex = 0 } return m, nil } type paletteCmd struct{ id, title, hint string } func paletteCommands() []paletteCmd { return []paletteCmd{ {"workflows", "start workflow", "open the workflow picker"}, {"tools", "tool palette", "tools for the current stage"}, {"models", "swap model", "pick / pin the local model"}, {"events", "event inspector", "browse the event stream"}, {"artifacts", "view artifacts", "browse this session's artifacts"}, {"stats", "session stats", "metrics for the selected session"}, {"health", "health checks", "system health probe status"}, {"config", "edit config", "view / change correx settings"}, {"mode", "toggle mode", "switch chat / steering"}, {"cancel", "cancel session", "stop the selected session"}, {"back", "back to list", "leave the current session"}, {"quit", "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.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.overlay = OverlayEventInspector m.overlayEventIdx = 0 case "artifacts": m.openArtifacts() case "stats": m.openStats() case "health": m.openHealth() case "config": m.openConfig() 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 } 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.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 } // 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.client.Send(protocol.StartChatSession(id, text)) m.clearInput() return m, nil case StateInSession: if text == "" || m.selectedID == "" { return m, nil } sid := m.selectedID hist := m.history[sid] hist = append(hist, text) if len(hist) > 50 { hist = hist[len(hist)-50:] } m.history[sid] = hist // 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.Pending = nil m.steerBuffer = "" m.steering = false m.editMode = ModeNormal 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.selectedID = list[n].ID m.wfIndex = -1 } func (m *Model) historyPrev() { hist := m.history[m.selectedID] 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.history[m.selectedID] 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) } // 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 "" } func (m Model) currentEvents() []EventEntry { if s := m.session(m.selectedID); s != nil { return s.Events } return nil }