Files
correx/apps/tui-go/internal/app/update.go
T
kami c82a86477d fix(tui): wrap long artifact lines in viewer instead of truncating
Artifact content wider than the modal was silently cut off. Lines are now
hard-wrapped at rune boundaries to the modal content width, and scroll
clamping uses the wrapped line count so the last page stays reachable.
2026-06-10 20:54:39 +04:00

819 lines
18 KiB
Go

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.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 "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
}
}
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))
}
// 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"},
{"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 "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
}