fix(modes): reset terminal title on /clear
Extract resetSessionTitleLocked and reuse it for /clear, /cd session resets, and fresh branches so the next real prompt generates a new title.
4 files · +45 -11
M packages/agent/modes/interactive_commands.go +5 -0
| @@ -254,11 +254,16 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 254 | 254 | i.planTotal = 0 | |
| 255 | 255 | i.planRevision++ | |
| 256 | 256 | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 257 | + | // A cleared conversation starts over with a fresh terminal title. | |
| 258 | + | titleCancel := i.resetSessionTitleLocked() | |
| 257 | 259 | i.view.InvalidateRenderCache() | |
| 258 | 260 | // A cleared conversation gets its pinned skills again. | |
| 259 | 261 | i.armSkillPinsLocked(pinFetch) | |
| 260 | 262 | i.mu.Unlock() | |
| 261 | 263 | pinDone() | |
| 264 | + | if titleCancel != nil { | |
| 265 | + | titleCancel() | |
| 266 | + | } | |
| 262 | 267 | if persistHandoff { | |
| 263 | 268 | i.persistCompactHandoff(handoff) | |
| 264 | 269 | } | |
| @@ -254,11 +254,16 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 254 | i.planTotal = 0 | 254 | i.planTotal = 0 | ||
| 255 | i.planRevision++ | 255 | i.planRevision++ | ||
| 256 | handoff, persistHandoff := i.resetCompactContinuationLocked() | 256 | handoff, persistHandoff := i.resetCompactContinuationLocked() | ||
| 257 | + | // A cleared conversation starts over with a fresh terminal title. | |||
| 258 | + | titleCancel := i.resetSessionTitleLocked() | |||
| 257 | i.view.InvalidateRenderCache() | 259 | i.view.InvalidateRenderCache() | ||
| 258 | // A cleared conversation gets its pinned skills again. | 260 | // A cleared conversation gets its pinned skills again. | ||
| 259 | i.armSkillPinsLocked(pinFetch) | 261 | i.armSkillPinsLocked(pinFetch) | ||
| 260 | i.mu.Unlock() | 262 | i.mu.Unlock() | ||
| 261 | pinDone() | 263 | pinDone() | ||
| 264 | + | if titleCancel != nil { | |||
| 265 | + | titleCancel() | |||
| 266 | + | } | |||
| 262 | if persistHandoff { | 267 | if persistHandoff { | ||
| 263 | i.persistCompactHandoff(handoff) | 268 | i.persistCompactHandoff(handoff) | ||
| 264 | } | 269 | } | ||
M packages/agent/modes/interactive_host.go +1 -7
| @@ -203,13 +203,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||
| 203 | 203 | i.cfg.Provider = provider | |
| 204 | 204 | i.cfg.Model = model | |
| 205 | 205 | i.cfg.detachMismatchedModelProfile() | |
| 206 | - | titleCancel := i.titleCancel | |
| 207 | - | i.titleCancel = nil | |
| 208 | - | i.titleVersion++ | |
| 209 | - | i.sessionTitle = "" | |
| 210 | - | i.titleRealPromptSeen = false | |
| 211 | - | i.titleGenerationStarted = false | |
| 212 | - | i.writeTerminalTitleLocked("") | |
| 206 | + | titleCancel := i.resetSessionTitleLocked() | |
| 213 | 207 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 214 | 208 | i.toolOrder = nil | |
| 215 | 209 | i.stream.Reset() | |
| @@ -203,13 +203,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||||
| 203 | i.cfg.Provider = provider | 203 | i.cfg.Provider = provider | ||
| 204 | i.cfg.Model = model | 204 | i.cfg.Model = model | ||
| 205 | i.cfg.detachMismatchedModelProfile() | 205 | i.cfg.detachMismatchedModelProfile() | ||
| 206 | - | titleCancel := i.titleCancel | 206 | + | titleCancel := i.resetSessionTitleLocked() |
| 207 | - | i.titleCancel = nil | |||
| 208 | - | i.titleVersion++ | |||
| 209 | - | i.sessionTitle = "" | |||
| 210 | - | i.titleRealPromptSeen = false | |||
| 211 | - | i.titleGenerationStarted = false | |||
| 212 | - | i.writeTerminalTitleLocked("") | |||
| 213 | i.toolCalls = map[string]*tui.ToolCallView{} | 207 | i.toolCalls = map[string]*tui.ToolCallView{} | ||
| 214 | i.toolOrder = nil | 208 | i.toolOrder = nil | ||
| 215 | i.stream.Reset() | 209 | i.stream.Reset() | ||
M packages/agent/modes/session_title.go +12 -4
| @@ -211,6 +211,17 @@ func (i *Interactive) restoreFailedSessionTitle() { | |||
| 211 | 211 | ||
| 212 | 212 | func (i *Interactive) resetSessionTitleForFreshBranch() { | |
| 213 | 213 | i.mu.Lock() | |
| 214 | + | cancel := i.resetSessionTitleLocked() | |
| 215 | + | i.mu.Unlock() | |
| 216 | + | if cancel != nil { | |
| 217 | + | cancel() | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // resetSessionTitleLocked drops the current title, shows the neutral terminal | |
| 222 | + | // title, and lets the next real prompt generate a new one. Callers hold i.mu | |
| 223 | + | // and must call the returned cancel func (if any) after unlocking. | |
| 224 | + | func (i *Interactive) resetSessionTitleLocked() context.CancelFunc { | |
| 214 | 225 | cancel := i.titleCancel | |
| 215 | 226 | i.titleCancel = nil | |
| 216 | 227 | i.titleVersion++ | |
| @@ -219,10 +230,7 @@ func (i *Interactive) resetSessionTitleForFreshBranch() { | |||
| 219 | 230 | i.titleGenerationStarted = false | |
| 220 | 231 | i.firstRealPrompt = "" | |
| 221 | 232 | i.writeTerminalTitleLocked("") | |
| 222 | - | i.mu.Unlock() | |
| 223 | - | if cancel != nil { | |
| 224 | - | cancel() | |
| 225 | - | } | |
| 233 | + | return cancel | |
| 226 | 234 | } | |
| 227 | 235 | ||
| 228 | 236 | func (i *Interactive) agentMessagesLocked() []provider.Message { | |
| @@ -211,6 +211,17 @@ func (i *Interactive) restoreFailedSessionTitle() { | |||||
| 211 | 211 | ||||
| 212 | func (i *Interactive) resetSessionTitleForFreshBranch() { | 212 | func (i *Interactive) resetSessionTitleForFreshBranch() { | ||
| 213 | i.mu.Lock() | 213 | i.mu.Lock() | ||
| 214 | + | cancel := i.resetSessionTitleLocked() | |||
| 215 | + | i.mu.Unlock() | |||
| 216 | + | if cancel != nil { | |||
| 217 | + | cancel() | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // resetSessionTitleLocked drops the current title, shows the neutral terminal | |||
| 222 | + | // title, and lets the next real prompt generate a new one. Callers hold i.mu | |||
| 223 | + | // and must call the returned cancel func (if any) after unlocking. | |||
| 224 | + | func (i *Interactive) resetSessionTitleLocked() context.CancelFunc { | |||
| 214 | cancel := i.titleCancel | 225 | cancel := i.titleCancel | ||
| 215 | i.titleCancel = nil | 226 | i.titleCancel = nil | ||
| 216 | i.titleVersion++ | 227 | i.titleVersion++ | ||
| @@ -219,10 +230,7 @@ func (i *Interactive) resetSessionTitleForFreshBranch() { | |||||
| 219 | i.titleGenerationStarted = false | 230 | i.titleGenerationStarted = false | ||
| 220 | i.firstRealPrompt = "" | 231 | i.firstRealPrompt = "" | ||
| 221 | i.writeTerminalTitleLocked("") | 232 | i.writeTerminalTitleLocked("") | ||
| 222 | - | i.mu.Unlock() | 233 | + | return cancel |
| 223 | - | if cancel != nil { | |||
| 224 | - | cancel() | |||
| 225 | - | } | |||
| 226 | } | 234 | } | ||
| 227 | 235 | ||||
| 228 | func (i *Interactive) agentMessagesLocked() []provider.Message { | 236 | func (i *Interactive) agentMessagesLocked() []provider.Message { | ||
M packages/agent/modes/session_title_test.go +27 -0
| @@ -128,6 +128,33 @@ func TestFirstRealPromptSetsAndPersistsTerminalTitle(t *testing.T) { | |||
| 128 | 128 | } | |
| 129 | 129 | } | |
| 130 | 130 | ||
| 131 | + | func TestClearResetsTerminalTitleAndRegeneratesOnNextPrompt(t *testing.T) { | |
| 132 | + | term := &alertTestTerminal{} | |
| 133 | + | client := &interactiveTitleClient{title: "Fix login retries"} | |
| 134 | + | ag := core.NewAgent(client, "test-model", "", nil) | |
| 135 | + | i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term}) | |
| 136 | + | i.markInteractiveStarted() | |
| 137 | + | ||
| 138 | + | i.Submit("Please fix login retries") | |
| 139 | + | waitInteractiveIdle(t, i) | |
| 140 | + | waitInteractiveTitle(t, i, "Fix login retries") | |
| 141 | + | ||
| 142 | + | i.runSlash(context.Background(), "/clear") | |
| 143 | + | if got := term.String(); !strings.HasSuffix(got, "\x1b]0;zut\x07") { | |
| 144 | + | t.Fatalf("terminal title output after /clear = %q, want neutral title last", got) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | client.mu.Lock() | |
| 148 | + | client.title = "Add dark theme" | |
| 149 | + | client.mu.Unlock() | |
| 150 | + | i.Submit("Please add a dark theme") | |
| 151 | + | waitInteractiveIdle(t, i) | |
| 152 | + | waitInteractiveTitle(t, i, "Add dark theme") | |
| 153 | + | if got := term.String(); !strings.HasSuffix(got, "\x1b]0;zut: Add dark theme\x07") { | |
| 154 | + | t.Fatalf("terminal title output = %q, want new title last", got) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 131 | 158 | func TestTitleFailureFallsBackWithoutFailingMainTurn(t *testing.T) { | |
| 132 | 159 | term := &alertTestTerminal{} | |
| 133 | 160 | client := &interactiveTitleClient{titleErr: errors.New("title provider unavailable")} | |
| @@ -128,6 +128,33 @@ func TestFirstRealPromptSetsAndPersistsTerminalTitle(t *testing.T) { | |||||
| 128 | } | 128 | } | ||
| 129 | } | 129 | } | ||
| 130 | 130 | ||||
| 131 | + | func TestClearResetsTerminalTitleAndRegeneratesOnNextPrompt(t *testing.T) { | |||
| 132 | + | term := &alertTestTerminal{} | |||
| 133 | + | client := &interactiveTitleClient{title: "Fix login retries"} | |||
| 134 | + | ag := core.NewAgent(client, "test-model", "", nil) | |||
| 135 | + | i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: term}) | |||
| 136 | + | i.markInteractiveStarted() | |||
| 137 | + | ||||
| 138 | + | i.Submit("Please fix login retries") | |||
| 139 | + | waitInteractiveIdle(t, i) | |||
| 140 | + | waitInteractiveTitle(t, i, "Fix login retries") | |||
| 141 | + | ||||
| 142 | + | i.runSlash(context.Background(), "/clear") | |||
| 143 | + | if got := term.String(); !strings.HasSuffix(got, "\x1b]0;zut\x07") { | |||
| 144 | + | t.Fatalf("terminal title output after /clear = %q, want neutral title last", got) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | client.mu.Lock() | |||
| 148 | + | client.title = "Add dark theme" | |||
| 149 | + | client.mu.Unlock() | |||
| 150 | + | i.Submit("Please add a dark theme") | |||
| 151 | + | waitInteractiveIdle(t, i) | |||
| 152 | + | waitInteractiveTitle(t, i, "Add dark theme") | |||
| 153 | + | if got := term.String(); !strings.HasSuffix(got, "\x1b]0;zut: Add dark theme\x07") { | |||
| 154 | + | t.Fatalf("terminal title output = %q, want new title last", got) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 131 | func TestTitleFailureFallsBackWithoutFailingMainTurn(t *testing.T) { | 158 | func TestTitleFailureFallsBackWithoutFailingMainTurn(t *testing.T) { | ||
| 132 | term := &alertTestTerminal{} | 159 | term := &alertTestTerminal{} | ||
| 133 | client := &interactiveTitleClient{titleErr: errors.New("title provider unavailable")} | 160 | client := &interactiveTitleClient{titleErr: errors.New("title provider unavailable")} | ||