Skip to content
Draft
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
63 changes: 56 additions & 7 deletions Sources/CodeIsland/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ final class AppState {
private(set) var recentHookEvents: [DiagnosticHookEvent] = []
@ObservationIgnored
private let maxRecentHookEvents = 100
@ObservationIgnored
var questionTerminalFrontmostDetector: (SessionSnapshot) -> Bool =
TerminalVisibilityDetector.isTerminalFrontmostForSession

func recordHookEvent(
source: String?,
Expand Down Expand Up @@ -904,10 +907,18 @@ final class AppState {
}

private func shouldAutoOpenQuestionSurface(for event: HookEvent) -> Bool {
// AskUserQuestion holds the provider/CLI until its continuation resolves,
// so there is no parallel terminal prompt for Smart Suppress to defer to.
if event.toolName == "AskUserQuestion" { return true }
return shouldAutoOpenPendingSurface(for: event.sessionId ?? "default")
let source = SessionSnapshot.normalizedSupportedSource(event.rawJSON["_source"] as? String)
let nativeAskIsRacing = event.rawJSON["_codeisland_native_ask_racing"] as? Bool == true
// Only OMP v3 explicitly guarantees that its native ask dialog races
// CodeIsland. Pi and legacy OMP block here, so hiding their card deadlocks.
if event.toolName == "AskUserQuestion",
(source != "pi" || !nativeAskIsRacing) {
return true
}
return shouldAutoOpenPendingSurface(
for: event.sessionId ?? "default",
isTerminalFrontmost: questionTerminalFrontmostDetector
)
}

private func showCompletion(_ sessionId: String) {
Expand Down Expand Up @@ -1662,6 +1673,19 @@ final class AppState {
}

func answerQuestionMulti(_ answers: [(question: String, answer: String)]) {
answerQuestionMulti(
answers.map {
AskUserQuestionAnswer(
question: $0.question,
answer: $0.answer,
selectedOptions: [],
customInput: nil
)
}
)
}

func answerQuestionMulti(_ answers: [AskUserQuestionAnswer]) {
guard !questionQueue.isEmpty else { return }
// Codex app-server questions reply over the JSON-RPC client, not a hook.
if questionQueue[0].isCodexAppServer {
Expand All @@ -1687,11 +1711,23 @@ final class AppState {
let responseData: Data
if pending.isFromPermission {
var answersDict: [String: String] = [:]
var answerDetails: [String: [String: Any]] = [:]
if let askState = pending.askUserQuestionState {
// Match by position — wizard collects answers in the same order as items
for (index, item) in askState.items.enumerated() {
if index < answers.count {
answersDict[item.answerKey] = answers[index].answer
let submitted = answers[index]
answersDict[item.answerKey] = submitted.answer
var details: [String: Any] = [:]
if !submitted.selectedOptions.isEmpty {
details["selectedOptions"] = submitted.selectedOptions
}
if let customInput = submitted.customInput {
details["customInput"] = customInput
}
if !details.isEmpty {
answerDetails[item.answerKey] = details
}
}
}
} else {
Expand All @@ -1702,7 +1738,8 @@ final class AppState {
event: pending.event,
answers: answersDict,
answer: answers.first?.answer,
originalQuestions: pending.event.toolInput?["questions"] as? [[String: Any]]
originalQuestions: pending.event.toolInput?["questions"] as? [[String: Any]],
answerDetails: answerDetails
)
let obj: [String: Any] = [
"hookSpecificOutput": [
Expand Down Expand Up @@ -1735,7 +1772,8 @@ final class AppState {
event: HookEvent,
answers: [String: String],
answer: String?,
originalQuestions: [[String: Any]]?
originalQuestions: [[String: Any]]?,
answerDetails: [String: [String: Any]] = [:]
) -> [String: Any] {
var updatedInput = event.toolInput ?? [:]
// `questions` must always be present in updatedInput. Claude Code's
Expand All @@ -1748,6 +1786,11 @@ final class AppState {
if let answer {
updatedInput["answer"] = answer
}
if !answerDetails.isEmpty,
SessionSnapshot.normalizedSupportedSource(event.rawJSON["_source"] as? String) == "pi",
event.toolUseId != nil {
updatedInput["_codeislandAnswerDetails"] = answerDetails
}
return updatedInput
}

Expand Down Expand Up @@ -1842,6 +1885,12 @@ final class AppState {
activeSessionId = sid
if shouldAutoOpenQuestionSurface(for: next.event) {
surface = .questionCard(sessionId: sid)
} else if case .questionCard = surface {
// Smart Suppress wants this card collapsed (e.g. an OMP ask
// whose terminal dialog is racing). Fold an inherited
// question-card surface so the promoted card does not render
// expanded on top of the previous question's surface.
surface = .collapsed
}
return true
} else if !completionQueue.isEmpty {
Expand Down
10 changes: 9 additions & 1 deletion Sources/CodeIsland/ConfigInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2679,6 +2679,9 @@ struct ConfigInstaller {
/// Current pi extension version — bump when codeisland-pi.ts changes.
private static let piExtensionVersion = "v2"

/// Current omp extension version — bump when codeisland-omp.ts changes.
private static let ompExtensionVersion = "v4"

private static func piExtensionSource() -> String? {
if let url = Bundle.appModule.url(forResource: "codeisland-pi", withExtension: "ts", subdirectory: "Resources"),
let src = try? String(contentsOf: url) { return src }
Expand Down Expand Up @@ -2758,7 +2761,12 @@ struct ConfigInstaller {
ompExtensionPath: String = ompExtensionPath,
fm: FileManager
) -> Bool {
isPiExtensionInstalled(piExtensionPath: ompExtensionPath, fm: fm)
guard fm.fileExists(atPath: ompExtensionPath),
let data = fm.contents(atPath: ompExtensionPath),
let content = String(data: data, encoding: .utf8)
else { return false }
return content.contains("CodeIsland pi extension")
&& content.contains("// version: \(ompExtensionVersion)")
}

// MARK: - OpenClaw plugin (#235)
Expand Down
7 changes: 7 additions & 0 deletions Sources/CodeIsland/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ struct AskUserQuestionItem {
let multiSelect: Bool
}

struct AskUserQuestionAnswer {
let question: String
let answer: String
let selectedOptions: [String]
let customInput: String?
}

struct AskUserQuestionState {
let items: [AskUserQuestionItem]
var answers: [String: String]
Expand Down
78 changes: 57 additions & 21 deletions Sources/CodeIsland/NotchPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1166,6 +1166,19 @@ private struct ApprovalBar: View {

// MARK: - Question Bar (below notch, auto-expanded)

func makeQuestionBarFreeTextAnswer(
question: String,
text: String
) -> AskUserQuestionAnswer? {
guard !text.isEmpty else { return nil }
return AskUserQuestionAnswer(
question: question,
answer: text,
selectedOptions: [],
customInput: text
)
}

private struct QuestionBar: View {
let question: String
let options: [String]?
Expand All @@ -1177,7 +1190,7 @@ private struct QuestionBar: View {
let queuePosition: Int
let queueTotal: Int
let onAnswer: (String) -> Void
let onAnswerMulti: ([(question: String, answer: String)]) -> Void
let onAnswerMulti: ([AskUserQuestionAnswer]) -> Void
let onSkip: () -> Void

@State private var textInput = ""
Expand All @@ -1186,7 +1199,7 @@ private struct QuestionBar: View {

// Multi-question wizard state
@State private var currentQuestionIndex: Int = 0
@State private var collectedAnswers: [(question: String, answer: String)] = []
@State private var collectedAnswers: [AskUserQuestionAnswer] = []
@State private var selectedIndices: Set<Int> = []
@State private var showOtherInput: Bool = false
@State private var otherText: String = ""
Expand Down Expand Up @@ -1291,7 +1304,7 @@ private struct QuestionBar: View {
isSelected: selectedIndex == idx, accent: cyan) {
selectedIndex = idx
showOtherInput = false
advanceWithAnswer(option)
advanceWithAnswer(option, selectedOptions: [option])
}
}
}
Expand All @@ -1312,7 +1325,7 @@ private struct QuestionBar: View {
.focused($otherFocused)
.onSubmit {
if !item.multiSelect && !otherText.isEmpty {
advanceWithAnswer(otherText)
advanceWithAnswer(otherText, customInput: otherText)
}
}
}
Expand Down Expand Up @@ -1340,9 +1353,7 @@ private struct QuestionBar: View {
.font(.system(size: 10.5))
.foregroundStyle(.white)
.focused($isFocused)
.onSubmit {
if !textInput.isEmpty { advanceWithAnswer(textInput) }
}
.onSubmit(submitFreeText)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
Expand Down Expand Up @@ -1373,29 +1384,29 @@ private struct QuestionBar: View {
border: Color.white.opacity(0.12),
action: onSkip
)
if item.multiSelect {
if item.payload.options?.isEmpty != false {
PixelButton(
label: L10n.shared["confirm"],
label: L10n.shared["submit"],
fg: .white.opacity(0.95),
bg: Color(red: 0.16, green: 0.38, blue: 0.18),
border: Color(red: 0.28, green: 0.62, blue: 0.32),
action: confirmMultiSelect
action: submitFreeText
)
} else if item.payload.options == nil || item.payload.options?.isEmpty == true {
} else if item.multiSelect {
PixelButton(
label: L10n.shared["submit"],
label: L10n.shared["confirm"],
fg: .white.opacity(0.95),
bg: Color(red: 0.16, green: 0.38, blue: 0.18),
border: Color(red: 0.28, green: 0.62, blue: 0.32),
action: { if !textInput.isEmpty { advanceWithAnswer(textInput) } }
action: confirmMultiSelect
)
} else if showOtherInput && !item.multiSelect {
PixelButton(
label: L10n.shared["submit"],
fg: .white.opacity(0.95),
bg: Color(red: 0.16, green: 0.38, blue: 0.18),
border: Color(red: 0.28, green: 0.62, blue: 0.32),
action: { if !otherText.isEmpty { advanceWithAnswer(otherText) } }
action: { if !otherText.isEmpty { advanceWithAnswer(otherText, customInput: otherText) } }
)
}
}
Expand Down Expand Up @@ -1423,9 +1434,31 @@ private struct QuestionBar: View {

// MARK: - Navigation

private func advanceWithAnswer(_ answer: String) {
private func submitFreeText() {
guard let item = currentItem,
let answer = makeQuestionBarFreeTextAnswer(
question: item.payload.question,
text: textInput
) else { return }
advance(with: answer)
}

private func advanceWithAnswer(
_ answer: String,
selectedOptions: [String] = [],
customInput: String? = nil
) {
guard let item = currentItem else { return }
collectedAnswers.append((question: item.payload.question, answer: answer))
advance(with: AskUserQuestionAnswer(
question: item.payload.question,
answer: answer,
selectedOptions: selectedOptions,
customInput: customInput
))
}

private func advance(with answer: AskUserQuestionAnswer) {
collectedAnswers.append(answer)

if currentQuestionIndex + 1 < allQuestions.count {
withAnimation(NotchAnimation.micro) {
Expand All @@ -1439,14 +1472,17 @@ private struct QuestionBar: View {

private func confirmMultiSelect() {
guard let item = currentItem, let opts = item.payload.options else { return }
var parts: [String] = selectedIndices.sorted().compactMap { idx in
let selectedOptions = selectedIndices.sorted().compactMap { idx in
opts.indices.contains(idx) ? opts[idx] : nil
}
if showOtherInput && !otherText.isEmpty {
parts.append(otherText)
}
let customInput = showOtherInput && !otherText.isEmpty ? otherText : nil
let parts = selectedOptions + (customInput.map { [$0] } ?? [])
guard !parts.isEmpty else { return }
advanceWithAnswer(parts.joined(separator: ", "))
advanceWithAnswer(
parts.joined(separator: ", "),
selectedOptions: selectedOptions,
customInput: customInput
)
}

private func goBack() {
Expand Down
Loading