| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | |
| 7 | tea "charm.land/bubbletea/v2" |
| 8 | |
| 9 | "reasonix/internal/boot" |
| 10 | "reasonix/internal/i18n" |
| 11 | ) |
| 12 | |
| 13 | type workModeOption struct { |
| 14 | name string |
| 15 | desc string |
| 16 | } |
| 17 | |
| 18 | func runtimeProfileName(profile string) string { |
| 19 | switch boot.NormalizeTokenMode(profile) { |
| 20 | case boot.TokenModeEconomy: |
| 21 | return "economy" |
| 22 | case boot.TokenModeDelivery: |
| 23 | return "delivery" |
| 24 | default: |
| 25 | return "balanced" |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | func runtimeProfileDisplay(profile string) string { |
| 30 | switch boot.NormalizeTokenMode(profile) { |
| 31 | case boot.TokenModeEconomy: |
| 32 | return i18n.M.WorkModeEconomyLabel |
| 33 | case boot.TokenModeDelivery: |
| 34 | return i18n.M.WorkModeDeliveryLabel |
| 35 | default: |
| 36 | return i18n.M.WorkModeBalancedLabel |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | func parseWorkMode(value string) (string, bool) { |
| 41 | switch strings.ToLower(strings.TrimSpace(value)) { |
| 42 | case "economy": |
| 43 | return boot.TokenModeEconomy, true |
| 44 | case "balanced", boot.TokenModeFull: |
| 45 | return boot.TokenModeFull, true |
| 46 | case "delivery": |
| 47 | return boot.TokenModeDelivery, true |
| 48 | default: |
| 49 | return "", false |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func workModeOptions() []workModeOption { |
| 54 | return []workModeOption{ |
| 55 | {name: "economy", desc: i18n.M.WorkModeEconomyDesc}, |
| 56 | {name: "balanced", desc: i18n.M.WorkModeBalancedDesc}, |
| 57 | {name: "delivery", desc: i18n.M.WorkModeDeliveryDesc}, |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | func renderWorkModes(width int, current string) string { |
| 62 | currentName := runtimeProfileName(current) |
| 63 | var b strings.Builder |
| 64 | fmt.Fprintf(&b, "%s\n", viewHeader(i18n.M.WorkModeListHeaderFmt, runtimeProfileDisplay(current))) |
| 65 | for _, option := range workModeOptions() { |
| 66 | status := "" |
| 67 | if option.name == currentName { |
| 68 | status = " " + viewStatus(i18n.M.ArgModelCurrent) |
| 69 | } |
| 70 | nameWidth := viewPadWidth(option.name, 10) |
| 71 | desc := viewCompactText(option.desc, viewBudget(width, 2+nameWidth+1+visibleWidth(status))) |
| 72 | fmt.Fprintf(&b, " %-*s %s%s\n", nameWidth, option.name, viewMeta(desc), status) |
| 73 | } |
| 74 | b.WriteString(viewHint(i18n.M.WorkModeListHint)) |
| 75 | return strings.TrimRight(b.String(), "\n") |
| 76 | } |
| 77 | |
| 78 | // runWorkModeCommand changes the runtime profile for the current TUI session. |
| 79 | // Rebuilding is asynchronous and failure-atomic: the old controller and |
| 80 | // profile remain active until a fully initialized replacement is ready. |
| 81 | func (m *chatTUI) runWorkModeCommand(input string) tea.Cmd { |
| 82 | args := tokenizeArgs(input) |
| 83 | if len(args) == 1 { |
| 84 | m.commitLine(renderWorkModes(m.width, m.runtimeProfile)) |
| 85 | return nil |
| 86 | } |
| 87 | if len(args) != 2 { |
| 88 | m.notice(i18n.M.WorkModeUsage) |
| 89 | return nil |
| 90 | } |
| 91 | target, ok := parseWorkMode(args[1]) |
| 92 | if !ok { |
| 93 | m.notice(i18n.M.WorkModeUsage) |
| 94 | return nil |
| 95 | } |
| 96 | if m.buildController == nil || m.ctrl == nil { |
| 97 | m.notice(i18n.M.WorkModeSwitchUnavailable) |
| 98 | return nil |
| 99 | } |
| 100 | if m.modelSwitchPending { |
| 101 | m.notice(i18n.M.RuntimeSwitchPending) |
| 102 | return nil |
| 103 | } |
| 104 | if m.runtimeSwitchBusy() { |
| 105 | m.notice(i18n.M.WorkModeSwitchBusy) |
| 106 | return nil |
| 107 | } |
| 108 | if boot.NormalizeTokenMode(m.runtimeProfile) == target { |
| 109 | m.notice(fmt.Sprintf(i18n.M.WorkModeAlreadyOnFmt, runtimeProfileDisplay(target))) |
| 110 | return nil |
| 111 | } |
| 112 | |
| 113 | if err := m.ctrl.Snapshot(); err != nil { |
| 114 | m.notice("work-mode: snapshot failed: " + err.Error()) |
| 115 | } |
| 116 | carried := m.ctrl.History() |
| 117 | resumePath := m.ctrl.SessionPath() |
| 118 | if err := m.rebindSessionLease(resumePath); err != nil { |
| 119 | m.notice("work-mode: " + sessionLeaseHeldNotice(err)) |
| 120 | return nil |
| 121 | } |
| 122 | |
| 123 | display := runtimeProfileDisplay(target) |
| 124 | m.notice(fmt.Sprintf(i18n.M.WorkModeSwitchingFmt, display)) |
| 125 | oldCtrl := m.ctrl |
| 126 | build := m.buildController |
| 127 | ref := m.modelRef |
| 128 | m.modelSwitchPending = true |
| 129 | m.pendingModelSwitch = func() tea.Msg { |
| 130 | c, err := build(controllerBuildSpec{ |
| 131 | ModelRef: ref, |
| 132 | RuntimeProfile: target, |
| 133 | ToolApprovalMode: oldCtrl.ToolApprovalMode(), |
| 134 | PlanMode: oldCtrl.PlanMode(), |
| 135 | }, carried, resumePath, oldCtrl) |
| 136 | if err != nil { |
| 137 | return modelSwitchMsg{ |
| 138 | ref: ref, |
| 139 | profile: target, |
| 140 | failurePrefix: "work-mode", |
| 141 | err: err, |
| 142 | } |
| 143 | } |
| 144 | return modelSwitchMsg{ |
| 145 | ref: ref, |
| 146 | profile: target, |
| 147 | ctrl: c, |
| 148 | oldCtrl: oldCtrl, |
| 149 | label: c.Label(), |
| 150 | commands: c.Commands(), |
| 151 | skills: c.SlashSkills(), |
| 152 | host: c.Host(), |
| 153 | successNotice: fmt.Sprintf(i18n.M.WorkModeSwitchedFmt, display), |
| 154 | } |
| 155 | } |
| 156 | return m.pendingModelSwitch |
| 157 | } |
| 158 | |
| 159 | func (m *chatTUI) workModeArgItems(val string) ([]compItem, int, bool) { |
| 160 | cmdEnd := strings.IndexAny(val, " \t") |
| 161 | if cmdEnd < 0 { |
| 162 | return nil, 0, false |
| 163 | } |
| 164 | cmd := val[:cmdEnd] |
| 165 | if cmd != "/work-mode" && cmd != "/profile" { |
| 166 | return nil, 0, false |
| 167 | } |
| 168 | from := strings.LastIndexAny(val, " \t") + 1 |
| 169 | if len(strings.Fields(val[:from])) != 1 { |
| 170 | return nil, from, true |
| 171 | } |
| 172 | query := strings.ToLower(val[from:]) |
| 173 | current := runtimeProfileName(m.runtimeProfile) |
| 174 | var out []compItem |
| 175 | for _, option := range workModeOptions() { |
| 176 | if query != "" && !strings.HasPrefix(option.name, query) { |
| 177 | continue |
| 178 | } |
| 179 | hint := option.desc |
| 180 | if option.name == current { |
| 181 | hint = i18n.M.ArgModelCurrent + " · " + hint |
| 182 | } |
| 183 | out = append(out, compItem{label: option.name, insert: option.name, hint: hint}) |
| 184 | } |
| 185 | return out, from, true |
| 186 | } |
| 187 |