Files
correx/apps/tui-go/internal/app/update.go
T
kami 9143d554f1 feat(tui-go): global input history + stop idle redraws (copy-safe)
Input history is now a single global ring (recordInput) shared by every input surface —
free-chat, the workflow-intent line, and in-session chat — so ↑/↓ recalls anything you've
sent, including outside a session (previously per-session, so idle/intent had none).

Copy fix: the 120ms animation tick now only runs while something actually animates
(animating(): active session spinner, blinking caret, reconnect). Init no longer starts it
unconditionally; tickKick (re)starts it on demand and tickMsg stops it when idle. An idle
screen no longer auto-redraws, so a native terminal text selection survives instead of being
wiped every frame. Tests cover the ring (dedup/cap/cross-context walk) and the idle gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 09:33:16 +00:00

1028 lines
26 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 (
"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. 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 { // the palette shows a blinking filter caret
return true
}
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.KeyMsg:
next, cmd := m.handleKey(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 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()
// 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 tea.KeyUp:
m.navUp()
return m, nil
case tea.KeyDown:
m.navDown()
return m, nil
case tea.KeyEnter:
return m.normalEnter()
case tea.KeyEsc:
m.filter = ""
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 "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.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":
m.cycleChatMode()
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 tea.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 == tea.KeyUp || runeIs(k, "k"):
s.navApproval(-1)
m.approvalArmed = false
return m, nil
case k.Type == tea.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 == tea.KeyEnter || k.Type == tea.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 == tea.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 == tea.KeyEsc:
m.approvalDismissed = true
return m, nil
case k.Type == tea.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 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.removeApproval(p.RequestID)
m.steerBuffer = ""
m.steering = false
m.approvalArmed = 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)
}
// 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 == 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 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))
}
// 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"},
{"sessions", "resume session", "browse / resume recent sessions"},
{"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 "sessions":
return m, m.openSessions()
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.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
}
// 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.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)
}
// 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
}