04d7e26482
Typing `@` at a word boundary in the chat input (mid-word `@` stays literal, e.g. emails) opens a workspace file picker: it requests file.list for the session (cached per session), filters as you type, and enter inserts `@path ` at the cursor — back to typing. Renders as a transparent overlay, windowed around the selection. file.list is classified non-rendering in the render-matrix guard. Tests cover token-boundary detection, ref insertion, and filtering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1247 lines
32 KiB
Go
1247 lines
32 KiB
Go
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.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 (T0–T2) 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
|
||
}
|
||
|
||
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
|
||
}
|
||
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
|
||
}
|
||
}
|
||
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.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)
|
||
}
|
||
|
||
// 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
|
||
}
|