Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,14 @@ task push-image # Build and push multi-platform

5. **Telemetry adds overhead**: Disable with `TELEMETRY_ENABLED=false` for benchmarking

### TUI General Guidelines

- Never use commands to send messages when you can directly mutate children or state.
- Keep things simple; do not overcomplicate.
- Create files if needed to separate logic; do not nest models.
- Never do IO or expensive work in `Update`; always use a `tea.Cmd`.
- Never change the model state inside of a command use messages and than update the state in the main loop

## Quick Reference: Key Files

| File | Purpose |
Expand Down
50 changes: 43 additions & 7 deletions pkg/tui/components/sidebar/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ type Model interface {
SetTokenUsage(event *runtime.TokenUsageEvent)
SetTodos(result *tools.ToolCallResult) error
SetMode(mode Mode)
SetAgentInfo(agentName, model, description string)
SetAgentInfo(agentName, model, description string) tea.Cmd
SetTeamInfo(availableAgents []runtime.AgentDetails)
SetAgentSwitching(switching bool)
SetToolsetInfo(availableTools int, loading bool)
Expand Down Expand Up @@ -93,6 +93,8 @@ type Model interface {
ScrollByWheel(delta int)
// IsScrollbarDragging returns true when the scrollbar thumb is being dragged.
IsScrollbarDragging() bool
// Cleanup cancels any in-flight async operations.
Cleanup()
}

// ragIndexingState tracks per-strategy indexing progress
Expand Down Expand Up @@ -143,6 +145,8 @@ type model struct {
titleInput textinput.Model
lastTitleClickTime time.Time // for double-click detection on title

cancelReasoningCheck context.CancelFunc // cancels the in-flight ModelSupportsReasoning call

// Render cache to avoid re-rendering sections on every frame during scroll
cachedLines []string // Cached rendered lines
cachedWidth int // Width used for cached render
Expand Down Expand Up @@ -248,14 +252,31 @@ func (m *model) SetTodos(result *tools.ToolCallResult) error {
return m.todoComp.SetTodos(result)
}

// reasoningSupportResultMsg carries the async result of a ModelSupportsReasoning check.
type reasoningSupportResultMsg struct {
modelID string
supported bool
}

// checkReasoningSupportCmd returns a tea.Cmd that checks reasoning support asynchronously.
func checkReasoningSupportCmd(ctx context.Context, modelID string) tea.Cmd {
return func() tea.Msg {
supported := modelsdev.ModelSupportsReasoning(ctx, modelID)
return reasoningSupportResultMsg{modelID: modelID, supported: supported}
}
}

// SetAgentInfo sets the current agent information and updates the model in availableAgents
func (m *model) SetAgentInfo(agentName, modelID, description string) {
func (m *model) SetAgentInfo(agentName, modelID, description string) tea.Cmd {
m.currentAgent = agentName
m.agentModel = modelID
m.agentDescription = description
// TODO: this can block for up to 30s on the first call if the cache is cold,
// which freezes the TUI. Move to an async command.
m.reasoningSupported = modelsdev.ModelSupportsReasoning(context.TODO(), modelID)

if m.cancelReasoningCheck != nil {
m.cancelReasoningCheck()
}
ctx, cancel := context.WithCancel(context.Background())
m.cancelReasoningCheck = cancel

// Update the provider and model in availableAgents for the current agent.
// This is important when fallback models from different providers are used.
Expand All @@ -274,6 +295,7 @@ func (m *model) SetAgentInfo(agentName, modelID, description string) {
}
}
m.invalidateCache()
return checkReasoningSupportCmd(ctx, modelID)
}

// SetTeamInfo sets the available agents in the team
Expand Down Expand Up @@ -596,9 +618,15 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
m.invalidateCache()
m.stopSpinner() // Will only stop if no other state needs it
return m, nil
case *runtime.AgentInfoEvent:
m.SetAgentInfo(msg.AgentName, msg.Model, msg.Description)
case reasoningSupportResultMsg:
if msg.modelID == m.agentModel {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale reasoning support status displayed when switching agents

The guard if msg.modelID == m.agentModel correctly prevents stale async results from updating the wrong agent's state. However, when SetAgentInfo is called with a new agent, the reasoningSupported flag retains the value from the previous agent until the new async check completes.

Scenario:

  1. Agent A does not support reasoning → reasoningSupported = false
  2. Switch to Agent B (which supports reasoning)
  3. Until the async check completes, the UI shows reasoning as not supported (stale value from Agent A)
  4. The initial default is true (fail-open), so we should reset to this default

Suggested fix: Reset reasoningSupported to the fail-open default when setting a new agent:

func (m *model) SetAgentInfo(agentName, modelID, description string) tea.Cmd {
    m.currentAgent = agentName
    m.agentModel = modelID
    m.agentDescription = description
    m.reasoningSupported = true // Reset to fail-open default

    if m.cancelReasoningCheck != nil {
        m.cancelReasoningCheck()
    }
    ctx, cancel := context.WithCancel(context.Background())
    m.cancelReasoningCheck = cancel
    // ... rest of function
}
``` <!-- cagent-review -->

m.reasoningSupported = msg.supported
m.invalidateCache()
}
return m, nil
case *runtime.AgentInfoEvent:
cmd := m.SetAgentInfo(msg.AgentName, msg.Model, msg.Description)
return m, cmd
case *runtime.TeamInfoEvent:
m.SetTeamInfo(msg.AvailableAgents)
return m, nil
Expand Down Expand Up @@ -1348,3 +1376,11 @@ func (m *model) UpdateTitleInput(msg tea.Msg) tea.Cmd {
m.invalidateCache() // Input changes affect rendering
return cmd
}

// Cleanup cancels any in-flight async operations.
func (m *model) Cleanup() {
if m.cancelReasoningCheck != nil {
m.cancelReasoningCheck()
m.cancelReasoningCheck = nil
}
}
27 changes: 18 additions & 9 deletions pkg/tui/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,32 +328,41 @@ func (a *appModel) handleToggleYolo() (tea.Model, tea.Cmd) {
}

func (a *appModel) handleToggleThinking() (tea.Model, tea.Cmd) {
// Check if the current model supports reasoning
if a.cancelThinkingCheck != nil {
a.cancelThinkingCheck()
}
ctx, cancel := context.WithCancel(context.Background())
a.cancelThinkingCheck = cancel

currentModel := a.application.CurrentAgentModel()
// TODO: this can block for up to 30s on the first call if the cache is cold,
// which freezes the TUI. Move to an async command.
if !modelsdev.ModelSupportsReasoning(context.TODO(), currentModel) {
return a, func() tea.Msg {
supported := modelsdev.ModelSupportsReasoning(ctx, currentModel)
return messages.ToggleThinkingResultMsg{Supported: supported}
}
}

func (a *appModel) handleToggleThinkingResult(msg messages.ToggleThinkingResultMsg) (tea.Model, tea.Cmd) {
if !msg.Supported {
return a, notification.InfoCmd("Thinking/reasoning is not supported for the current model")
}

sess := a.application.Session()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Session state race condition

The handleToggleThinkingResult function gets the current session without verifying it's the same session that initiated the thinking toggle. If a user:

  1. Toggles thinking on Session A
  2. Quickly switches to Session B before the async check completes
  3. The async result arrives and toggles thinking on Session B instead of Session A

This causes the thinking flag to be applied to the wrong session.

Suggested fix: Capture the session ID when starting the async check and include it in ToggleThinkingResultMsg. Then verify the session ID matches before applying the toggle:

func (a *appModel) handleToggleThinking() (tea.Model, tea.Cmd) {
    // ...
    sessionID := a.application.Session().ID
    return a, func() tea.Msg {
        supported := modelsdev.ModelSupportsReasoning(ctx, currentModel)
        return messages.ToggleThinkingResultMsg{
            Supported: supported,
            SessionID: sessionID,
        }
    }
}

func (a *appModel) handleToggleThinkingResult(msg messages.ToggleThinkingResultMsg) (tea.Model, tea.Cmd) {
    sess := a.application.Session()
    if sess.ID != msg.SessionID {
        return a, nil // Session changed, discard result
    }
    // ... rest of logic
}
``` <!-- cagent-review -->

sess.Thinking = !sess.Thinking
a.sessionState.SetThinking(sess.Thinking)

// Persist the change to the database immediately
if store := a.application.SessionStore(); store != nil {
if err := store.UpdateSession(context.Background(), sess); err != nil {
return a, notification.ErrorCmd(fmt.Sprintf("Failed to save session: %v", err))
}
}

var msg string
var infoMsg string
if sess.Thinking {
msg = "Thinking/reasoning enabled for this session"
infoMsg = "Thinking/reasoning enabled for this session"
} else {
msg = "Thinking/reasoning disabled for this session"
infoMsg = "Thinking/reasoning disabled for this session"
}
return a, notification.InfoCmd(msg)
return a, notification.InfoCmd(infoMsg)
}

func (a *appModel) handleToggleHideToolResults() (tea.Model, tea.Cmd) {
Expand Down
6 changes: 6 additions & 0 deletions pkg/tui/messages/toggle.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ type (
// ToggleThinkingMsg toggles extended thinking mode.
ToggleThinkingMsg struct{}

// ToggleThinkingResultMsg carries the async result of reasoning support check.
// If Supported is true, thinking is toggled; otherwise a notification is shown.
ToggleThinkingResultMsg struct {
Supported bool
}

// ToggleHideToolResultsMsg toggles hiding of tool results.
ToggleHideToolResultsMsg struct{}

Expand Down
1 change: 1 addition & 0 deletions pkg/tui/page/chat/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,7 @@ func (p *chatPage) CompactSession(additionalPrompt string) tea.Cmd {

func (p *chatPage) Cleanup() {
p.stopProgressBar()
p.sidebar.Cleanup()
p.editor.Cleanup()
}

Expand Down
8 changes: 4 additions & 4 deletions pkg/tui/page/chat/runtime_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@ func (p *chatPage) handleRuntimeEvent(msg tea.Msg) (bool, tea.Cmd) {

case *runtime.ModelFallbackEvent:
// Update sidebar with the fallback model immediately so it reflects the switch
p.sidebar.SetAgentInfo(msg.AgentName, msg.FallbackModel, "")
sidebarCmd := p.sidebar.SetAgentInfo(msg.AgentName, msg.FallbackModel, "")
// Notify user when switching to a fallback model, include the reason
fallbackMsg := fmt.Sprintf("Model %s failed (%s), switching to %s", msg.FailedModel, msg.Reason, msg.FallbackModel)
return true, notification.WarningCmd(fallbackMsg)
return true, tea.Batch(sidebarCmd, notification.WarningCmd(fallbackMsg))

// ===== Stream Lifecycle Events =====
case *runtime.StreamStartedEvent:
Expand Down Expand Up @@ -102,9 +102,9 @@ func (p *chatPage) handleRuntimeEvent(msg tea.Msg) (bool, tea.Cmd) {
return true, nil

case *runtime.AgentInfoEvent:
p.sidebar.SetAgentInfo(msg.AgentName, msg.Model, msg.Description)
sidebarCmd := p.sidebar.SetAgentInfo(msg.AgentName, msg.Model, msg.Description)
p.messages.AddWelcomeMessage(msg.WelcomeMessage)
return true, nil
return true, sidebarCmd

case *runtime.TeamInfoEvent:
p.sidebar.SetTeamInfo(msg.AvailableAgents)
Expand Down
21 changes: 17 additions & 4 deletions pkg/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ type appModel struct {

transcriber *transcribe.Transcriber

cancelThinkingCheck context.CancelFunc // cancels the in-flight thinking toggle check

// External event subscriptions (Elm Architecture pattern)
themeWatcher *styles.ThemeWatcher
themeSubscription *subscription.ChannelSubscription[string] // Listens for theme file changes
Expand Down Expand Up @@ -160,14 +162,22 @@ func New(ctx context.Context, a *app.App) tea.Model {
// Make sure to stop the progress bar and theme watcher when the app quits abruptly.
go func() {
<-ctx.Done()
t.chatPage.Cleanup()
t.cleanup()
t.themeWatcher.Stop()
}()

return t
}

// Init initializes the application
func (a *appModel) cleanup() {
if a.cancelThinkingCheck != nil {
a.cancelThinkingCheck()
a.cancelThinkingCheck = nil
}
a.chatPage.Cleanup()
}

func (a *appModel) Init() tea.Cmd {
cmds := []tea.Cmd{
a.dialog.Init(),
Expand Down Expand Up @@ -350,7 +360,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

case messages.ExitSessionMsg:
// /exit command exits immediately without confirmation
a.chatPage.Cleanup()
a.cleanup()
return a, tea.Quit

case messages.NewSessionMsg:
Expand Down Expand Up @@ -407,6 +417,9 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case messages.ToggleThinkingMsg:
return a.handleToggleThinking()

case messages.ToggleThinkingResultMsg:
return a.handleToggleThinkingResult(msg)

case messages.ToggleHideToolResultsMsg:
return a.handleToggleHideToolResults()

Expand Down Expand Up @@ -525,11 +538,11 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, nil

case dialog.ExitConfirmedMsg:
a.chatPage.Cleanup()
a.cleanup()
return a, tea.Quit

case messages.ExitAfterFirstResponseMsg:
a.chatPage.Cleanup()
a.cleanup()
return a, tea.Quit

case chat.EditorHeightChangedMsg:
Expand Down