Skip to content

Commit b02e49e

Browse files
cpcloudcursoragent
andcommitted
fix(chat): route ctrl+c through cancelChatOperations, not dead handler
The root cause of the frozen spinner and missing "Interrupted" notice: the global ctrl+c handler in model.Update (line 151) intercepts ALL ctrl+c events before they reach the chat-specific handler in handleChatKey. The global handler called cancelChatOperations() which only cancelled the context and set Streaming=false, but never cleaned up StreamingSQL, channel references, the assistant message, or added the "Interrupted" notice. Fix: Move the full cleanup logic into cancelChatOperations: - Cancel context (if CancelFn exists) - Clear all streaming state (Streaming, StreamingSQL, channels, CancelFn) - Remove the "generating query" notice - Remove the incomplete assistant message - Add "Interrupted" notice - Refresh viewport Also handle the edge case where CancelFn is nil (user presses ctrl+c between submitChat and handleSQLStreamStarted). Removed the dead ctrl+c handler in handleChatKey since it was never reachable. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3ddf6bf commit b02e49e

File tree

3 files changed

+91
-85
lines changed

3 files changed

+91
-85
lines changed

internal/app/chat.go

Lines changed: 36 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -224,20 +224,47 @@ func (m *Model) hideChat() {
224224
}
225225

226226
// cancelChatOperations cancels any in-flight LLM streams or model pulls.
227+
// When the chat is visible, this also cleans up messages and shows an
228+
// "Interrupted" notice.
227229
func (m *Model) cancelChatOperations() {
228230
if m.chat == nil {
229231
return
230232
}
231-
if m.chat.Streaming && m.chat.CancelFn != nil {
232-
m.chat.CancelFn()
233+
if m.chat.Streaming {
234+
if m.chat.CancelFn != nil {
235+
m.chat.CancelFn()
236+
}
233237
m.chat.Streaming = false
238+
m.chat.StreamingSQL = false
239+
m.chat.SQLStreamCh = nil
240+
m.chat.StreamCh = nil
241+
m.chat.CancelFn = nil
242+
243+
if m.chat.Visible {
244+
// Remove the "generating query" notice and incomplete assistant message.
245+
m.removeLastNotice()
246+
if len(m.chat.Messages) > 0 &&
247+
m.chat.Messages[len(m.chat.Messages)-1].Role == roleAssistant {
248+
m.chat.Messages = m.chat.Messages[:len(m.chat.Messages)-1]
249+
}
250+
m.chat.Messages = append(m.chat.Messages, chatMessage{
251+
Role: roleNotice, Content: "Interrupted",
252+
})
253+
m.refreshChatViewport()
254+
}
234255
}
235256
if m.chat.Pulling && m.chat.PullCancel != nil {
236257
m.chat.PullCancel()
237258
m.chat.Pulling = false
238259
m.chat.PullCancel = nil
239260
m.chat.PullDisplay = ""
240261
m.chat.PullPeak = 0
262+
if m.chat.Visible {
263+
m.chat.Messages = append(m.chat.Messages, chatMessage{
264+
Role: roleNotice, Content: "Pull cancelled",
265+
})
266+
m.refreshChatViewport()
267+
}
241268
}
242269
}
243270

@@ -1258,7 +1285,11 @@ func (m *Model) renderChatMessages() string {
12581285
if msg.Content == "generating query" {
12591286
continue
12601287
}
1261-
rendered = m.styles.ChatNotice.Render(msg.Content)
1288+
if msg.Content == "Interrupted" || msg.Content == "Pull cancelled" {
1289+
rendered = m.styles.ChatInterrupted.Render(msg.Content)
1290+
} else {
1291+
rendered = m.styles.ChatNotice.Render(msg.Content)
1292+
}
12621293
}
12631294
parts = append(parts, rendered)
12641295
}
@@ -1341,45 +1372,8 @@ func (m *Model) handleChatKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
13411372
m.toggleChatMag()
13421373
return m, nil
13431374
case "ctrl+c":
1344-
// Cancel stream or pull if active.
1345-
if m.chat.Streaming && m.chat.CancelFn != nil {
1346-
cancelFn := m.chat.CancelFn
1347-
m.chat.Streaming = false
1348-
m.chat.StreamingSQL = false
1349-
m.chat.SQLStreamCh = nil
1350-
m.chat.CancelFn = nil
1351-
// Cancel context - this will close the stream channel and any
1352-
// pending waitForChunk/waitForSQLChunk will return nil.
1353-
cancelFn()
1354-
// Remove all trailing notices and incomplete assistant messages.
1355-
m.removeLastNotice()
1356-
// Remove the assistant message that was being streamed.
1357-
// It doesn't matter if it has partial content or SQL - if we're
1358-
// cancelling, we don't want to show it.
1359-
if len(m.chat.Messages) > 0 &&
1360-
m.chat.Messages[len(m.chat.Messages)-1].Role == roleAssistant {
1361-
m.chat.Messages = m.chat.Messages[:len(m.chat.Messages)-1]
1362-
}
1363-
// Add cancellation notice.
1364-
m.chat.Messages = append(m.chat.Messages, chatMessage{
1365-
Role: roleNotice, Content: "Interrupted",
1366-
})
1367-
m.refreshChatViewport()
1368-
return m, nil
1369-
}
1370-
if m.chat.Pulling && m.chat.PullCancel != nil {
1371-
m.chat.PullCancel()
1372-
m.chat.Pulling = false
1373-
m.chat.PullCancel = nil
1374-
m.chat.PullDisplay = ""
1375-
m.chat.PullPeak = 0
1376-
m.chat.Messages = append(m.chat.Messages, chatMessage{
1377-
Role: roleNotice, Content: "Pull cancelled",
1378-
})
1379-
m.refreshChatViewport()
1380-
return m, nil
1381-
}
1382-
// No active operations -- ctrl+c does nothing in chat.
1375+
// Handled by the global ctrl+c handler in model.Update which calls
1376+
// cancelChatOperations. This case is unreachable but kept for clarity.
13831377
return m, nil
13841378
case "up", "ctrl+p":
13851379
if m.chat.Input.Focused() && !m.chat.Streaming {

internal/app/chat_test.go

Lines changed: 51 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ func TestSQLStreamCancellation(t *testing.T) {
157157

158158
// TestCancellationRemovesAssistantMessage verifies that pressing ctrl+c
159159
// removes the in-progress assistant message and shows "Interrupted" notice.
160+
// This tests cancelChatOperations which is the real ctrl+c handler (the global
161+
// handler in model.Update intercepts ctrl+c before the chat-specific handler).
160162
func TestCancellationRemovesAssistantMessage(t *testing.T) {
161163
m := newTestModel()
162164
m.openChat()
@@ -173,31 +175,25 @@ func TestCancellationRemovesAssistantMessage(t *testing.T) {
173175
{Role: roleAssistant, Content: "", SQL: "SELECT * FROM"},
174176
}
175177

176-
// Verify assistant message exists
178+
// Verify assistant message exists before cancellation
177179
assert.Len(t, m.chat.Messages, 3)
178180
assert.Equal(t, roleAssistant, m.chat.Messages[2].Role)
179181

180-
// Simulate ctrl+c by calling the handler logic
181-
cancelFn := m.chat.CancelFn
182-
m.chat.Streaming = false
183-
m.chat.StreamingSQL = false
184-
m.chat.SQLStreamCh = nil
185-
m.chat.CancelFn = nil
186-
cancelFn()
187-
m.removeLastNotice()
188-
if len(m.chat.Messages) > 0 &&
189-
m.chat.Messages[len(m.chat.Messages)-1].Role == roleAssistant {
190-
m.chat.Messages = m.chat.Messages[:len(m.chat.Messages)-1]
191-
}
192-
m.chat.Messages = append(m.chat.Messages, chatMessage{
193-
Role: roleNotice, Content: "Interrupted",
194-
})
182+
// This is what the global ctrl+c handler calls
183+
m.cancelChatOperations()
195184

196185
// Verify assistant message was removed and Interrupted notice added
197186
assert.Len(t, m.chat.Messages, 2, "should have user + interrupted notice")
198187
assert.Equal(t, roleUser, m.chat.Messages[0].Role)
199188
assert.Equal(t, roleNotice, m.chat.Messages[1].Role)
200189
assert.Equal(t, "Interrupted", m.chat.Messages[1].Content)
190+
191+
// Verify all streaming state is cleaned up
192+
assert.False(t, m.chat.Streaming)
193+
assert.False(t, m.chat.StreamingSQL)
194+
assert.Nil(t, m.chat.CancelFn)
195+
assert.Nil(t, m.chat.SQLStreamCh)
196+
assert.Nil(t, m.chat.StreamCh)
201197
}
202198

203199
// TestCancellationRemovesAssistantWithPartialContent verifies that ctrl+c
@@ -221,25 +217,47 @@ func TestCancellationRemovesAssistantWithPartialContent(t *testing.T) {
221217
assert.Len(t, m.chat.Messages, 2)
222218
assert.Equal(t, "Based on the data", m.chat.Messages[1].Content)
223219

224-
// Simulate ctrl+c
225-
cancelFn := m.chat.CancelFn
226-
m.chat.Streaming = false
227-
m.chat.StreamingSQL = false
228-
m.chat.CancelFn = nil
229-
cancelFn()
230-
m.removeLastNotice()
231-
if len(m.chat.Messages) > 0 &&
232-
m.chat.Messages[len(m.chat.Messages)-1].Role == roleAssistant {
233-
m.chat.Messages = m.chat.Messages[:len(m.chat.Messages)-1]
234-
}
235-
m.chat.Messages = append(m.chat.Messages, chatMessage{
236-
Role: roleNotice, Content: "Interrupted",
237-
})
220+
// This is what the global ctrl+c handler calls
221+
m.cancelChatOperations()
238222

239223
// Verify partial assistant message was removed
240224
assert.Len(t, m.chat.Messages, 2, "should have user + interrupted notice")
241225
assert.Equal(t, roleNotice, m.chat.Messages[1].Role)
242226
assert.Equal(t, "Interrupted", m.chat.Messages[1].Content)
227+
228+
// Verify all streaming state is cleaned up
229+
assert.False(t, m.chat.Streaming)
230+
assert.False(t, m.chat.StreamingSQL)
231+
assert.Nil(t, m.chat.CancelFn)
232+
}
233+
234+
// TestCancellationWorksWithoutCancelFn verifies that ctrl+c still cleans up
235+
// even when CancelFn is nil (e.g. user presses ctrl+c before the LLM stream
236+
// has been established).
237+
func TestCancellationWorksWithoutCancelFn(t *testing.T) {
238+
m := newTestModel()
239+
m.openChat()
240+
241+
// Simulate the window between submitChat and handleSQLStreamStarted
242+
// where Streaming is true but CancelFn hasn't been set yet
243+
m.chat.Streaming = true
244+
m.chat.StreamingSQL = true
245+
m.chat.CancelFn = nil // not yet set
246+
m.chat.Messages = []chatMessage{
247+
{Role: roleUser, Content: testQuestion},
248+
{Role: roleNotice, Content: "generating query"},
249+
{Role: roleAssistant, Content: "", SQL: ""},
250+
}
251+
252+
// This is what the global ctrl+c handler calls
253+
m.cancelChatOperations()
254+
255+
// Should still clean up even without CancelFn
256+
assert.Len(t, m.chat.Messages, 2, "should have user + interrupted notice")
257+
assert.Equal(t, roleNotice, m.chat.Messages[1].Role)
258+
assert.Equal(t, "Interrupted", m.chat.Messages[1].Content)
259+
assert.False(t, m.chat.Streaming)
260+
assert.False(t, m.chat.StreamingSQL)
243261
}
244262

245263
// TestSpinnerOnlyShowsForLastMessage verifies that the spinner is only
@@ -290,26 +308,16 @@ func TestNoSpinnerAfterCancellation(t *testing.T) {
290308
{Role: roleAssistant, Content: "", SQL: "SELECT"},
291309
}
292310

293-
// Simulate full cancellation flow
294-
m.chat.Streaming = false
295-
m.chat.StreamingSQL = false
296-
m.removeLastNotice()
297-
if len(m.chat.Messages) > 0 &&
298-
m.chat.Messages[len(m.chat.Messages)-1].Role == roleAssistant {
299-
m.chat.Messages = m.chat.Messages[:len(m.chat.Messages)-1]
300-
}
301-
m.chat.Messages = append(m.chat.Messages, chatMessage{
302-
Role: roleNotice, Content: "Interrupted",
303-
})
311+
// Use the real cancelChatOperations function
312+
m.cancelChatOperations()
304313

305314
// Verify state
306315
assert.Len(t, m.chat.Messages, 2)
307316
assert.Equal(t, roleNotice, m.chat.Messages[1].Role)
308317
assert.False(t, m.chat.Streaming)
309318
assert.False(t, m.chat.StreamingSQL)
310319

311-
// Render should not include any spinner (can't test spinner.View() output
312-
// but we verify the conditions for showing spinner are false)
320+
// Last message is a notice, not assistant -- no spinner can render
313321
lastMsg := m.chat.Messages[len(m.chat.Messages)-1]
314322
assert.NotEqual(t, roleAssistant, lastMsg.Role, "last message should not be assistant")
315323
}

internal/app/styles.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ type Styles struct {
5353
ChatUser lipgloss.Style // chat: user message label
5454
ChatAssistant lipgloss.Style // chat: assistant message label
5555
ChatNotice lipgloss.Style // chat: system notice (model switch, pull progress)
56+
ChatInterrupted lipgloss.Style // chat: user-initiated cancellation
5657
StatusStyles map[string]lipgloss.Style
5758
}
5859

@@ -246,6 +247,9 @@ func DefaultStyles() Styles {
246247
ChatNotice: lipgloss.NewStyle().
247248
Foreground(success).
248249
Italic(true),
250+
ChatInterrupted: lipgloss.NewStyle().
251+
Foreground(secondary).
252+
Italic(true),
249253
StatusStyles: map[string]lipgloss.Style{
250254
"ideating": lipgloss.NewStyle().Foreground(muted),
251255
"planned": lipgloss.NewStyle().Foreground(accent),

0 commit comments

Comments
 (0)