186 lines
5.9 KiB
Go
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
|
|
}
|