| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "time" |
| 5 | |
| 6 | "reasonix/internal/control" |
| 7 | "reasonix/internal/event" |
| 8 | |
| 9 | tea "charm.land/bubbletea/v2" |
| 10 | ) |
| 11 | |
| 12 | // startTurn commits the user bubble to scrollback, resets the turn accumulator, |
| 13 | // and kicks off the controller turn. `sent` goes to the model uncomposed (the |
| 14 | // controller frames it with any plan marker); `displayed` is what the transcript |
| 15 | // shows, and `restore` is what Esc puts back while the bubble is still deferred. |
| 16 | func (m *chatTUI) startTurn(sent, displayed, restore string) tea.Cmd { |
| 17 | return m.startTurnWithRaw(sent, displayed, restore, sent) |
| 18 | } |
| 19 | |
| 20 | // startTurnWithRaw is startTurn plus an explicit unresolved user prompt. This |
| 21 | // keeps reference-expanded model input separate from the text shown/restored by |
| 22 | // the frontend. |
| 23 | func (m *chatTUI) startTurnWithRaw(sent, displayed, restore, raw string) tea.Cmd { |
| 24 | return m.startControllerTurnWithQueue(displayed, restore, raw, func(ctrl control.SessionAPI) { ctrl.SendWithRaw(sent, raw) }) |
| 25 | } |
| 26 | |
| 27 | // startControllerTurn owns the TUI-side turn setup for controller entry points. |
| 28 | // Most prompts use SendWithRaw; slash-invoked skills use SubmitDisplay so the |
| 29 | // controller can choose inline vs isolated subagent execution from the live |
| 30 | // skill's RunAs metadata without the TUI reimplementing that policy. |
| 31 | func (m *chatTUI) startControllerTurn(displayed, restore string, start func(control.SessionAPI)) tea.Cmd { |
| 32 | return m.startControllerTurnWithQueue(displayed, restore, displayed, start) |
| 33 | } |
| 34 | |
| 35 | func (m *chatTUI) startControllerTurnWithQueue(displayed, restore, queued string, start func(control.SessionAPI)) tea.Cmd { |
| 36 | return m.prepareControllerTurn(controllerTurnIntent{displayed, restore, queued, start}, false) |
| 37 | } |
| 38 | |
| 39 | func (m *chatTUI) prepareControllerTurn(intent controllerTurnIntent, settingsChecked bool) tea.Cmd { |
| 40 | displayed, restore, queued, start := intent.displayed, intent.restore, intent.queued, intent.start |
| 41 | if m.sessionReclaimed || m.takeover != nil && m.takeover.Returned() { |
| 42 | m.notice(sessionReclaimedNotice) |
| 43 | return nil |
| 44 | } |
| 45 | if m.takeover != nil && m.takeover.Reclaiming() { |
| 46 | m.notice("the remote side is taking this session back; new input is disabled") |
| 47 | return nil |
| 48 | } |
| 49 | if !settingsChecked && m.ctrl != nil && !m.ctrl.Running() { |
| 50 | if cmd, checking := m.checkTurnModelSettings(intent); checking { |
| 51 | return cmd |
| 52 | } |
| 53 | } |
| 54 | if auth, ok := m.ctrl.(interface { |
| 55 | AuthenticationState() control.AuthenticationState |
| 56 | }); ok { |
| 57 | state := auth.AuthenticationState() |
| 58 | if !state.Ready() { |
| 59 | err := &control.AuthenticationError{State: state} |
| 60 | m.notice(err.Error()) |
| 61 | if m.input.Value() == "" { |
| 62 | m.input.SetValue(restore) |
| 63 | m.growInputToFit() |
| 64 | } |
| 65 | return nil |
| 66 | } |
| 67 | } |
| 68 | // The composer can read idle while the controller already runs a |
| 69 | // dispatched queued follow-up (TurnStarted not yet ingested): queue rather |
| 70 | // than race the admission guard's silent drop (#9575). |
| 71 | if m.ctrl != nil && m.ctrl.Running() { |
| 72 | receipt, err := m.enqueueFollowup(displayed, queued) |
| 73 | if err != nil { |
| 74 | m.notice("queue: " + err.Error()) |
| 75 | if m.input.Value() == "" { |
| 76 | m.input.SetValue(restore) |
| 77 | m.growInputToFit() |
| 78 | } |
| 79 | return nil |
| 80 | } |
| 81 | m.notice("durable follow-up queued #" + shortID(receipt.ItemID) + " — will run when idle") |
| 82 | m.clearQueuedPastes(restore) |
| 83 | return nil |
| 84 | } |
| 85 | // Flush any half-streamed leftover before the new turn (defensive). |
| 86 | m.commitReasoning() |
| 87 | m.commitPending() |
| 88 | |
| 89 | // Echo the user bubble to scrollback now so it appears the instant Enter is |
| 90 | // pressed, not when the first packet lands: Esc before the reply pops it |
| 91 | // back off and restores the text, leaving nothing stranded. |
| 92 | m.pendingRestore = restore |
| 93 | m.pendingPastes = m.pasteLabelsIn(restore) |
| 94 | m.bubbleStartIdx = len(m.transcript) |
| 95 | m.commitLine("") // blank line separating turns |
| 96 | m.commitTranscriptSource(transcriptSource{ |
| 97 | kind: transcriptSourceUser, raw: displayed, planMode: m.planMode, |
| 98 | }) |
| 99 | m.bubblePending = true |
| 100 | m.turnDiscarded = false |
| 101 | |
| 102 | m.state = tuiRunning |
| 103 | m.runStart = time.Now() |
| 104 | m.elapsed = 0 |
| 105 | m.turnTokens = 0 |
| 106 | // The controller owns the run goroutine, its context, and cancellation; it |
| 107 | // streams events to eventCh and emits TurnDone when the turn settles. |
| 108 | m.noteWatchdogRunning() |
| 109 | start(m.ctrl) |
| 110 | return m.startRunningTicks() |
| 111 | } |
| 112 | |
| 113 | // confirmBubbleSent marks the already-echoed user bubble as really sent once a |
| 114 | // turn's first response packet arrives, so Esc no longer un-sends it (it cancels |
| 115 | // the stream instead). Also called defensively at turn end. A no-op once confirmed. |
| 116 | func (m *chatTUI) confirmBubbleSent() { |
| 117 | if !m.bubblePending { |
| 118 | return |
| 119 | } |
| 120 | m.bubblePending = false |
| 121 | m.pendingRestore = "" |
| 122 | } |
| 123 | |
| 124 | // drainAgentEvents ingests the events already buffered behind the first one: |
| 125 | // the producing goroutine has exited (a Cmd reads the channel once), so one |
| 126 | // re-wrap covers the whole batch instead of one per event. |
| 127 | type agentEventDrain struct { |
| 128 | turnDone, gitMaybeChanged bool |
| 129 | cmds []tea.Cmd |
| 130 | } |
| 131 | |
| 132 | func (m *chatTUI) consumeAgentEvent(e event.Event, drained *agentEventDrain) { |
| 133 | // Record before ingest so TurnDone still counts as an active heartbeat. |
| 134 | m.noteWatchdogHeartbeat(watchdogAgentSource(e.Kind)) |
| 135 | if e.Kind == event.TurnStarted { |
| 136 | m.todos = nil |
| 137 | m.todosDismissed = false |
| 138 | if cmd := m.noteControllerTurnStarted(); cmd != nil { |
| 139 | drained.cmds = append(drained.cmds, cmd) |
| 140 | } |
| 141 | } |
| 142 | m.ingestEvent(e) |
| 143 | drained.turnDone = drained.turnDone || e.Kind == event.TurnDone |
| 144 | drained.gitMaybeChanged = drained.gitMaybeChanged || e.Kind == event.ToolResult && !e.Tool.ReadOnly |
| 145 | } |
| 146 | |
| 147 | func (m *chatTUI) drainAgentEvents(first event.Event) agentEventDrain { |
| 148 | var drained agentEventDrain |
| 149 | m.consumeAgentEvent(first, &drained) |
| 150 | for range maxEventDrain { |
| 151 | select { |
| 152 | case e2 := <-m.eventCh: |
| 153 | m.consumeAgentEvent(e2, &drained) |
| 154 | default: |
| 155 | return drained |
| 156 | } |
| 157 | } |
| 158 | return drained |
| 159 | } |
| 160 | |
| 161 | // noteControllerTurnStarted enters running state for a turn the TUI did not |
| 162 | // submit itself — the controller auto-dispatching a queued follow-up. Without |
| 163 | // it the composer reads as ready while the dispatched turn streams, so an |
| 164 | // Enter races the dispatch (silently dropped, or preempting the queue) and the |
| 165 | // elapsed-tick heartbeat chain stays dead (#9575). |
| 166 | func (m *chatTUI) noteControllerTurnStarted() tea.Cmd { |
| 167 | if m.state == tuiRunning { |
| 168 | return nil |
| 169 | } |
| 170 | m.state = tuiRunning |
| 171 | m.runStart = time.Now() |
| 172 | m.elapsed = 0 |
| 173 | m.turnTokens = 0 |
| 174 | m.noteWatchdogRunning() |
| 175 | return m.startRunningTicks() |
| 176 | } |
| 177 | |
| 178 | func (m *chatTUI) startRunningTicks() tea.Cmd { |
| 179 | m.elapsedTickGeneration++ |
| 180 | return tea.Batch(m.spinner.Tick, elapsedTick(m.elapsedTickGeneration)) |
| 181 | } |
| 182 | |
| 183 | func (m *chatTUI) clearQueuedPastes(restore string) { |
| 184 | labels := m.pasteLabelsIn(restore) |
| 185 | if len(labels) == 0 { |
| 186 | return |
| 187 | } |
| 188 | queued := make(map[string]struct{}, len(labels)) |
| 189 | for _, label := range labels { |
| 190 | queued[label] = struct{}{} |
| 191 | } |
| 192 | kept := m.pastedBlocks[:0] |
| 193 | for _, block := range m.pastedBlocks { |
| 194 | if _, ok := queued[block.label]; !ok { |
| 195 | kept = append(kept, block) |
| 196 | } |
| 197 | } |
| 198 | m.pastedBlocks = kept |
| 199 | } |
| 200 |