Files
correx/apps/tui-go/internal/app/update.go
T
kami 6a06f2ead5 feat(tui-go): inline action rows in the OUTPUT transcript
Surface side-effecting ("external feedback") events in the conversation
flow, opencode-style, instead of only in the EVENTS side log.

- New RouterEntry role "action" (glyph + text) appended at arrival so it
  interleaves with chat turns by order. Rendered dim with an accent gutter
  glyph; the EVENTS panel still carries the full stream.
- Surfaced: tool start (⏵), tool done (✓ / ✎ wrote <path> (+a −b) for a
  diff), tool failed (✗), rejected (✕ blocked), approval resolved
  (⌘ approved / ✕ rejected, noting "· via grant" on a grant auto-approve),
  and grant create/revoke (⊞ / ⊟ · scope). ApprovalResolved names no tool
  on the wire, so the tool is recovered from the pending queue before the
  gate is dropped.
- Toggle: "inline actions" palette command flips actionsHidden (rows are
  always recorded, gated at render, so toggling reveals history); persisted
  in tui-prefs.json. prefs.go refactored to load/mutate/save so the new
  field and statusbarHidden no longer clobber each other.
- demo.go: "actions" preview kind.

go build / vet / test green; rendered the flow via cmd/preview.

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

1410 lines
38 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"
osc52 "github.com/aymanbagabas/go-osc52/v2"
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{}
// 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.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)
}
// 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 tea.KeyCtrlUp:
m.transcriptNavUser(-1)
return m, nil
case tea.KeyCtrlDown:
m.transcriptNavUser(1)
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 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:
// In-session, esc first drops a transcript selection (back to tail-follow);
// otherwise it clears the session-list filter.
if m.transcriptSel >= 0 {
m.transcriptSel = -1
return m, nil
}
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.openPalette()
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.openGrants()
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 "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 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:
// 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 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
}
case OverlayFiles:
files := m.filteredFiles()
switch {
case k.Type == tea.KeyUp:
if m.fileIndex > 0 {
m.fileIndex--
}
case k.Type == tea.KeyDown:
if m.fileIndex < len(files)-1 {
m.fileIndex++
}
case k.Type == tea.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 == tea.KeyBackspace:
if n := len(m.fileFilter); n > 0 {
m.fileFilter = m.fileFilter[:n-1]
m.fileIndex = 0
}
case k.Type == tea.KeyRunes || k.Type == tea.KeySpace:
m.fileFilter += string(k.Runes)
m.fileIndex = 0
}
case OverlayStatusbar:
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
if m.sbIndex > 0 {
m.sbIndex--
}
case k.Type == tea.KeyDown || runeIs(k, "j"):
if m.sbIndex < len(statusSegments)-1 {
m.sbIndex++
}
case k.Type == tea.KeySpace || k.Type == tea.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 == tea.KeyUp || runeIs(k, "k"):
if m.grantIndex > 0 {
m.grantIndex--
}
case k.Type == tea.KeyDown || runeIs(k, "j"):
if m.grantIndex < len(m.grants)-1 {
m.grantIndex++
}
case k.Type == tea.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 tea.KeyMsg) (tea.Model, tea.Cmd) {
opts := grantScopeOptions()
switch {
case k.Type == tea.KeyUp || runeIs(k, "k"):
if m.grantScopeIndex > 0 {
m.grantScopeIndex--
}
case k.Type == tea.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 == tea.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
// 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
}
// 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"},
{"actions", "", "inline actions", "show / hide tool & action rows in output"},
{"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.overlay = OverlayEventInspector
m.overlayEventIdx = 0
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 "actions":
m.toggleInlineActions()
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.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)
}
// 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 ""
}
func (m Model) currentEvents() []EventEntry {
if s := m.session(m.selectedID); s != nil {
return s.Events
}
return nil
}