diff --git a/cmd/chat.go b/cmd/chat.go index 058d5f94..3d996b31 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -315,7 +315,7 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting startup.EndPhase("newChatModel:commandPalette") // Pre-warm footer connection line so ctx (e.g. 0k/1.0m) shows on first paint. - if m.session != nil && m.session.ContextWindowCached > 0 { + if m.session != nil && m.session.ContextWindowCachedValue() > 0 { m.connStatusVal = m.buildConnectionStatusPlain() m.connStatusKey = m.connStatusFingerprint() } @@ -896,11 +896,11 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateViewportContent() return m, nil } - next := nextAutonomyTier(m.session.Autonomy) - if m.session.Autonomy == 0 || autonomyTierIndex(m.session.Autonomy) < 0 { + next := nextAutonomyTier(m.session.PermSvc().Autonomy()) + if m.session.PermSvc().Autonomy() == 0 || autonomyTierIndex(m.session.PermSvc().Autonomy()) < 0 { next = DefaultContainerAutonomy } - m.session.Autonomy = next + m.session.PermSvc().SetAutonomy(next) m.invalidateConnStatus() m.messages = append(m.messages, displayMsg{role: "system", content: formatAutonomyTierMessage(next)}) m.viewDirty = true @@ -1275,17 +1275,17 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.sandbox != nil { m.containerSandbox = msg.sandbox if m.session != nil { - m.session.ContainerExecutor = msg.sandbox + m.session.SetContainerExecutor(msg.sandbox) } } if msg.ready && m.session != nil { - if m.session.Autonomy == 0 { - m.session.Autonomy = DefaultContainerAutonomy + if m.session.PermSvc().Autonomy() == 0 { + m.session.PermSvc().SetAutonomy(DefaultContainerAutonomy) } if m.phase == phaseWelcomeGate { m.sandboxReadyPending = true } else { - m.messages = append(m.messages, displayMsg{role: "system", content: formatSandboxReadyAutonomyMessage(m.session.Autonomy)}) + m.messages = append(m.messages, displayMsg{role: "system", content: formatSandboxReadyAutonomyMessage(m.session.PermSvc().Autonomy())}) } m.invalidateConnStatus() } @@ -1294,8 +1294,8 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.containerEnabled = false m.containerReady = false if m.session != nil { - m.session.ContainerRequired = false - m.session.ContainerExecutor = nil + m.session.SetContainerRequired(false) + m.session.SetContainerExecutor(nil) } m.messages = append(m.messages, displayMsg{ role: "system", diff --git a/cmd/chat_commands_session.go b/cmd/chat_commands_session.go index 17824389..e1330f41 100644 --- a/cmd/chat_commands_session.go +++ b/cmd/chat_commands_session.go @@ -200,7 +200,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string case "/fork": // If convodag is active, fork from the current head node - if m.session.ConvoDAG != nil { + if m.session.Persistence().DAG() != nil { headID := m.session.ConvoHead() if headID == "" { m.messages = append(m.messages, displayMsg{role: "error", content: "No conversation to fork from."}) @@ -415,7 +415,7 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string case "/session": info := fmt.Sprintf("Session: %s\nModel: %s/%s\nPermission mode: %s\nMessages: %d\nTools: %d\n%s", m.sessionID, m.session.Provider(), m.session.Model(), - permissionModeLabel(m.session), m.session.MessageCount(), len(m.registry.EyrieTools()), m.session.Cost.Summary()) + permissionModeLabel(m.session), m.session.MessageCount(), len(m.registry.EyrieTools()), m.session.CostValue().Summary()) m.messages = append(m.messages, displayMsg{role: "system", content: info}) return m, nil diff --git a/cmd/chat_commands_util.go b/cmd/chat_commands_util.go index 371e4640..cbb8133d 100644 --- a/cmd/chat_commands_util.go +++ b/cmd/chat_commands_util.go @@ -123,7 +123,7 @@ func (m *chatModel) mcpSummary() string { func sessionStats(sess *engine.Session, id string) string { return fmt.Sprintf("Session: %s\nMessages: %d\nModel: %s/%s\n%s", - id, sess.MessageCount(), sess.Provider(), sess.Model(), sess.Cost.Summary()) + id, sess.MessageCount(), sess.Provider(), sess.Model(), sess.CostValue().Summary()) } func hooksSummary() string { diff --git a/cmd/chat_model_test.go b/cmd/chat_model_test.go index 62a781b0..097f802a 100644 --- a/cmd/chat_model_test.go +++ b/cmd/chat_model_test.go @@ -12,7 +12,7 @@ import ( func newTestChatModel() *chatModel { sess := engine.NewSession("", "test-model", "you are helpful", nil) - sess.MaxTurns = 1 + sess.PermSvc().SetMaxTurns(1) sess.SetTestClient(engine.NewMockClientForTest()) m := &chatModel{ diff --git a/cmd/chat_status.go b/cmd/chat_status.go index 8a312eeb..7d647c57 100644 --- a/cmd/chat_status.go +++ b/cmd/chat_status.go @@ -139,7 +139,7 @@ func (m chatModel) connectionStatusParts() (gateway, model, contextLabel string) model, contextLabel = modelStatusMeta(gw, modelID) if contextLabel == "" || contextLabel == "—" || contextLabel == "0k" { if m.session != nil { - if w := m.session.ContextWindowCached; w > 0 { + if w := m.session.ContextWindowCachedValue(); w > 0 { contextLabel = formatModelTableContext(w) } else if w := m.session.ContextWindowSize(); w > 0 && w != engine.DefaultContextWindow { contextLabel = formatModelTableContext(w) @@ -149,7 +149,7 @@ func (m chatModel) connectionStatusParts() (gateway, model, contextLabel string) if w := platformContextForNativeModel(modelID); w > 0 { contextLabel = formatModelTableContext(w) if m.session != nil { - m.session.ContextWindowCached = w + m.session.SetContextWindowCached(w) m.session.EnsureAutoCompactor() } } diff --git a/cmd/chat_subcommand.go b/cmd/chat_subcommand.go index f2189961..c90f6a47 100644 --- a/cmd/chat_subcommand.go +++ b/cmd/chat_subcommand.go @@ -89,7 +89,10 @@ func NewSubcommandRegistry() *SubcommandRegistry { // all aliases are indexed. If a name is already registered, this // is a no-op (the existing entry is kept) — duplicate registration // is treated as a configuration error but doesn't panic, so test -// ordering and re-init don't blow up the binary. +// ordering and re-init don't blow up the binary. The same applies +// to alias collisions: if any of the subcommand's aliases is already +// registered (either as a primary or as another alias), registration +// is rejected (see M5 in the code review). func (r *SubcommandRegistry) Register(cmd ChatSubcommand) { if cmd == nil { return @@ -100,6 +103,11 @@ func (r *SubcommandRegistry) Register(cmd ChatSubcommand) { if _, exists := r.primary[name]; exists { return // duplicate } + for _, alias := range cmd.Aliases() { + if _, exists := r.aliasOf[alias]; exists { + return // alias collision + } + } r.primary[name] = cmd for _, alias := range cmd.Aliases() { r.aliasOf[alias] = name diff --git a/cmd/chat_subcommand_branches.go b/cmd/chat_subcommand_branches.go index abb6ea50..fae36dbc 100644 --- a/cmd/chat_subcommand_branches.go +++ b/cmd/chat_subcommand_branches.go @@ -14,7 +14,7 @@ func (b *branchesSubcommand) Aliases() []string { return nil } func (b *branchesSubcommand) Description() string { return "list conversation DAG branches" } func (b *branchesSubcommand) Usage() string { return "" } func (b *branchesSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { - if m.session.ConvoDAG == nil { + if m.session.Persistence().DAG() == nil { m.messages = append(m.messages, displayMsg{role: "system", content: "No conversation branches (DAG not active)."}) return m, nil } diff --git a/cmd/chat_subcommand_cost.go b/cmd/chat_subcommand_cost.go index b45739f9..4d5b53fb 100644 --- a/cmd/chat_subcommand_cost.go +++ b/cmd/chat_subcommand_cost.go @@ -13,7 +13,7 @@ func (c *costSubcommand) Aliases() []string { return nil } func (c *costSubcommand) Description() string { return "print session cost and token usage summary" } func (c *costSubcommand) Usage() string { return "" } func (c *costSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { - m.messages = append(m.messages, displayMsg{role: "system", content: m.session.Cost.Summary()}) + m.messages = append(m.messages, displayMsg{role: "system", content: m.session.CostValue().Summary()}) return m, nil } diff --git a/cmd/chat_subcommand_pin.go b/cmd/chat_subcommand_pin.go index da6599c5..f8bb6e3e 100644 --- a/cmd/chat_subcommand_pin.go +++ b/cmd/chat_subcommand_pin.go @@ -25,7 +25,7 @@ func (p *pinSubcommand) Handle(m *chatModel, args []string, text string) (tea.Mo n = parsed } } - m.session.PinnedMessages = n + m.session.SetPinnedMessages(n) m.messages = append(m.messages, displayMsg{role: "system", content: fmt.Sprintf("Pinned last %d messages (protected from compaction).", n)}) return m, nil } diff --git a/cmd/chat_subcommand_session.go b/cmd/chat_subcommand_session.go index f431d742..3f973862 100644 --- a/cmd/chat_subcommand_session.go +++ b/cmd/chat_subcommand_session.go @@ -24,20 +24,45 @@ func (s *sessionSubcommand) Description() string { return "session management: clear, compact, diff, recover, resume, history, quit, exit" } func (s *sessionSubcommand) Usage() string { return "" } -func (s *sessionSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { - name := "" + +// sessionSubcommandNames is the ordered list of slash names covered +// by sessionSubcommand. Exposed for tests and help rendering. +var sessionSubcommandNames = []string{"/clear", "/compact", "/diff", "/recover", "/resume", "/history", "/quit", "/exit"} + +// resolveSessionName inspects the raw slash text and returns the +// matching command name (with the leading "/"). Falls back to +// "/" when text doesn't start with a slash. +func resolveSessionName(text string, primary string) string { if len(text) > 0 && text[0] == '/' { - for _, c := range []string{"/clear", "/compact", "/diff", "/recover", "/resume", "/history", "/quit", "/exit"} { + for _, c := range sessionSubcommandNames { if len(text) >= len(c) && text[:len(c)] == c { - name = c - break + return c } } } - if name == "" { - name = "/" + s.Name() - } - return m.handleSessionCommand(name, args, text) + return "/" + primary +} + +// buildSessionParts reconstructs the full parts slice that +// handleSessionCommand expects: parts[0] is the command name, +// parts[1:] is the post-name argument list. Exposed for testing +// the dispatcher contract (see M6 in the code review). +func buildSessionParts(name string, args []string) []string { + parts := make([]string, 0, 1+len(args)) + parts = append(parts, name) + parts = append(parts, args...) + return parts +} + +func (s *sessionSubcommand) Handle(m *chatModel, args []string, text string) (tea.Model, tea.Cmd) { + name := resolveSessionName(text, s.Name()) + // handleSessionCommand expects parts[0] to be the command name (e.g. + // "/recover"), with the remaining entries being the post-name args. + // The dispatcher hands us the post-name slice; reconstruct the + // full parts slice so /recover , /resume , /tag