Files
2026-07-13 13:00:08 +08:00

186 lines
5.9 KiB
Go

package cli
import (
"fmt"
tea "charm.land/bubbletea/v2"
"reasonix/internal/config"
"reasonix/internal/i18n"
)
// runModelSubcommand handles "/model": with no argument it lists the configured
// (provider, model) refs and marks the active one; "/model <ref>" switches the
// session to that model in place, carrying the conversation across. The actual
// controller build runs asynchronously so it cannot block the TUI event loop.
func (m *chatTUI) runModelSubcommand(input string) {
args := tokenizeArgs(input) // args[0] == "/model"
if len(args) < 2 {
m.showModels()
return
}
ref := args[1]
if m.buildController == nil {
m.notice(i18n.M.ModelSwitchUnavailable)
return
}
if m.ctrl.Running() {
m.notice(i18n.M.ModelSwitchBusy)
return
}
if m.modelSwitchPending {
m.notice(i18n.M.RuntimeSwitchPending)
return
}
if ref == m.modelRef {
m.notice(fmt.Sprintf(i18n.M.ModelAlreadyOnFmt, ref))
return
}
// Persist the user's choice to the user config.toml so the next
// session starts on the same model instead of falling back to the global
// default. Mirrors the pattern used by /theme (persistTheme), /effort, and
// /language.
m.persistModel(ref)
if err := m.ctrl.Snapshot(); err != nil {
m.notice("model: snapshot failed: " + err.Error())
}
// Capture the resume path and history only after Snapshot: a snapshot
// conflict can retarget the controller to a recovery branch (or adopt the
// newer disk transcript), and a pre-snapshot capture would bind the rebuilt
// controller back to the original file, re-conflicting on every later save.
carried := m.ctrl.History()
prevPath := m.ctrl.SessionPath()
// Move the lease before the rebuilt controller binds prevPath for writing
// (AdoptHistory resumes there): after a snapshot retarget the lease still
// guards the old path, and the async build must not open an unguarded
// writer on the recovery branch.
if err := m.rebindSessionLease(prevPath); err != nil {
m.notice("model: " + sessionLeaseHeldNotice(err))
return
}
m.notice(fmt.Sprintf(i18n.M.ModelSwitchingFmt, ref))
// Capture old controller for cleanup after the async build succeeds.
oldCtrl := m.ctrl
build := m.buildController
// Fire the build off the event loop; the result arrives as a tea.Cmd.
// Both the build AND the old-controller close run in the goroutine so
// neither blocks the bubbletea event loop. The old controller's Close
// kills plugin subprocesses (incl. CodeGraph), which can disrupt the
// terminal's cancelReader if called synchronously inside Update — so it
// must happen here, before we hand the new controller back.
m.modelSwitchPending = true
m.pendingModelSwitch = func() tea.Msg {
c, err := build(controllerBuildSpec{
ModelRef: ref,
RuntimeProfile: m.runtimeProfile,
ToolApprovalMode: oldCtrl.ToolApprovalMode(),
PlanMode: oldCtrl.PlanMode(),
}, carried, prevPath)
if err != nil {
return modelSwitchMsg{ref: ref, err: err}
}
// Do NOT close the old controller here. Controller.Close() runs
// SessionEnd hooks (arbitrary shell commands) and kills plugin
// subprocesses — operations that corrupt bubbletea's terminal raw
// mode when executed from a goroutine. Instead, pass the old
// controller back in the message so the Update handler can defer
// its cleanup as a tea.Cmd that runs after the next render.
return modelSwitchMsg{
ref: ref,
ctrl: c,
oldCtrl: oldCtrl,
label: c.Label(),
commands: c.Commands(),
skills: c.SlashSkills(),
host: c.Host(),
}
}
}
// showModels lists the configured provider/model refs, marking the active one.
func (m *chatTUI) showModels() {
cfg, err := config.Load()
if err != nil {
m.notice("model: " + err.Error())
return
}
var refs []string
for i := range cfg.Providers {
p := &cfg.Providers[i]
if !p.Configured() {
continue
}
for _, model := range p.ChatModelList() {
refs = append(refs, p.Name+"/"+model)
}
}
m.commitLine(renderModels(m.width, refs, m.modelRef))
}
// persistModel writes ref (a "provider/model" string) to default_model in the
// user config.toml so the next CLI launch starts on the same
// model. The in-memory switch is always allowed to proceed regardless of the
// outcome here, but every step (rejected by validation, save failed, or
// persisted successfully) reports back to the TUI notice channel so the user
// can see whether their /model choice will survive a restart. Run before
// Snapshot/ModelSwitchingFmt so the persistence outcome shows up first in
// the notice area.
func (m *chatTUI) persistModel(ref string) {
path := config.UserConfigPath()
if path == "" {
return
}
// Serialize the load-modify-save against other in-process user-config
// editors so concurrent writers don't drop each other's fields.
unlock := config.LockUserConfigEdits()
defer unlock()
edit := config.LoadForEdit(path)
if err := edit.SetDefaultModel(ref); err != nil {
m.notice(fmt.Sprintf("model: persist refused: %v (ref=%s)", err, ref))
return
}
if err := edit.SaveTo(path); err != nil {
m.notice(fmt.Sprintf("model: persist save failed: %v (ref=%s, path=%s)", err, ref, path))
return
}
m.notice(fmt.Sprintf("model: persisted (ref=%s, path=%s)", ref, path))
}
// modelRefs returns the configured provider/model refs for slash completion.
func modelRefs() []string {
cfg, err := config.Load()
if err != nil {
return nil
}
var out []string
for i := range cfg.Providers {
p := &cfg.Providers[i]
if !p.Configured() {
continue
}
for _, model := range p.ChatModelList() {
out = append(out, p.Name+"/"+model)
}
}
return out
}
// providerNames returns the names of configured providers for slash completion.
func providerNames() []string {
cfg, err := config.Load()
if err != nil {
return nil
}
var out []string
for i := range cfg.Providers {
p := &cfg.Providers[i]
if !p.Configured() {
continue
}
out = append(out, p.Name)
}
return out
}