package app import ( "fmt" "os" "sort" "strings" "time" tea "charm.land/bubbletea/v2" osc52 "github.com/aymanbagabas/go-osc52/v2" "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 { // Note: ModeInsert is intentionally NOT here — the composer caret is now a real // terminal cursor (View.Cursor) that the terminal blinks itself, so insert mode no // longer needs the frame loop. The other typing surfaces keep their drawn carets. if 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 tasksLoadedMsg: m.applyTasksLoaded(msg) return m, nil case tea.PasteMsg: // Bracketed paste (Ctrl+V / Ctrl+Shift+V / right-click): drop the clipboard text into // whichever composer is focused. Without this the paste is silently swallowed. return m.handlePaste(msg.Content), m.tickKick() case tea.KeyPressMsg: next, cmd := m.handleKey(toKeyMsg(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 sort.Slice(m.sessionList, func(i, j int) bool { return m.sessionList[i].LastActivityAt > m.sessionList[j].LastActivityAt }) 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 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 == 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 keyCtrlUp: m.transcriptNavUser(-1) return m, nil case keyCtrlDown: m.transcriptNavUser(1) return m, nil } } // On the idle launcher, Tab cycles the launch target (chat → workflows → chat) in any // edit mode, so it works whether or not the input is focused. if m.displayState() == StateIdle && k.Type == keyTab { if k.Shift { m.cycleLauncherWfBack() } else { m.cycleLauncherWf() } 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 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 keyUp: m.navUp() return m, nil case keyDown: m.navDown() return m, nil case keyEnter: return m.normalEnter() case keyEsc: // In-session, esc first drops a transcript selection or output scroll (back to // tail-follow); otherwise it clears the session-list filter. if m.transcriptSel >= 0 { m.transcriptSel = -1 return m, nil } if m.outputScroll != 0 { m.outputScroll = 0 return m, nil } m.filter = "" return m, nil case keyPgUp: m.scrollOutput(m.outputViewportPage()) return m, nil case keyPgDown: m.scrollOutput(-m.outputViewportPage()) return m, nil case keyCtrlU: m.scrollOutput(m.outputViewportPage() / 2) return m, nil case keyCtrlD: m.scrollOutput(-m.outputViewportPage() / 2) return m, nil case keyCtrlX: if m.currentDiff() != "" { m.overlay = OverlayDiff m.diffScrollOffset = 0 } return m, nil } if k.Type != keyRunes { return m, nil } switch string(k.Runes) { case "i": m.enterInsert(ModeRouter) case "?": m.overlay = OverlayHelp m.modalScroll = 0 case "/": m.enterInsert(ModeFilter) case "j": m.navDown() case "k": m.navUp() case "p": m.openPalette() case "e": m.openEventInspector() case "t": m.overlay = OverlayToolPalette case "v": m.openArtifacts() case "I": m.openIdeas() case "H": m.openHealth() 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 "T": // Task board: all tasks across projects over HTTP (GET /tasks). return m, m.openTasks() case "S": m.openStats() case "G": m.openGrants() case "g": m.openConfig() case "P": m.openProjectProfile() case "O": m.openOperatorProfile() case "m": m.openModelsOverlay() case "w": // Launcher: w is an alias for Tab — cycle the launch target (chat / workflow…). if ds == StateIdle { m.cycleLauncherWf() } case "d": // In-session: cycle the right panel (events → changes → off). if ds == StateInSession { m.cycleRightPanel() } 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 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 == keyUp || runeIs(k, "k"): s.navApproval(-1) m.approvalArmed = false return m, nil case k.Type == 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 == keyEnter || k.Type == 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 == 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 == keyEsc: m.approvalDismissed = true return m, nil case k.Type == 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 keyMsg) (tea.Model, tea.Cmd) { if m.steering { switch k.Type { case keyEsc: m.steering = false m.editMode = ModeNormal case keyEnter: return m.submitApproval() case keyBackspace: if n := len(m.steerBuffer); n > 0 { m.steerBuffer = m.steerBuffer[:n-1] } case keyRunes, keySpace: m.steerBuffer += string(k.Runes) } return m, nil } switch k.Type { case 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 keyEnter: // Shift+Enter (and Alt+Enter) insert a newline; a bare Enter submits. Shift+Enter // is now distinguishable thanks to bubbletea v2's kitty keyboard disambiguation; // Ctrl+J below stays as a fallback for terminals without kitty support. if (k.Shift || k.Alt) && m.inputMode != ModeFilter { m.appendRunes("\n") return m, nil } return m.submit() case keyCtrlJ: // Reliable "newline without sending" (Ctrl+J is the literal LF key) for the // single-line filter excluded. if m.inputMode != ModeFilter { m.appendRunes("\n") } return m, nil case keyBackspace: if k.Alt { m.deleteWordBack() } else { m.backspace() } m.syncFilter() case keyLeft: if m.inputCursor > 0 { m.inputCursor-- } case keyRight: if m.inputCursor < len(m.inputBuffer) { m.inputCursor++ } case keyUp: if m.inputMode == ModeFilter { m.listNav(-1) } else { m.historyPrev() } case keyDown: if m.inputMode == ModeFilter { m.listNav(1) } else { m.historyNext() } case keyRunes, 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 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) } // Project/operator profile overlays own all their keys, same reason as config. if m.overlay == OverlayProjectProfile { return m.handleProjectProfileKey(k) } if m.overlay == OverlayOperatorProfile { return m.handleOperatorProfileKey(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) } // Tasks overlay owns all its keys: r refresh emits a cmd and enter toggles a detail // pane, so it can't share the generic no-cmd esc path below. if m.overlay == OverlayTasks { return m.handleTasksKey(k) } if k.Type == keyEsc { // In the event inspector, esc first stops an in-progress filter edit (keeping the // query) rather than closing the overlay. if m.overlay == OverlayEventInspector && m.eventFilterTyping { m.eventFilterTyping = false return m, nil } m.overlay = OverlayNone return m, nil } switch m.overlay { case OverlayPalette: return m.handlePaletteKey(k) case OverlayDiff: // A long diff/preview is unreadable one line at a time, so the fullscreen // viewer pages like the OUTPUT transcript: ↑↓/jk by line, PgUp/PgDn by a // full body, ^u/^d by half, g/G to the ends. All clamp to the diff bounds. page := m.diffBodyHeight() switch { case k.Type == keyUp || runeIs(k, "k"): m.scrollDiff(-1) case k.Type == keyDown || runeIs(k, "j"): m.scrollDiff(1) case k.Type == keyPgUp: m.scrollDiff(-page) case k.Type == keyPgDown: m.scrollDiff(page) case k.Type == keyCtrlU: m.scrollDiff(-page / 2) case k.Type == keyCtrlD: m.scrollDiff(page / 2) case runeIs(k, "g"): m.diffScrollOffset = 0 case runeIs(k, "G"): m.diffScrollOffset = m.diffMaxScroll() case k.Type == keyCtrlX: m.overlay = OverlayNone } case OverlayEventInspector: evs := m.currentEvents() if m.eventFilterTyping { switch { case k.Type == keyEnter: m.eventFilterTyping = false case k.Type == keyBackspace: if n := len(m.eventFilter); n > 0 { m.eventFilter = m.eventFilter[:n-1] m.overlayEventIdx = 0 } case k.Type == keyRunes || k.Type == keySpace: m.eventFilter += string(k.Runes) m.overlayEventIdx = 0 } return m, nil } switch { case runeIs(k, "/"): m.eventFilterTyping = true case runeIs(k, "c"): m.eventFilter = "" m.overlayEventIdx = 0 case k.Type == keyUp || runeIs(k, "k"): if m.overlayEventIdx > 0 { m.overlayEventIdx-- } case k.Type == keyDown || runeIs(k, "j"): if m.overlayEventIdx < len(evs)-1 { m.overlayEventIdx++ } } case OverlayHelp: // The cheat-sheet scrolls when it's taller than the screen; any non-scroll key // dismisses it (esc handled above), keeping the quick-glance feel. page := m.modalBodyRows() switch { case k.Type == keyUp || runeIs(k, "k"): m.scrollModal(-1) case k.Type == keyDown || runeIs(k, "j"): m.scrollModal(1) case k.Type == keyPgUp: m.scrollModal(-page) case k.Type == keyPgDown: m.scrollModal(page) case k.Type == keyCtrlU: m.scrollModal(-page / 2) case k.Type == keyCtrlD: m.scrollModal(page / 2) case runeIs(k, "g"): m.modalScroll = 0 case runeIs(k, "G"): m.modalScroll = m.scrollableModalMax(len(m.activeModalBody())) default: m.overlay = OverlayNone } case OverlayToolPalette: if runeIs(k, "t") { m.overlay = OverlayNone } case OverlayArtifacts: switch { case k.Type == keyUp || runeIs(k, "k"): if m.artifactsIndex > 0 { m.artifactsIndex-- m.artifactScroll = 0 } case k.Type == keyDown || runeIs(k, "j"): if m.artifactsIndex < len(m.artifacts)-1 { m.artifactsIndex++ m.artifactScroll = 0 } case k.Type == keyPgUp: m.scrollArtifact(-m.artifactContentBodyH()) case k.Type == keyPgDown: m.scrollArtifact(m.artifactContentBodyH()) case k.Type == keyCtrlU: m.scrollArtifact(-m.artifactContentBodyH() / 2) case k.Type == keyCtrlD: m.scrollArtifact(m.artifactContentBodyH() / 2) case runeIs(k, "g"): m.artifactScroll = 0 case runeIs(k, "G"): m.artifactScroll = m.artifactContentMaxScroll(m.modalWidth() - 6) case runeIs(k, "v"): m.overlay = OverlayNone } case OverlayModels: switch { case k.Type == keyUp || runeIs(k, "k"): if m.modelsIndex > 0 { m.modelsIndex-- } case k.Type == keyDown || runeIs(k, "j"): if m.modelsIndex < len(m.availableModels)-1 { m.modelsIndex++ } case k.Type == 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: page := m.modalBodyRows() switch { case k.Type == keyUp || runeIs(k, "k"): m.scrollModal(-1) case k.Type == keyDown || runeIs(k, "j"): m.scrollModal(1) case k.Type == keyPgUp: m.scrollModal(-page) case k.Type == keyPgDown: m.scrollModal(page) case k.Type == keyCtrlU: m.scrollModal(-page / 2) case k.Type == keyCtrlD: m.scrollModal(page / 2) case runeIs(k, "g"): m.modalScroll = 0 case runeIs(k, "G"): m.modalScroll = m.scrollableModalMax(len(m.activeModalBody())) case runeIs(k, "S"): m.overlay = OverlayNone } case OverlayHealth: if runeIs(k, "H") { m.overlay = OverlayNone } case OverlayIdeas: switch { case k.Type == keyUp || runeIs(k, "k"): if m.ideasIndex > 0 { m.ideasIndex-- } case k.Type == 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 == keyUp: if m.fileIndex > 0 { m.fileIndex-- } case k.Type == keyDown: if m.fileIndex < len(files)-1 { m.fileIndex++ } case k.Type == 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 == keyBackspace: if n := len(m.fileFilter); n > 0 { m.fileFilter = m.fileFilter[:n-1] m.fileIndex = 0 } case k.Type == keyRunes || k.Type == keySpace: m.fileFilter += string(k.Runes) m.fileIndex = 0 } case OverlayStatusbar: switch { case k.Type == keyUp || runeIs(k, "k"): if m.sbIndex > 0 { m.sbIndex-- } case k.Type == keyDown || runeIs(k, "j"): if m.sbIndex < len(statusSegments)-1 { m.sbIndex++ } case k.Type == keySpace || k.Type == 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 == keyUp || runeIs(k, "k"): if m.grantIndex > 0 { m.grantIndex-- } case k.Type == keyDown || runeIs(k, "j"): if m.grantIndex < len(m.grants)-1 { m.grantIndex++ } case k.Type == 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 keyMsg) (tea.Model, tea.Cmd) { opts := grantScopeOptions() switch { case k.Type == keyUp || runeIs(k, "k"): if m.grantScopeIndex > 0 { m.grantScopeIndex-- } case k.Type == 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 == 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 m.modalScroll = 0 // 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)) } // openHealth opens the system health-checks pane and fetches the current report. // Health is system-scoped (not session-scoped), so it opens from anywhere. func (m *Model) openHealth() { m.overlay = OverlayHealth // Always re-fetch and show a loading state: health changes out from under us, so a // cached report would be stale (TUI spec §2 — render from the fetched report). m.health = nil m.healthLoading = true m.client.Send(protocol.GetHealthChecks()) } // 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 keyMsg, s string) bool { return k.Type == keyRunes && string(k.Runes) == s } func (m Model) handlePaletteKey(k keyMsg) (tea.Model, tea.Cmd) { cmds := m.filteredPalette() switch k.Type { case keyUp: if m.paletteIndex > 0 { m.paletteIndex-- } case keyDown: if m.paletteIndex < len(cmds)-1 { m.paletteIndex++ } case keyEnter: if m.paletteIndex >= 0 && m.paletteIndex < len(cmds) { return m.execPalette(cmds[m.paletteIndex].id) } case keyBackspace: if n := len(m.paletteFilter); n > 0 { m.paletteFilter = m.paletteFilter[:n-1] m.paletteIndex = 0 } case keyRunes, 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"}, {"health", "H", "health checks", "system health probe status"}, {"sessions", "R", "resume session", "browse / resume recent sessions"}, {"tasks", "T", "task board", "browse tasks across projects"}, {"config", "g", "edit config", "view / change correx settings"}, {"grants", "G", "grants", "view / revoke standing grants"}, {"statusbar", "", "status bar", "show / hide status-bar segments"}, {"rail", "", "idle rail", "show / hide the idle status + keys rail"}, {"panel", "d", "right panel", "in-session: events → changes → off"}, {"actions", "", "inline actions", "show / hide tool & action rows in output"}, {"thinking", "", "thinking blocks", "show / hide model reasoning in output"}, {"help", "?", "help", "keybinding cheat-sheet"}, {"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.openEventInspector() case "artifacts": m.openArtifacts() case "stats": m.openStats() case "health": m.openHealth() case "sessions": return m, m.openSessions() case "tasks": return m, m.openTasks() case "config": m.openConfig() case "grants": m.openGrants() case "statusbar": m.overlay = OverlayStatusbar m.sbIndex = 0 case "rail": m.railHidden = !m.railHidden case "panel": m.cycleRightPanel() case "actions": m.toggleInlineActions() case "thinking": m.thinkingShown = !m.thinkingShown saveThinkingShown(m.thinkingShown) case "help": m.overlay = OverlayHelp m.modalScroll = 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 --- // handlePaste inserts pasted clipboard text into the focused composer (steering note or the // main input). Newlines are preserved — the input buffer is multi-line and editorLines wraps it. func (m Model) handlePaste(s string) tea.Model { switch { case s == "": case m.steering: m.steerBuffer += s case m.editMode == ModeInsert && m.overlay == OverlayNone: m.appendRunes(s) m.syncFilter() } return m } 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 } // deleteWordBack removes the word before the cursor (Alt+Backspace): first any run of // spaces, then the run of non-space chars, all left of the cursor. func (m *Model) deleteWordBack() { c := m.inputCursor if c > len(m.inputBuffer) { c = len(m.inputBuffer) } i := c for i > 0 && m.inputBuffer[i-1] == ' ' { i-- } for i > 0 && m.inputBuffer[i-1] != ' ' { i-- } m.inputBuffer = m.inputBuffer[:i] + m.inputBuffer[c:] m.inputCursor = i } // cycleLauncherWf advances the idle launch target: 0 = chat, 1..N = workflows[i-1], wrapping. func (m *Model) cycleLauncherWf() { m.launcherWf = (m.launcherWf + 1) % (len(m.workflows) + 1) } // cycleLauncherWfBack is cycleLauncherWf in reverse, for Shift+Tab. func (m *Model) cycleLauncherWfBack() { n := len(m.workflows) + 1 m.launcherWf = (m.launcherWf - 1 + n) % n } // cycleRightPanel advances the in-session right panel: events → changes → off → events. func (m *Model) cycleRightPanel() { m.rightPanel = (m.rightPanel + 1) % 3 } 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 } // Launcher: a workflow is Tab-selected → start it with the typed text as its brief // (empty brief is allowed, like the intent flow). The server makes the session; we // focus it when its events arrive (pendingWorkflowFocus). if m.launcherWf > 0 && m.launcherWf <= len(m.workflows) { wf := m.workflows[m.launcherWf-1] m.recordInput(text) m.client.Send(protocol.StartSession(wf.ID, text)) m.clearInput() m.inputMode = ModeRouter m.editMode = ModeNormal m.pendingWorkflowFocus = true m.launcherWf = 0 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) } // scrollOutput moves the OUTPUT transcript by delta rows (positive = up / back through history), // clamped to the transcript height against the same geometry the renderer uses. A 0 delta or a // transcript shorter than the panel is a no-op (stays tail-following). func (m *Model) scrollOutput(delta int) { if delta == 0 { return } w, h := m.outputViewport() rows, _ := m.buildTranscriptRows(w) max := len(rows) - h if max < 0 { max = 0 } m.outputScroll += delta if m.outputScroll < 0 { m.outputScroll = 0 } if m.outputScroll > max { m.outputScroll = max } } // outputViewport mirrors View/renderMain to give the OUTPUT transcript's inner (w, h), so the // scroll handler clamps against the exact geometry the renderer windows to. func (m Model) outputViewport() (w, h int) { bottomH := inputH if m.displayState() == StateApproval { bottomH = m.approvalBandHeight() } mainH := m.height - statusH - footerH - bottomH if mainH < 3 { mainH = 3 } if m.narrow() { outH := mainH * 55 / 100 if outH < 3 { outH = 3 } evH := mainH - outH if evH < 3 { evH = 3 outH = mainH - evH } return m.width - 4, outH - 2 } leftW := m.width * 63 / 100 return leftW - 4, mainH - 2 } // outputViewportPage is the PgUp/PgDn step (a panel minus one row of overlap for context). func (m Model) outputViewportPage() int { _, h := m.outputViewport() if h < 2 { return 1 } return h - 1 } // 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 "" } // openEventInspector opens the event inspector with a fresh (cleared) filter. func (m *Model) openEventInspector() { m.overlay = OverlayEventInspector m.overlayEventIdx = 0 m.eventFilter = "" m.eventFilterTyping = false } // currentEvents is the selected session's events, narrowed by the inspector filter (a // case-insensitive substring of type/detail) when one is set. func (m Model) currentEvents() []EventEntry { s := m.session(m.selectedID) if s == nil { return nil } if m.eventFilter == "" { return s.Events } q := strings.ToLower(m.eventFilter) out := make([]EventEntry, 0, len(s.Events)) for _, e := range s.Events { if strings.Contains(strings.ToLower(e.Type+" "+e.Detail), q) { out = append(out, e) } } return out }