| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | |
| 7 | tea "charm.land/bubbletea/v2" |
| 8 | |
| 9 | "reasonix/internal/boot" |
| 10 | "reasonix/internal/config" |
| 11 | "reasonix/internal/control" |
| 12 | "reasonix/internal/event" |
| 13 | "reasonix/internal/i18n" |
| 14 | ) |
| 15 | |
| 16 | // runtimeRebuilder builds the /reload replacement controller through |
| 17 | // boot.Rebuild with the session's current model/profile/effort. cli.go |
| 18 | // supplies it because that is where the boot.Options (and the SharedHost, when |
| 19 | // one exists) live; the TUI owns only the queue/swap/close sequencing. |
| 20 | type runtimeRebuilder func(ctx context.Context, spec controllerBuildSpec, old *control.Controller) (*boot.BuildResult, error) |
| 21 | |
| 22 | // reloadDisposition is the /reload decision, extracted so the queue semantics |
| 23 | // are unit-testable without a running tea program. |
| 24 | type reloadDisposition int |
| 25 | |
| 26 | const ( |
| 27 | // reloadUnavailable means this session has no rebuild seam (headless or |
| 28 | // test surface): /reload can only report unavailable. |
| 29 | reloadUnavailable reloadDisposition = iota |
| 30 | // reloadNow means the TUI is idle: rebuild immediately. |
| 31 | reloadNow |
| 32 | // reloadQueued means a turn or a runtime switch is in flight: coalesce |
| 33 | // exactly one reload and run it from the idle drain. |
| 34 | reloadQueued |
| 35 | ) |
| 36 | |
| 37 | func (m *chatTUI) reloadDisposition() reloadDisposition { |
| 38 | if m == nil || m.ctrl == nil || m.rebuildRuntime == nil { |
| 39 | return reloadUnavailable |
| 40 | } |
| 41 | if m.modelSwitchPending || m.runtimeSwitchBusy() { |
| 42 | return reloadQueued |
| 43 | } |
| 44 | return reloadNow |
| 45 | } |
| 46 | |
| 47 | // runReloadCommand handles "/reload": rebuild the agent runtime (tools, |
| 48 | // skills, commands, hooks, MCP servers, providers) in place, keeping this |
| 49 | // session. A turn or rebuild in flight queues exactly one reload; the |
| 50 | // TurnDone drain runs it once the TUI is idle. |
| 51 | func (m *chatTUI) runReloadCommand() tea.Cmd { |
| 52 | if m == nil || m.ctrl == nil { |
| 53 | return nil |
| 54 | } |
| 55 | switch m.reloadDisposition() { |
| 56 | case reloadUnavailable: |
| 57 | m.notice(i18n.M.RuntimeRefreshUnavailable) |
| 58 | case reloadQueued: |
| 59 | // Coalesce: repeated /reload while busy keeps a single queued reload. |
| 60 | if !m.pendingReload { |
| 61 | m.pendingReload = true |
| 62 | m.notice(i18n.M.RuntimeReloadQueued) |
| 63 | } |
| 64 | case reloadNow: |
| 65 | return m.scheduleRuntimeReload() |
| 66 | } |
| 67 | return nil |
| 68 | } |
| 69 | |
| 70 | // drainQueuedRuntimeReload runs a /reload queued while the TUI was busy. It is |
| 71 | // called from the TurnDone drain and after a runtime switch settles; the busy |
| 72 | // re-check keeps the reload queued when background work outlives the turn. |
| 73 | func (m *chatTUI) drainQueuedRuntimeReload() tea.Cmd { |
| 74 | if m == nil || !m.pendingReload { |
| 75 | return nil |
| 76 | } |
| 77 | if m.reloadDisposition() != reloadNow { |
| 78 | return nil |
| 79 | } |
| 80 | return m.scheduleRuntimeReload() |
| 81 | } |
| 82 | |
| 83 | // scheduleRuntimeReload rebuilds the current controller through boot.Rebuild |
| 84 | // (same model/profile/effort; fresh extension discovery and migrated session |
| 85 | // state) and swaps it in only after the replacement is fully built. The swap |
| 86 | // and the old controller's retirement land in the shared modelSwitchMsg |
| 87 | // handler, exactly like a model switch: on failure the old controller keeps |
| 88 | // serving; on success the old one is closed at exit (closing it inside the |
| 89 | // TUI corrupts bubbletea's raw mode — see oldControllers). |
| 90 | func (m *chatTUI) scheduleRuntimeReload() tea.Cmd { |
| 91 | if m == nil || m.ctrl == nil || m.rebuildRuntime == nil { |
| 92 | return nil |
| 93 | } |
| 94 | oldCtrl, ok := m.ctrl.(*control.Controller) |
| 95 | if !ok { |
| 96 | // The real TUI always drives a *control.Controller; a SessionAPI test |
| 97 | // double has no boot.Options to rebuild from. |
| 98 | m.notice(i18n.M.RuntimeRefreshUnavailable) |
| 99 | return nil |
| 100 | } |
| 101 | m.pendingReload = false |
| 102 | if err := m.ctrl.Snapshot(); err != nil { |
| 103 | m.notice("reload: snapshot failed: " + err.Error()) |
| 104 | } |
| 105 | // Move the lease to the controller's current file before the replacement |
| 106 | // binds it for writing (mirrors scheduleCurrentControllerRebuild). Capture |
| 107 | // the path after Snapshot: a conflict retarget can move it. |
| 108 | resumePath := m.ctrl.SessionPath() |
| 109 | if err := m.rebindSessionLease(resumePath); err != nil { |
| 110 | m.notice("reload: " + sessionLeaseHeldNotice(err)) |
| 111 | return nil |
| 112 | } |
| 113 | |
| 114 | rebuild := m.rebuildRuntime |
| 115 | spec := controllerBuildSpec{ |
| 116 | ModelRef: m.modelRef, |
| 117 | // EffortOverride stays nil: the captured build options in cli.go |
| 118 | // already track the session's current effort. |
| 119 | } |
| 120 | m.modelSwitchPending = true |
| 121 | m.pendingModelSwitch = func() tea.Msg { |
| 122 | res, err := rebuild(context.Background(), spec, oldCtrl) |
| 123 | if err != nil { |
| 124 | return modelSwitchMsg{ref: spec.ModelRef, failurePrefix: "reload", err: err} |
| 125 | } |
| 126 | c := res.Controller |
| 127 | notice := i18n.M.RuntimeReloaded |
| 128 | if res.Snapshot != nil { |
| 129 | notice = fmt.Sprintf(i18n.M.RuntimeReloadedGenerationFmt, res.Snapshot.Generation()) |
| 130 | } |
| 131 | // res.Runtime is the stage-3a (always empty) runtime set; when stage 5 |
| 132 | // binds sidecar processes it must retire alongside the controller via |
| 133 | // oldControllers. |
| 134 | return modelSwitchMsg{ |
| 135 | ref: spec.ModelRef, |
| 136 | ctrl: c, |
| 137 | oldCtrl: oldCtrl, |
| 138 | label: c.Label(), |
| 139 | commands: c.Commands(), |
| 140 | skills: c.SlashSkills(), |
| 141 | host: c.Host(), |
| 142 | successNotice: notice, |
| 143 | } |
| 144 | } |
| 145 | return m.pendingModelSwitch |
| 146 | } |
| 147 | |
| 148 | // runtimeSettingChangeReady guards settings that need a controller rebuild. |
| 149 | // A nil controller is a non-interactive/test surface where persistence can |
| 150 | // still proceed without an in-session refresh. |
| 151 | func (m *chatTUI) runtimeSettingChangeReady() bool { |
| 152 | if m == nil || m.ctrl == nil { |
| 153 | return true |
| 154 | } |
| 155 | if m.buildController == nil { |
| 156 | m.notice(i18n.M.RuntimeRefreshUnavailable) |
| 157 | return false |
| 158 | } |
| 159 | if m.runtimeSwitchBusy() { |
| 160 | m.notice(i18n.M.RuntimeRefreshBusy) |
| 161 | return false |
| 162 | } |
| 163 | if m.modelSwitchPending { |
| 164 | m.notice(i18n.M.RuntimeSwitchPending) |
| 165 | return false |
| 166 | } |
| 167 | return true |
| 168 | } |
| 169 | |
| 170 | // scheduleCurrentControllerRebuild refreshes configuration-backed runtime state |
| 171 | // without changing the active model/profile. The old controller remains usable |
| 172 | // until a fully initialized replacement is ready. |
| 173 | func (m *chatTUI) scheduleCurrentControllerRebuild(reason, successNotice string) tea.Cmd { |
| 174 | if m == nil || m.ctrl == nil { |
| 175 | return nil |
| 176 | } |
| 177 | if m.buildController == nil { |
| 178 | m.notice(i18n.M.RuntimeRefreshUnavailable) |
| 179 | return nil |
| 180 | } |
| 181 | if err := m.ctrl.Snapshot(); err != nil { |
| 182 | m.notice(reason + ": snapshot failed: " + err.Error()) |
| 183 | } |
| 184 | carried := m.ctrl.History() |
| 185 | resumePath := m.ctrl.SessionPath() |
| 186 | if err := m.rebindSessionLease(resumePath); err != nil { |
| 187 | m.notice(reason + ": " + sessionLeaseHeldNotice(err)) |
| 188 | return nil |
| 189 | } |
| 190 | |
| 191 | oldCtrl := m.ctrl |
| 192 | build := m.buildController |
| 193 | ref := m.modelRef |
| 194 | m.modelSwitchPending = true |
| 195 | m.pendingModelSwitch = func() tea.Msg { |
| 196 | c, err := build(controllerBuildSpec{ |
| 197 | ModelRef: ref, |
| 198 | ToolApprovalMode: oldCtrl.ToolApprovalMode(), |
| 199 | PlanMode: oldCtrl.PlanMode(), |
| 200 | }, carried, resumePath, oldCtrl) |
| 201 | if err != nil { |
| 202 | return modelSwitchMsg{ref: ref, failurePrefix: reason, err: err} |
| 203 | } |
| 204 | return modelSwitchMsg{ |
| 205 | ref: ref, |
| 206 | ctrl: c, |
| 207 | oldCtrl: oldCtrl, |
| 208 | label: c.Label(), |
| 209 | commands: c.Commands(), |
| 210 | skills: c.SlashSkills(), |
| 211 | host: c.Host(), |
| 212 | successNotice: successNotice, |
| 213 | } |
| 214 | } |
| 215 | return m.pendingModelSwitch |
| 216 | } |
| 217 | |
| 218 | func (m *chatTUI) bindRuntimeRebuilder(maxSteps int, sink event.Sink, yolo bool, overrides *cliBuildOverrides, buildOpts func(string, int, bool, event.Sink, cliBuildOverrides) boot.Options) { |
| 219 | m.rebuildRuntime = func(ctx context.Context, spec controllerBuildSpec, old *control.Controller) (*boot.BuildResult, error) { |
| 220 | effectiveOverrides := overrides.forSelection(m.cfg, spec) |
| 221 | opts := buildOpts(spec.ModelRef, maxSteps, false, sink, effectiveOverrides) |
| 222 | var res *boot.BuildResult |
| 223 | var err error |
| 224 | if m.lastBuildResult != nil { |
| 225 | res, err = boot.RebuildFrom(ctx, m.lastBuildResult, opts) |
| 226 | } else { |
| 227 | res, err = boot.Rebuild(ctx, old, opts) |
| 228 | } |
| 229 | if err != nil { |
| 230 | return nil, err |
| 231 | } |
| 232 | overrides.Effort = effectiveOverrides.Effort |
| 233 | overrides.EffortModel = spec.ModelRef |
| 234 | m.lastBuildResult = res |
| 235 | res.Controller.EnableInteractiveApproval() |
| 236 | if yolo { |
| 237 | res.Controller.SetAutoApproveTools(true) |
| 238 | } |
| 239 | return res, nil |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | func (overrides cliBuildOverrides) forSelection(cfg *config.Config, spec controllerBuildSpec) cliBuildOverrides { |
| 244 | overrides.Effort = config.RebindSessionEffort(cfg, overrides.EffortModel, spec.ModelRef, overrides.Effort) |
| 245 | overrides.EffortModel = spec.ModelRef |
| 246 | if spec.EffortOverride != nil { |
| 247 | overrides.Effort = spec.EffortOverride |
| 248 | } |
| 249 | return overrides |
| 250 | } |
| 251 |