1017bfffef
Rebuild the Go TUI transcript from the event stream instead of optimistic local echoes. ChatTurnEvent now maps to ServerMessage.ChatTurn (chat.turn); the TUI auto-vivifies sessions and renders user+router turns from events. SessionStarted is de-special-cased into SessionAnnounced (still carries workflowId), dropping the router.response shadow and optimistic echoes. Delete the unused Kotlin TUI (apps/tui) and add a kotlinx<->Go golden-fixture test to lock the wire protocol. Gitignore server runtime logs.
724 lines
16 KiB
Go
724 lines
16 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 "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 == StateInSession {
|
|
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
|
m.approvalDismissed = false
|
|
}
|
|
}
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
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) {
|
|
wf := m.workflows[m.wfIndex]
|
|
m.client.Send(protocol.StartSession(wf.ID))
|
|
m.wfVisible = false
|
|
m.wfIndex = -1
|
|
m.pendingWorkflowFocus = true
|
|
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) {
|
|
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"):
|
|
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 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
|
|
}
|
|
|
|
// 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"},
|
|
{"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 "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:
|
|
// Workflow picker selection.
|
|
if m.wfIndex >= 0 && m.wfIndex < len(m.workflows) {
|
|
wf := m.workflows[m.wfIndex]
|
|
m.wfIndex = -1
|
|
m.client.Send(protocol.StartSession(wf.ID))
|
|
m.clearInput()
|
|
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
|
|
}
|
|
}
|
|
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
|
|
}
|