Skip to content

Commit 55b7bc5

Browse files
wxtskyclaude
andcommitted
fix(cursor): surface AskQuestion waits instead of sticking on thinking
Cursor's AskQuestion tool has no hook channel: the question is asked and answered entirely inside Cursor's own UI while the registered hooks go silent, so the session card sat on "thinking" forever and the question never appeared. There is also no channel to answer from CodeIsland, so this is a display-only wait derived from the agent transcript. - JSONLTailer: recognize Cursor's role-keyed transcript lines ({"role":...,"message":{"content":[...]}}) via an O(1) role-prefix probe and derive a CursorQuestionSignal — pending when the trailing assistant entry carries an unanswered AskQuestion tool_use (args {title, questions[].prompt, options[].label}, snake_case or camelCase, run_async questions excluded), cleared when any newer entry supersedes it. Chat text is deliberately not extracted from this shape; hooks already stream it and the transcript copies differ cosmetically. - AppState: apply the signal to main-agent Cursor sessions only (transcript parent == session id, so #262 folded Task cards are never touched): flip processing/running into .waitingQuestion with the question text, resume as .processing once the transcript moves on; hook activity resumes via the existing wasWaiting drain. Attach-time backfill recovers an already-stuck question after app restart or discovery (recency-gated to 30 min). - SessionCard: show the question plus a localized "answer in Cursor" hint (7 languages) instead of the endless thinking indicator; play the approval chime on a fresh wait; seed the multi preview scenario. Fixes #265 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8a8ae68 commit 55b7bc5

8 files changed

Lines changed: 890 additions & 9 deletions

File tree

Sources/CodeIsland/AppState+TranscriptTailer.swift

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
import Foundation
22
import CodeIslandCore
33

4+
/// Outcome of applying a `CursorQuestionSignal` to a session snapshot.
5+
enum CursorQuestionApplication: Equatable {
6+
/// Session is now display-waiting on a Cursor-side question.
7+
/// `fresh` is false when it was already waiting (prompt refresh only).
8+
case markedWaiting(fresh: Bool)
9+
/// A previously pending question was superseded; normal flow resumed.
10+
case clearedWaiting
11+
/// Signal did not apply (wrong source, subagent transcript, approval in flight, …).
12+
case ignored
13+
}
14+
415
extension AppState {
516
/// Start watching a session's transcript file for appended lines. Safe to call
617
/// repeatedly with the same (session, path) pair — the tailer reattaches only
@@ -42,9 +53,113 @@ extension AppState {
4253
sessions[sessionId] = session
4354
}
4455

56+
// Cursor stuck-question recovery (#265): if the transcript already ends
57+
// with an unanswered AskQuestion (e.g. CodeIsland launched or the session
58+
// was discovered while Cursor sat on a question), surface the wait now
59+
// instead of showing an endless "thinking". Recency-gated so an idle card
60+
// over a long-abandoned transcript doesn't resurrect as waiting.
61+
if let session = sessions[sessionId],
62+
session.source == "cursor" || session.source == "cursor-cli",
63+
CursorSessionFolding.parentConversationId(fromTranscriptPath: path) == sessionId,
64+
let signal = Self.latestCursorTailQuestion(path: path) {
65+
let modifiedAt = (try? FileManager.default.attributesOfItem(atPath: path))?[.modificationDate] as? Date
66+
let isRecent = modifiedAt.map { Date().timeIntervalSince($0) < Self.cursorQuestionBackfillMaxAge } ?? false
67+
let skipStalePending: Bool
68+
if case .pending = signal, !isRecent {
69+
skipStalePending = true
70+
} else {
71+
skipStalePending = false
72+
}
73+
if !skipStalePending, var mutable = sessions[sessionId] {
74+
if Self.applyCursorQuestionSignal(
75+
signal,
76+
to: &mutable,
77+
sessionId: sessionId,
78+
transcriptPath: path
79+
) != .ignored {
80+
sessions[sessionId] = mutable
81+
}
82+
}
83+
}
84+
4585
transcriptTailer.attach(sessionId: sessionId, filePath: path)
4686
}
4787

88+
/// Backfill freshness bound for flipping a session into the display-only
89+
/// question wait from a cold start. Live tail deltas are not age-gated.
90+
nonisolated static let cursorQuestionBackfillMaxAge: TimeInterval = 30 * 60
91+
92+
/// Trailing Cursor question state for a whole transcript file, scanned in
93+
/// bounded chunks (same pattern as `latestCodexTurnStatus`).
94+
nonisolated static func latestCursorTailQuestion(path: String) -> CursorQuestionSignal? {
95+
guard let handle = FileHandle(forReadingAtPath: path) else { return nil }
96+
defer { handle.closeFile() }
97+
98+
handle.seek(toFileOffset: 0)
99+
let chunkSize = 64 * 1024
100+
var pendingFragment = Data()
101+
var latestSignal: CursorQuestionSignal?
102+
103+
while true {
104+
let chunk = handle.readData(ofLength: chunkSize)
105+
if chunk.isEmpty { break }
106+
107+
let result = JSONLTailer.scanLines(pendingFragment + chunk)
108+
pendingFragment = result.trailingFragment
109+
if let signal = result.delta.cursorQuestion {
110+
latestSignal = signal
111+
}
112+
}
113+
114+
return latestSignal
115+
}
116+
117+
/// Apply a Cursor trailing-question signal to one session snapshot.
118+
///
119+
/// Pure state transition (no side effects) so both the live tail path and the
120+
/// attach-time backfill share identical rules, and tests can drive it directly:
121+
/// - `.pending` flips a **main-agent** Cursor session (transcript parent ==
122+
/// session id, so folded Task/subagent transcripts never qualify) into
123+
/// `.waitingQuestion` with the question text stored for the card. A real
124+
/// approval wait is never stomped.
125+
/// - `.cleared` erases the stored question; if the session was in the
126+
/// display-only wait it resumes as `.processing` (follow-up hooks or tail
127+
/// deltas refine from there).
128+
nonisolated static func applyCursorQuestionSignal(
129+
_ signal: CursorQuestionSignal,
130+
to session: inout SessionSnapshot,
131+
sessionId: String,
132+
transcriptPath: String?
133+
) -> CursorQuestionApplication {
134+
guard session.source == "cursor" || session.source == "cursor-cli" else { return .ignored }
135+
136+
switch signal {
137+
case .pending(let prompt):
138+
guard let transcriptPath,
139+
CursorSessionFolding.parentConversationId(fromTranscriptPath: transcriptPath) == sessionId else {
140+
return .ignored
141+
}
142+
// An interactive approval outranks the display-only wait.
143+
guard session.status != .waitingApproval else { return .ignored }
144+
let fresh = session.status != .waitingQuestion || session.cursorPendingQuestion == nil
145+
session.status = .waitingQuestion
146+
session.cursorPendingQuestion = prompt
147+
session.currentTool = nil
148+
session.toolDescription = nil
149+
session.lastActivity = Date()
150+
return .markedWaiting(fresh: fresh)
151+
152+
case .cleared:
153+
guard session.cursorPendingQuestion != nil else { return .ignored }
154+
session.cursorPendingQuestion = nil
155+
if session.status == .waitingQuestion {
156+
session.status = .processing
157+
}
158+
session.lastActivity = Date()
159+
return .clearedWaiting
160+
}
161+
}
162+
48163
private nonisolated static func latestCodexTurnStatus(path: String) -> ConversationTurnStatus? {
49164
guard let handle = FileHandle(forReadingAtPath: path) else { return nil }
50165
defer { handle.closeFile() }
@@ -121,9 +236,34 @@ extension AppState {
121236
mutated = true
122237
}
123238

239+
// Cursor question tool has no hook channel (#265) — the transcript tail is
240+
// the only signal that the agent is blocked on (or resumed from) a question
241+
// answered inside Cursor's own UI.
242+
var questionStateChanged = false
243+
if let signal = delta.cursorQuestion {
244+
let application = Self.applyCursorQuestionSignal(
245+
signal,
246+
to: &session,
247+
sessionId: delta.sessionId,
248+
transcriptPath: attachedTranscriptPaths[delta.sessionId]
249+
)
250+
if application != .ignored {
251+
mutated = true
252+
questionStateChanged = true
253+
}
254+
if application == .markedWaiting(fresh: true) {
255+
SoundManager.shared.handleEvent("PermissionRequest")
256+
}
257+
}
258+
124259
if mutated {
125260
session.lastActivity = Date()
126261
sessions[delta.sessionId] = session
127262
}
263+
if questionStateChanged {
264+
// Hooks stay silent while Cursor waits on its question, so nothing
265+
// else recomputes the aggregated pill/mascot state for this flip.
266+
refreshDerivedState()
267+
}
128268
}
129269
}

Sources/CodeIsland/DebugHarness.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,13 +196,14 @@ enum DebugHarness {
196196
s2.gitBranch = "feat/query-planner"
197197
s2.gitIsWorktree = true
198198

199-
// Session 3: Cursor processing
199+
// Session 3: Cursor blocked on an in-IDE question (#265 display-only wait)
200200
var s3 = SessionSnapshot()
201-
s3.status = .processing
201+
s3.status = .waitingQuestion
202202
s3.cwd = "/Users/dev/mobile"
203203
s3.source = "cursor"
204204
s3.lastUserPrompt = "Fix the scroll jank"
205205
s3.addRecentMessage(ChatMessage(isUser: true, text: "Fix the scroll jank"))
206+
s3.cursorPendingQuestion = "Which list component should I optimize first? (+1)"
206207

207208
state.sessions["preview-multi-1"] = s1
208209
state.sessions["preview-multi-2"] = s2

Sources/CodeIsland/L10n.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,9 @@ final class L10n: ObservableObject {
375375
"open_path": "Open",
376376
"copy_session_id": "Copy session ID",
377377

378+
// Cursor external question wait (#265)
379+
"cursor_question_answer_hint": "Answer in Cursor to continue",
380+
378381
// Session grouping
379382
"status_running": "Running",
380383
"status_waiting": "Waiting",
@@ -715,6 +718,9 @@ final class L10n: ObservableObject {
715718
"open_path": "Öffnen",
716719
"copy_session_id": "Sitzungs-ID kopieren",
717720

721+
// Cursor external question wait (#265)
722+
"cursor_question_answer_hint": "Zum Fortfahren in Cursor antworten",
723+
718724
// Session grouping
719725
"status_running": "Läuft",
720726
"status_waiting": "Wartet",
@@ -1059,6 +1065,9 @@ final class L10n: ObservableObject {
10591065
"open_path": "打开",
10601066
"copy_session_id": "复制会话 ID",
10611067

1068+
// Cursor external question wait (#265)
1069+
"cursor_question_answer_hint": "请在 Cursor 中回答后继续",
1070+
10621071
// Session grouping
10631072
"status_running": "运行中",
10641073
"status_waiting": "等待中",
@@ -1403,6 +1412,9 @@ final class L10n: ObservableObject {
14031412
"open_path": "開啟",
14041413
"copy_session_id": "複製會話 ID",
14051414

1415+
// Cursor external question wait (#265)
1416+
"cursor_question_answer_hint": "請在 Cursor 中回答後繼續",
1417+
14061418
// Session grouping
14071419
"status_running": "執行中",
14081420
"status_waiting": "等待中",
@@ -1747,6 +1759,9 @@ final class L10n: ObservableObject {
17471759
"open_path": "開く",
17481760
"copy_session_id": "セッション ID をコピー",
17491761

1762+
// Cursor external question wait (#265)
1763+
"cursor_question_answer_hint": "続行するには Cursor で回答してください",
1764+
17501765
// Session grouping
17511766
"status_running": "実行中",
17521767
"status_waiting": "待機中",
@@ -2091,6 +2106,9 @@ final class L10n: ObservableObject {
20912106
"open_path": "열기",
20922107
"copy_session_id": "세션 ID 복사",
20932108

2109+
// Cursor external question wait (#265)
2110+
"cursor_question_answer_hint": "계속하려면 Cursor에서 답변하세요",
2111+
20942112
// Session grouping
20952113
"status_running": "실행 중",
20962114
"status_waiting": "대기 중",
@@ -2435,6 +2453,9 @@ final class L10n: ObservableObject {
24352453
"open_path": "",
24362454
"copy_session_id": "Oturum ID Kopyala",
24372455

2456+
// Cursor external question wait (#265)
2457+
"cursor_question_answer_hint": "Devam etmek için Cursor'da yanıtlayın",
2458+
24382459
// Session grouping
24392460
"status_running": "Çalışıyor",
24402461
"status_waiting": "Bekliyor",

Sources/CodeIsland/NotchPanelView.swift

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2155,6 +2155,11 @@ private struct SessionCard: View {
21552155
appState.permissionQueue.firstIndex { ($0.event.sessionId ?? "default") == sessionId }
21562156
}
21572157
private var isActiveApproval: Bool { approvalQueueIndex == 0 }
2158+
/// Cursor is blocked on a question answered inside its own UI (#265) —
2159+
/// a display-only wait with no in-panel answer flow.
2160+
private var showsExternalCursorQuestion: Bool {
2161+
session.status == .waitingQuestion && session.cursorPendingQuestion != nil
2162+
}
21582163
private var statusNameColor: Color {
21592164
if session.status == .idle && session.interrupted {
21602165
return Color(red: 1.0, green: 0.45, blue: 0.35)
@@ -2332,6 +2337,29 @@ private struct SessionCard: View {
23322337
}
23332338
}
23342339

2340+
// Cursor asked a question in its own UI (#265). There is no hook
2341+
// channel to answer from here, so show the question plus a hint
2342+
// instead of an endless "thinking" indicator.
2343+
if showsExternalCursorQuestion {
2344+
VStack(alignment: .leading, spacing: 3) {
2345+
if let question = session.cursorPendingQuestion, !question.isEmpty {
2346+
HStack(alignment: .top, spacing: 5) {
2347+
Text("?")
2348+
.font(.system(size: fontSize, weight: .bold, design: .monospaced))
2349+
.foregroundStyle(Color(red: 1.0, green: 0.6, blue: 0.2))
2350+
Text(question)
2351+
.font(.system(size: fontSize, weight: .medium, design: .monospaced))
2352+
.foregroundStyle(.white.opacity(0.85))
2353+
.lineLimit(2)
2354+
.truncationMode(.tail)
2355+
}
2356+
}
2357+
Text(L10n.shared["cursor_question_answer_hint"])
2358+
.font(.system(size: max(10, fontSize - 1), design: .monospaced))
2359+
.foregroundStyle(Color(red: 1.0, green: 0.6, blue: 0.2).opacity(0.85))
2360+
}
2361+
}
2362+
23352363
// Session title: first user prompt (hide when detailed mode shows chat history)
23362364
if let prompt = session.lastUserPrompt,
23372365
session.recentMessages.isEmpty {
@@ -2360,8 +2388,10 @@ private struct SessionCard: View {
23602388
)
23612389
}
23622390

2363-
// Working indicator: show what AI is doing right now
2364-
if session.status != .idle {
2391+
// Working indicator: show what AI is doing right now.
2392+
// Suppressed while a Cursor-side question is pending — the
2393+
// question block above already explains the wait (#265).
2394+
if session.status != .idle && !showsExternalCursorQuestion {
23652395
HStack(spacing: 4) {
23662396
Text("$")
23672397
.font(.system(size: fontSize, weight: .bold, design: .monospaced))

0 commit comments

Comments
 (0)