diff --git a/Config/Info.plist b/Config/Info.plist index d6bc3bd..623c1b3 100644 --- a/Config/Info.plist +++ b/Config/Info.plist @@ -21,5 +21,13 @@ + NSMicrophoneUsageDescription + Pi Mobile uses the microphone for voice dictation in the chat bar. + NSSpeechRecognitionUsageDescription + Pi Mobile turns your speech into text for chat messages. + NSCameraUsageDescription + Pi Mobile uses the camera to attach photos to messages. + NSPhotoLibraryUsageDescription + Pi Mobile attaches photos from your library to messages. diff --git a/PiMobile/APIClient.swift b/PiMobile/APIClient.swift index cf2f69e..edd847d 100644 --- a/PiMobile/APIClient.swift +++ b/PiMobile/APIClient.swift @@ -150,12 +150,13 @@ final class APIClient { return try decoder.decode(T.self, from: data) } - private func post(_ path: String, body: [String: String] = [:]) async throws -> Data { + private func post(_ path: String, body: some Encodable) async throws -> Data { guard let mac = activeMac, let url = URL(string: mac.baseURL + path) else { throw APIError.badURL } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(mac.token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") + // Request bodies stay camelCase to match the companion server's JSON. request.httpBody = try JSONEncoder().encode(body) let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } @@ -168,6 +169,10 @@ final class APIClient { return data } + private func post(_ path: String) async throws -> Data { + try await post(path, body: [String: String]()) + } + func repos(on mac: MacServer) async throws -> [Repo] { try await get("/repos", on: mac) } func piVersion(on mac: MacServer) async throws -> PiVersionInfo { try await get("/pi-version", on: mac) } @@ -187,14 +192,60 @@ final class APIClient { func diff(workspaceId: String) async throws -> WorkspaceDiff { try await get("/workspaces/\(workspaceId)/diff") } func diffStat(workspaceId: String) async throws -> DiffStat { try await get("/workspaces/\(workspaceId)/diffstat") } - func send(sessionId: String, text: String, model: String? = nil) async throws { - var body = ["text": text] - if let model { body["model"] = model } + struct SendBody: Encodable { + var text: String + var model: String? + var thinking: String? + var approvalMode: String? + var images: [PromptImage]? + } + + func send( + sessionId: String, + text: String, + model: String? = nil, + thinking: String? = nil, + approvalMode: String? = nil, + images: [PromptImage]? = nil + ) async throws { + let body = SendBody( + text: text, + model: model, + thinking: thinking, + approvalMode: approvalMode, + images: images?.isEmpty == false ? images : nil + ) _ = try await post("/sessions/\(sessionId)/send", body: body) } func stop(sessionId: String) async throws { _ = try await post("/sessions/\(sessionId)/stop") } + struct UIResponseBody: Encodable { + var id: String + var confirmed: Bool? + var value: String? + var cancelled: Bool? + } + + func respondUI(sessionId: String, id: String, confirmed: Bool? = nil, value: String? = nil, cancelled: Bool? = nil) async throws { + _ = try await post( + "/sessions/\(sessionId)/ui-response", + body: UIResponseBody(id: id, confirmed: confirmed, value: value, cancelled: cancelled) + ) + } + + struct ApprovalExtensionStatus: Codable { + let installed: Bool + } + + func approvalExtensionStatus() async throws -> ApprovalExtensionStatus { + try await get("/approval-extension") + } + + func installApprovalExtension() async throws { + _ = try await post("/approval-extension/install") + } + private func delete(_ path: String) async throws { guard let mac = activeMac, let url = URL(string: mac.baseURL + path) else { throw URLError(.badURL) } var request = URLRequest(url: url) diff --git a/PiMobile/CameraPicker.swift b/PiMobile/CameraPicker.swift new file mode 100644 index 0000000..30d0320 --- /dev/null +++ b/PiMobile/CameraPicker.swift @@ -0,0 +1,35 @@ +import SwiftUI +import UIKit + +struct CameraPicker: UIViewControllerRepresentable { + @Binding var image: UIImage? + @Environment(\.dismiss) private var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: CameraPicker + init(_ parent: CameraPicker) { self.parent = parent } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.dismiss() + } + + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + parent.image = info[.originalImage] as? UIImage + parent.dismiss() + } + } +} diff --git a/PiMobile/MarkdownText.swift b/PiMobile/MarkdownText.swift new file mode 100644 index 0000000..c6f2238 --- /dev/null +++ b/PiMobile/MarkdownText.swift @@ -0,0 +1,425 @@ +import SwiftUI + +/// Block-based markdown renderer. Apple's `AttributedString` `.full` parser collapses +/// whitespace around bold/headings (words glue together); splitting into blocks and only +/// using inline markdown inside each block keeps spacing readable. +struct MarkdownText: View { + let markdown: String + var baseSize: CGFloat = 15 + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(Self.parse(markdown).enumerated()), id: \.offset) { _, block in + blockView(block) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .tint(Theme.accent) + } + + @ViewBuilder + private func blockView(_ block: Block) -> some View { + switch block { + case .heading(let level, let text): + inlineText(text) + .font(.system(size: headingSize(level), weight: level <= 2 ? .bold : .semibold)) + .foregroundStyle(Theme.text) + .padding(.top, level <= 2 ? 6 : 2) + .padding(.bottom, 2) + + case .paragraph(let text): + inlineText(text) + .font(.system(size: baseSize)) + .foregroundStyle(Color(red: 0.86, green: 0.86, blue: 0.89)) + .lineSpacing(5) + .fixedSize(horizontal: false, vertical: true) + + case .bullets(let items): + VStack(alignment: .leading, spacing: 7) { + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("•") + .font(.system(size: baseSize, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) + inlineText(item) + .font(.system(size: baseSize)) + .foregroundStyle(Color(red: 0.86, green: 0.86, blue: 0.89)) + .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding(.leading, 2) + + case .numbered(let items): + VStack(alignment: .leading, spacing: 7) { + ForEach(Array(items.enumerated()), id: \.offset) { i, item in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("\(i + 1).") + .font(.system(size: baseSize, design: .monospaced)) + .foregroundStyle(Theme.textTertiary) + .frame(minWidth: 20, alignment: .trailing) + inlineText(item) + .font(.system(size: baseSize)) + .foregroundStyle(Color(red: 0.86, green: 0.86, blue: 0.89)) + .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + case .code(let text): + Text(text) + .font(.system(size: baseSize - 1, design: .monospaced)) + .foregroundStyle(Color(red: 0.78, green: 0.84, blue: 0.72)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 10)) + + case .quote(let text): + HStack(alignment: .top, spacing: 10) { + RoundedRectangle(cornerRadius: 1) + .fill(Theme.textMuted) + .frame(width: 3) + inlineText(text) + .font(.system(size: baseSize)) + .foregroundStyle(Theme.textSecondary) + .lineSpacing(4) + .fixedSize(horizontal: false, vertical: true) + } + + case .rule: + Divider().overlay(Theme.separator).padding(.vertical, 4) + + case .table(let headers, let rows): + tableView(headers: headers, rows: rows) + } + } + + private func tableView(headers: [String], rows: [[String]]) -> some View { + let columns = max(headers.count, rows.map(\.count).max() ?? 0) + return ScrollView(.horizontal, showsIndicators: false) { + Grid(alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0) { + GridRow { + ForEach(0.. some View { + inlineText(text) + .font(.system(size: baseSize - 1, weight: header ? .semibold : .regular)) + .foregroundStyle(header ? Theme.text : Color(red: 0.86, green: 0.86, blue: 0.89)) + .multilineTextAlignment(.leading) + .lineLimit(8) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(minWidth: 72, maxWidth: 200, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) + } + + private func headingSize(_ level: Int) -> CGFloat { + switch level { + case 1: return baseSize + 7 + case 2: return baseSize + 5 + case 3: return baseSize + 3 + default: return baseSize + 1 + } + } + + private func inlineText(_ text: String) -> Text { + var options = AttributedString.MarkdownParsingOptions() + options.interpretedSyntax = .inlineOnlyPreservingWhitespace + options.failurePolicy = .returnPartiallyParsedIfPossible + if let parsed = try? AttributedString(markdown: text, options: options) { + return Text(parsed) + } + return Text(text) + } + + // MARK: - Parsing + + private enum Block { + case heading(level: Int, text: String) + case paragraph(String) + case bullets([String]) + case numbered([String]) + case code(String) + case quote(String) + case rule + case table(headers: [String], rows: [[String]]) + } + + private static func parse(_ markdown: String) -> [Block] { + let lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n") + var blocks: [Block] = [] + var i = 0 + var paragraph: [String] = [] + + func flushParagraph() { + let text = paragraph.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + paragraph.removeAll() + guard !text.isEmpty else { return } + blocks.append(.paragraph(text)) + } + + while i < lines.count { + let line = lines[i] + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("```") { + flushParagraph() + i += 1 + var code: [String] = [] + while i < lines.count, !lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("```") { + code.append(lines[i]) + i += 1 + } + if i < lines.count { i += 1 } // closing fence + blocks.append(.code(code.joined(separator: "\n"))) + continue + } + + if trimmed == "---" || trimmed == "***" || trimmed == "___" { + flushParagraph() + blocks.append(.rule) + i += 1 + continue + } + + if let heading = headingMatch(trimmed) { + flushParagraph() + blocks.append(.heading(level: heading.0, text: heading.1)) + i += 1 + continue + } + + if trimmed.hasPrefix("> ") || trimmed == ">" { + flushParagraph() + var quote: [String] = [] + while i < lines.count { + let t = lines[i].trimmingCharacters(in: .whitespaces) + if t.hasPrefix("> ") { + quote.append(String(t.dropFirst(2))) + } else if t == ">" { + quote.append("") + } else { + break + } + i += 1 + } + blocks.append(.quote(quote.joined(separator: "\n"))) + continue + } + + if isBullet(trimmed) { + flushParagraph() + var items: [String] = [] + while i < lines.count, isBullet(lines[i].trimmingCharacters(in: .whitespaces)) { + items.append(stripBullet(lines[i].trimmingCharacters(in: .whitespaces))) + i += 1 + // Continuation lines indented under the bullet + while i < lines.count { + let raw = lines[i] + let t = raw.trimmingCharacters(in: .whitespaces) + if t.isEmpty || isBullet(t) || isNumbered(t) || headingMatch(t) != nil || t.hasPrefix("```") || t.hasPrefix(">") { + break + } + if raw.hasPrefix(" ") || raw.hasPrefix("\t") { + items[items.count - 1] += " " + t + i += 1 + } else { + break + } + } + } + blocks.append(.bullets(items)) + continue + } + + if isNumbered(trimmed) { + flushParagraph() + var items: [String] = [] + while i < lines.count, isNumbered(lines[i].trimmingCharacters(in: .whitespaces)) { + items.append(stripNumbered(lines[i].trimmingCharacters(in: .whitespaces))) + i += 1 + } + blocks.append(.numbered(items)) + continue + } + + // GFM tables — including soft-wrapped cells from narrow model output. + if looksLikeTableRow(trimmed) { + var idx = i + if let table = tryParseTable(lines, start: &idx) { + flushParagraph() + blocks.append(table) + i = idx + continue + } + } + + if trimmed.isEmpty { + flushParagraph() + i += 1 + continue + } + + paragraph.append(line) + i += 1 + } + flushParagraph() + return blocks + } + + // MARK: - Tables + + private static func tryParseTable(_ lines: [String], start i: inout Int) -> Block? { + var idx = i + guard let headerLine = readTableRow(lines, at: &idx) else { return nil } + let headers = parseCells(headerLine) + guard headers.count >= 2 else { return nil } + + guard let sepLine = readTableRow(lines, at: &idx), isTableSeparator(sepLine) else { return nil } + + var rows: [[String]] = [] + while idx < lines.count { + let saved = idx + guard let rowLine = readTableRow(lines, at: &idx) else { break } + let trimmed = rowLine.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || isTableSeparator(trimmed) || !looksLikeTableRow(trimmed) { + idx = saved + break + } + var cells = parseCells(rowLine) + while cells.count < headers.count { cells.append("") } + if cells.count > headers.count { cells = Array(cells.prefix(headers.count)) } + rows.append(cells) + } + + i = idx + return .table(headers: headers, rows: rows) + } + + /// Reads one logical table row, joining soft-wrapped continuations like: + /// `| … | High | timeout +` / `deny default |` + private static func readTableRow(_ lines: [String], at idx: inout Int) -> String? { + guard idx < lines.count else { return nil } + var row = lines[idx].trimmingCharacters(in: .whitespaces) + if row.isEmpty { return nil } + idx += 1 + + while idx < lines.count { + let nextTrim = lines[idx].trimmingCharacters(in: .whitespaces) + if nextTrim.isEmpty { break } + if nextTrim.hasPrefix("|") || isTableSeparator(nextTrim) { break } + if headingMatch(nextTrim) != nil || nextTrim.hasPrefix("```") + || isBullet(nextTrim) || isNumbered(nextTrim) || nextTrim.hasPrefix(">") { + break + } + // Incomplete row (model wrapped mid-cell) — join until we see a closing `|`. + if row.hasSuffix("|") { break } + row += " " + nextTrim + idx += 1 + } + return row + } + + private static func looksLikeTableRow(_ line: String) -> Bool { + let t = line.trimmingCharacters(in: .whitespaces) + guard t.contains("|") else { return false } + return t.hasPrefix("|") || t.hasSuffix("|") || parseCells(t).count >= 2 + } + + private static func isTableSeparator(_ line: String) -> Bool { + let cells = parseCells(line) + guard !cells.isEmpty else { return false } + return cells.allSatisfy { cell in + let c = cell.trimmingCharacters(in: .whitespaces) + guard !c.isEmpty else { return false } + return c.unicodeScalars.allSatisfy { $0 == "-" || $0 == ":" || $0 == " " } + } + } + + /// Split on `|` but keep `\|` and pipes inside `` `inline code` ``. + private static func parseCells(_ line: String) -> [String] { + var t = line.trimmingCharacters(in: .whitespaces) + if t.hasPrefix("|") { t.removeFirst() } + if t.hasSuffix("|") { t.removeLast() } + + var cells: [String] = [] + var current = "" + var inCode = false + var i = t.startIndex + while i < t.endIndex { + let ch = t[i] + if ch == "`" { + inCode.toggle() + current.append(ch) + i = t.index(after: i) + continue + } + if ch == "\\", !inCode { + let next = t.index(after: i) + if next < t.endIndex, t[next] == "|" { + current.append("|") + i = t.index(after: next) + continue + } + } + if ch == "|", !inCode { + cells.append(current.trimmingCharacters(in: .whitespaces)) + current = "" + i = t.index(after: i) + continue + } + current.append(ch) + i = t.index(after: i) + } + cells.append(current.trimmingCharacters(in: .whitespaces)) + return cells + } + + private static func headingMatch(_ line: String) -> (Int, String)? { + guard let regex = try? NSRegularExpression(pattern: #"^(#{1,6})\s+(.+)$"#) else { return nil } + let range = NSRange(line.startIndex..., in: line) + guard let match = regex.firstMatch(in: line, range: range), + let levelRange = Range(match.range(at: 1), in: line), + let textRange = Range(match.range(at: 2), in: line) else { return nil } + return (line[levelRange].count, String(line[textRange])) + } + + private static func isBullet(_ line: String) -> Bool { + line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") + } + + private static func stripBullet(_ line: String) -> String { + String(line.dropFirst(2)) + } + + private static func isNumbered(_ line: String) -> Bool { + guard let regex = try? NSRegularExpression(pattern: #"^\d+\.\s+"#) else { return false } + return regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) != nil + } + + private static func stripNumbered(_ line: String) -> String { + guard let regex = try? NSRegularExpression(pattern: #"^\d+\.\s+"#) else { return line } + return regex.stringByReplacingMatches(in: line, range: NSRange(line.startIndex..., in: line), withTemplate: "") + } +} diff --git a/PiMobile/Models.swift b/PiMobile/Models.swift index 26c5869..3214c28 100644 --- a/PiMobile/Models.swift +++ b/PiMobile/Models.swift @@ -36,19 +36,115 @@ struct ChatSession: Identifiable, Codable, Hashable { // One picker section per Pi provider; ids are "provider/model" and pass // straight through to `pi --model`. +struct ModelInfo: Codable, Hashable, Identifiable { + let id: String + let thinking: Bool + let images: Bool + /// Pi thinking levels this model actually accepts (empty → hide thinking UI). + let thinkingLevels: [String] + + var supportsThinking: Bool { thinking && !thinkingLevels.isEmpty } + + init(id: String, thinking: Bool = false, images: Bool = true, thinkingLevels: [String] = []) { + self.id = id + self.thinking = thinking + self.images = images + self.thinkingLevels = thinkingLevels + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + images = try c.decodeIfPresent(Bool.self, forKey: .images) ?? true + let levels = try c.decodeIfPresent([String].self, forKey: .thinkingLevels) ?? [] + if !levels.isEmpty { + thinkingLevels = levels + thinking = try c.decodeIfPresent(Bool.self, forKey: .thinking) ?? true + } else { + // Legacy servers only sent a boolean — don't invent a full level list. + thinking = try c.decodeIfPresent(Bool.self, forKey: .thinking) ?? false + thinkingLevels = thinking ? ["off", "minimal", "low", "medium", "high"] : [] + } + } +} + struct ModelGroup: Codable, Hashable { let title: String - let models: [String] + let models: [ModelInfo] + + init(title: String, models: [ModelInfo]) { + self.title = title + self.models = models + } + + // Accept both the new [{id,thinking,images}] shape and the legacy [string] shape + // so an older companion server still populates the full catalog. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + title = try c.decode(String.self, forKey: .title) + if let infos = try? c.decode([ModelInfo].self, forKey: .models) { + models = infos + } else if let ids = try? c.decode([String].self, forKey: .models) { + models = ids.map { ModelInfo(id: $0) } + } else { + models = [] + } + } +} + +enum ThinkingLevel: String, CaseIterable, Identifiable { + case off, minimal, low, medium, high, xhigh, max + var id: String { rawValue } + var label: String { + switch self { + case .off: return "Off" + case .minimal: return "Minimal" + case .low: return "Low" + case .medium: return "Medium" + case .high: return "High" + case .xhigh: return "XHigh" + case .max: return "Max" + } + } +} + +enum ApprovalMode: String, CaseIterable, Identifiable { + case auto, ask + var id: String { rawValue } + var label: String { self == .auto ? "Auto" : "Ask" } } // The live groups come from the server's /models (Pi's own model catalog). // This static list is only the fallback before the fetch lands. enum HarnessModels { static let fallback: [ModelGroup] = [ - ModelGroup(title: "anthropic", models: ["anthropic/claude-fable-5", "anthropic/claude-opus-4-8", - "anthropic/claude-sonnet-5", "anthropic/claude-haiku-4-5"]), - ModelGroup(title: "openai-codex", models: ["openai-codex/gpt-5.6-sol", "openai-codex/gpt-5.5", "openai-codex/gpt-5.4"]), + ModelGroup(title: "anthropic", models: [ + ModelInfo(id: "anthropic/claude-fable-5", thinking: true, + thinkingLevels: ["minimal", "low", "medium", "high", "xhigh", "max"]), + ModelInfo(id: "anthropic/claude-opus-4-8", thinking: true, + thinkingLevels: ["off", "minimal", "low", "medium", "high", "max"]), + ModelInfo(id: "anthropic/claude-sonnet-5", thinking: true, + thinkingLevels: ["off", "minimal", "low", "medium", "high", "xhigh", "max"]), + ModelInfo(id: "anthropic/claude-haiku-4-5", thinking: true, + thinkingLevels: ["off", "minimal", "low", "medium", "high"]), + ]), + ModelGroup(title: "openai-codex", models: [ + ModelInfo(id: "openai-codex/gpt-5.6-sol", thinking: true, + thinkingLevels: ["off", "low", "medium", "high", "xhigh", "max"]), + ModelInfo(id: "openai-codex/gpt-5.5", thinking: true, + thinkingLevels: ["off", "low", "medium", "high", "xhigh"]), + ModelInfo(id: "openai-codex/gpt-5.4", thinking: true, + thinkingLevels: ["off", "low", "medium", "high", "xhigh"]), + ]), ] + + static func info(for id: String?, in groups: [ModelGroup]?) -> ModelInfo? { + guard let id else { return nil } + for g in groups ?? fallback { + if let m = g.models.first(where: { $0.id == id }) { return m } + } + return nil + } } func prettyModel(_ model: String?) -> String { @@ -91,9 +187,23 @@ struct FolderListing: Codable { let dirs: [String] } +struct PendingUI: Codable, Hashable { + let id: String + let method: String + let title: String? + let message: String? + let options: [String]? +} + struct AgentStatus: Codable { let running: Bool let activity: String + var pendingUI: PendingUI? = nil +} + +struct PromptImage: Codable, Hashable { + let data: String // base64 + let mimeType: String } // From the companion's /pi-version — compares `pi --version` to pi.dev. diff --git a/PiMobile/SpeechDictation.swift b/PiMobile/SpeechDictation.swift new file mode 100644 index 0000000..68b4bb7 --- /dev/null +++ b/PiMobile/SpeechDictation.swift @@ -0,0 +1,214 @@ +import AVFoundation +import Foundation +import Speech + +@Observable +@MainActor +final class SpeechDictation { + private(set) var isListening = false + private(set) var transcript = "" + var errorMessage: String? + + private var recognizer = SFSpeechRecognizer(locale: .current) + private var request: SFSpeechAudioBufferRecognitionRequest? + private var task: SFSpeechRecognitionTask? + private let engine = AVAudioEngine() + private var baseText = "" + private var hasTap = false + /// Bumped on every stop/start so late recognition callbacks can't tear down a new session. + private var sessionID = UUID() + private var stopping = false + + private var isSimulator: Bool { + #if targetEnvironment(simulator) + true + #else + false + #endif + } + + func toggle(into draft: inout String) { + if isListening { + stop() + } else { + start(seed: draft) + } + } + + func start(seed: String) { + errorMessage = nil + baseText = seed + transcript = seed + + // Simulator ASR is unreliable ("Failed to initialize recognizer") — fail fast + // with a clear message instead of flashing the mic. + if isSimulator { + errorMessage = "Dictation isn’t available in the iOS Simulator. Use a physical iPhone, or type with the software keyboard." + return + } + + SFSpeechRecognizer.requestAuthorization { [weak self] status in + Task { @MainActor in + guard let self else { return } + switch status { + case .authorized: + break + case .denied, .restricted: + self.errorMessage = "Speech recognition is not allowed. Enable it in Settings → Pi Companion." + return + case .notDetermined: + self.errorMessage = "Speech recognition permission was not granted." + return + @unknown default: + self.errorMessage = "Speech recognition is unavailable." + return + } + + AVAudioApplication.requestRecordPermission { granted in + Task { @MainActor in + guard granted else { + self.errorMessage = "Microphone access is required for dictation." + return + } + self.beginSession() + } + } + } + } + } + + private func beginSession() { + teardownAudio() + + guard let recognizer else { + errorMessage = "No speech recognizer for the current locale." + return + } + guard recognizer.isAvailable else { + errorMessage = "Speech recognition is unavailable right now." + return + } + + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker]) + try session.setActive(true, options: .notifyOthersOnDeactivation) + } catch { + errorMessage = "Could not start the microphone: \(error.localizedDescription)" + return + } + + let id = UUID() + sessionID = id + stopping = false + + let req = SFSpeechAudioBufferRecognitionRequest() + req.shouldReportPartialResults = true + req.taskHint = .dictation + request = req + + guard let format = recordingFormat() else { + errorMessage = "Microphone format is unavailable." + teardownAudio() + return + } + + let input = engine.inputNode + input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in + guard let self, !self.stopping else { return } + self.request?.append(buffer) + } + hasTap = true + + engine.prepare() + do { + try engine.start() + } catch { + errorMessage = "Could not start dictation: \(error.localizedDescription)" + teardownAudio() + return + } + + task = recognizer.recognitionTask(with: req) { [weak self] result, error in + Task { @MainActor in + guard let self, self.sessionID == id, !self.stopping else { return } + + if let result { + let spoken = result.bestTranscription.formattedString + if self.baseText.isEmpty { + self.transcript = spoken + } else if spoken.isEmpty { + self.transcript = self.baseText + } else { + let needsSpace = !self.baseText.hasSuffix(" ") + self.transcript = self.baseText + (needsSpace ? " " : "") + spoken + } + if result.isFinal { + // Defer stop so observers see the final transcript while + // isListening is still true (ChatView only syncs then). + Task { @MainActor in self.stop() } + } + } + + if let error { + if Self.isCancellation(error) { return } + let ns = error as NSError + // Ignore transient “no speech yet” assistant codes. + if ns.domain == "kAFAssistantErrorDomain", ns.code == 1110 || ns.code == 1111 { + return + } + self.errorMessage = "Dictation failed: \(error.localizedDescription)" + self.stop() + } + } + } + + isListening = true + } + + private func recordingFormat() -> AVAudioFormat? { + let input = engine.inputNode + let hardware = input.outputFormat(forBus: 0) + if hardware.sampleRate > 0, hardware.channelCount > 0 { + return hardware + } + let sampleRate = AVAudioSession.sharedInstance().sampleRate + guard sampleRate > 0 else { return nil } + return AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: sampleRate, + channels: 1, + interleaved: false + ) + } + + func stop() { + guard isListening || hasTap || task != nil || request != nil else { return } + stopping = true + sessionID = UUID() + request?.endAudio() + teardownAudio() + } + + private func teardownAudio() { + if engine.isRunning { engine.stop() } + if hasTap { + engine.inputNode.removeTap(onBus: 0) + hasTap = false + } + task?.cancel() + task = nil + request = nil + isListening = false + stopping = false + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + private static func isCancellation(_ error: Error) -> Bool { + let ns = error as NSError + if ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled { return true } + if ns.domain == "kAFAssistantErrorDomain" && (ns.code == 216 || ns.code == 301) { return true } + let msg = ns.localizedDescription.lowercased() + return msg.contains("cancel") || msg.contains("canceled") || msg.contains("cancelled") + } +} diff --git a/PiMobile/Views/ChatView.swift b/PiMobile/Views/ChatView.swift index b2b1cd4..73cf1f4 100644 --- a/PiMobile/Views/ChatView.swift +++ b/PiMobile/Views/ChatView.swift @@ -1,4 +1,13 @@ +import PhotosUI import SwiftUI +import UIKit + +struct PendingImage: Identifiable { + let id = UUID() + let image: UIImage + let mimeType: String + let data: Data +} struct ChatView: View { @Environment(APIClient.self) private var api @@ -13,151 +22,479 @@ struct ChatView: View { @State private var running = false @State private var activity = "" @State private var model: String? // Pi "provider/model" id; nil = session default + @State private var thinking: String? = nil // nil = Pi default + @State private var approvalMode: ApprovalMode = .auto + @State private var pendingImages: [PendingImage] = [] + @State private var photoItems: [PhotosPickerItem] = [] + @State private var showPhotoPicker = false + @State private var showCamera = false + @State private var cameraImage: UIImage? + @State private var dictation = SpeechDictation() + @State private var alertMessage: String? + @State private var showInstallApproval = false + @State private var pendingUI: PendingUI? + @State private var showApprovalPrompt = false + @State private var showModelPicker = false + /// Active turn starts at this user message; a stable "active-turn" frame + /// keeps it at the top while the reply grows underneath. + @State private var pinnedMessageId: String? + @State private var pinnedContent: String? + /// Server message ids present when the current send started — used so + /// optimistic rows / image pins don't latch onto an older same-text turn. + @State private var turnBaselineIds: Set = [] + @State private var scrollPosition = ScrollPosition(idType: String.self) + @State private var viewportHeight: CGFloat = 560 + @State private var isAtBottom = true + + private static let activeTurnID = "active-turn" + private static let chatBottomID = "chat-bottom" + + private var turnStartIndex: Int? { + guard let pinnedMessageId else { return nil } + return messages.firstIndex(where: { $0.id == pinnedMessageId }) + } + + private var selectedModelId: String? { model ?? session?.model } + private var selectedModelInfo: ModelInfo? { + HarnessModels.info(for: selectedModelId, in: api.modelGroups) + } + private var modelSupportsThinking: Bool { selectedModelInfo?.supportsThinking == true } + private var modelSupportsImages: Bool { selectedModelInfo?.images ?? true } var body: some View { - VStack(spacing: 0) { - ScrollView { - LazyVStack(alignment: .leading, spacing: 14) { - ForEach(messages) { MessageRow(message: $0, sessionId: sessionId) } + chatBody + .background(Theme.bg) + .navigationBarTitleDisplayMode(.inline) + .toolbar { chatToolbar } + .sheet(isPresented: $showDiff) { DiffView(workspace: workspace) } + .sheet(isPresented: $showModelPicker) { + ModelPickerSheet( + groups: api.modelGroups ?? HarnessModels.fallback, + model: $model, + thinking: $thinking, + sessionModel: session?.model + ) + } + .photosPicker(isPresented: $showPhotoPicker, selection: $photoItems, maxSelectionCount: 6, matching: .images) + .fullScreenCover(isPresented: $showCamera) { + CameraPicker(image: $cameraImage).ignoresSafeArea() + } + .modifier(ChatOverlays( + alertMessage: $alertMessage, + showInstallApproval: $showInstallApproval, + showApprovalPrompt: $showApprovalPrompt, + pendingUI: pendingUI, + onInstallApproval: installApprovalExtension, + onAnswerUI: answerUI + )) + .onChange(of: photoItems) { _, items in + Task { await ingestPhotoItems(items) } + } + .onChange(of: cameraImage) { _, img in + if let img { addImage(img); cameraImage = nil } + } + .onChange(of: dictation.transcript) { _, text in + // Keep draft in sync even for the final recognition result, + // which may land in the same turn that flips isListening off. + if dictation.isListening { draft = text } + } + .onChange(of: dictation.isListening) { _, listening in + if !listening { draft = dictation.transcript } + } + .onChange(of: dictation.errorMessage) { _, msg in + if let msg { alertMessage = msg } + } + .task { await load() } + .task { await api.loadModelGroups() } + .refreshable { await load() } + // Drop the top-pin when leaving so re-entering lands at the bottom. + .onDisappear { endTurnPin() } + } + + private var chatBody: some View { + ScrollView { + // Eager stack: LazyVStack + scrollPosition frequently lands on the + // wrong row while cells are still estimated. + VStack(alignment: .leading, spacing: 14) { + if let idx = turnStartIndex { + // History above the turn (scroll up to read). + ForEach(Array(messages.prefix(idx))) { message in + MessageRow(message: message, sessionId: sessionId) + } + + // Stable turn frame: user message at the top, reply grows + // down into the remaining viewport. + VStack(alignment: .leading, spacing: 14) { + ForEach(Array(messages.suffix(from: idx))) { message in + MessageRow(message: message, sessionId: sessionId) + } + if running { + HStack { + Spacer(minLength: 60) + Text(activity.isEmpty ? "Working…" : activity) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(Theme.textTertiary) + .lineLimit(1) + } + .padding(.trailing, 4) + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, minHeight: max(viewportHeight, 1), alignment: .top) + .id(Self.activeTurnID) + } else { + ForEach(messages) { message in + MessageRow(message: message, sessionId: sessionId) + } } - .padding(16) - } - .defaultScrollAnchor(.bottom) - VStack(alignment: .leading, spacing: 9) { - if running { streamingBar } - composer - modelPill - } - .padding(.horizontal, 14) - .padding(.top, 10) - .padding(.bottom, 8) - .overlay(alignment: .top) { Divider().overlay(Theme.separator) } - } - .background(Theme.bg) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .principal) { - VStack(alignment: .leading, spacing: 2) { - Text(workspace.name) - .font(.system(size: 15, weight: .semibold)) + + Color.clear + .frame(height: 1) + .id(Self.chatBottomID) + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 10) + .scrollTargetLayout() + } + .scrollPosition($scrollPosition) + .defaultScrollAnchor(.bottom, for: .initialOffset) + .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { _, height in + viewportHeight = height + } + .onScrollGeometryChange(for: Bool.self, of: { geo in + // visibleRect survives composer safeAreaInset; contentOffset math + // does not (it looks "above bottom" even when settled there). + let slack: CGFloat = 64 + let viewable = geo.containerSize.height + - geo.contentInsets.top + - geo.contentInsets.bottom + if geo.contentSize.height <= viewable + slack { return true } + return geo.visibleRect.maxY >= geo.contentSize.height - slack + }, action: { _, atBottom in + isAtBottom = atBottom + }) + .overlay(alignment: .bottomTrailing) { + if !isAtBottom, !messages.isEmpty { + Button(action: jumpToBottom) { + Image(systemName: "chevron.down") + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Theme.text) + .frame(width: 40, height: 40) + .background(.ultraThinMaterial, in: Circle()) + .overlay(Circle().strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5)) + .shadow(color: .black.opacity(0.35), radius: 10, y: 4) + } + .padding(.trailing, 18) + .padding(.bottom, 12) + .transition(.scale.combined(with: .opacity)) + .accessibilityLabel("Scroll to bottom") + } + } + .animation(.easeOut(duration: 0.15), value: isAtBottom) + .safeAreaInset(edge: .bottom, spacing: 0) { + composer + .padding(.horizontal, 14) + .padding(.top, 10) + .padding(.bottom, 8) + .overlay(alignment: .top) { Divider().overlay(Theme.separator) } + .background(Theme.bg) + } + } + + private func jumpToBottom() { + let hadPin = pinnedMessageId != nil + endTurnPin() + Task { @MainActor in + // Let the turn frame collapse before scrolling, otherwise "bottom" + // is still the empty filler under the pinned turn. + if hadPin { + await Task.yield() + try? await Task.sleep(for: .milliseconds(30)) + } + scrollPosition.scrollTo(edge: .bottom) + await Task.yield() + scrollPosition.scrollTo(id: Self.chatBottomID, anchor: .bottom) + } + } + + private func beginTurn(pinning messageId: String, content: String?) { + pinnedMessageId = messageId + pinnedContent = content + // Jump once to the stable turn frame (not a per-message id). + scrollPosition.scrollTo(id: Self.activeTurnID, anchor: .top) + } + + /// Clears the ChatGPT-style top pin. Call when leaving the chat (or on + /// send failure) — not when a turn finishes, or short replies jump down. + private func endTurnPin() { + pinnedMessageId = nil + pinnedContent = nil + turnBaselineIds = [] + } + + /// Land on the latest messages after open/reload (pin must already be cleared). + private func scrollToLatest() { + Task { @MainActor in + await Task.yield() + scrollPosition.scrollTo(edge: .bottom) + await Task.yield() + scrollPosition.scrollTo(id: Self.chatBottomID, anchor: .bottom) + isAtBottom = true + } + } + + @ToolbarContentBuilder + private var chatToolbar: some ToolbarContent { + ToolbarItem(placement: .principal) { + VStack(alignment: .leading, spacing: 2) { + Text(workspace.name) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Theme.text) + .lineLimit(1) + if let branch = workspace.branch { + Text(branch) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Theme.textMuted) .lineLimit(1) - if let branch = workspace.branch { - Text(branch) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(Theme.textMuted) - .lineLimit(1) + } + } + } + ToolbarItem(placement: .topBarTrailing) { + if let stat = diffStat, !stat.isEmpty { + Button { showDiff = true } label: { + HStack(spacing: 4) { + Text("+\(compactCount(stat.insertions))").foregroundStyle(Theme.green) + Text("−\(compactCount(stat.deletions))").foregroundStyle(Color(red: 0.95, green: 0.57, blue: 0.56)) } + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .padding(.horizontal, 7).padding(.vertical, 4) + .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 6)) } } - ToolbarItem(placement: .topBarTrailing) { - if let stat = diffStat, !stat.isEmpty { - Button { showDiff = true } label: { - HStack(spacing: 4) { - Text("+\(compactCount(stat.insertions))").foregroundStyle(Theme.green) - Text("−\(compactCount(stat.deletions))").foregroundStyle(Color(red: 0.95, green: 0.57, blue: 0.56)) + } + .sharedBackgroundVisibility(.hidden) + ToolbarItem(placement: .topBarTrailing) { + StatusBadge(status: workspace.status) + } + .sharedBackgroundVisibility(.hidden) + } + + private func installApprovalExtension() { + Task { + do { + try await api.installApprovalExtension() + approvalMode = .ask + } catch { + alertMessage = error.localizedDescription + approvalMode = .auto + } + } + } + + private var canSend: Bool { + (!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !pendingImages.isEmpty) && !running + } + + private var composerPlaceholder: String { + "Ask \(prettyModel(selectedModelId))" + } + + private var composer: some View { + VStack(alignment: .leading, spacing: 10) { + if !pendingImages.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(pendingImages) { img in + ZStack(alignment: .topTrailing) { + Image(uiImage: img.image) + .resizable().scaledToFill() + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: 10)) + Button { + pendingImages.removeAll { $0.id == img.id } + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .black.opacity(0.55)) + } + .offset(x: 4, y: -4) + } } - .font(.system(size: 11, weight: .semibold, design: .monospaced)) - .padding(.horizontal, 7).padding(.vertical, 4) - .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 6)) } } } - .sharedBackgroundVisibility(.hidden) - ToolbarItem(placement: .topBarTrailing) { - StatusBadge(status: workspace.status) + + TextField(composerPlaceholder, text: $draft, axis: .vertical) + .lineLimit(1...6) + .font(.system(size: 16)) + .foregroundStyle(Theme.text) + .padding(.horizontal, 4) + .onSubmit { send() } + + HStack(spacing: 14) { + plusButton + approvalButton + modelButton + Spacer(minLength: 0) + micButton + sendButton } - .sharedBackgroundVisibility(.hidden) // drop iOS 26's glass capsule around the badge } - .sheet(isPresented: $showDiff) { DiffView(workspace: workspace) } - .task { await load() } - .task { await api.loadModelGroups() } - .refreshable { await load() } + .padding(.horizontal, 14) + .padding(.top, 14) + .padding(.bottom, 12) + .background(Color(red: 0.12, green: 0.12, blue: 0.13), in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 28, style: .continuous).strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)) } - private var streamingBar: some View { - HStack(spacing: 9) { - ProgressView().controlSize(.small).tint(Theme.accent) - Text(activity.isEmpty ? "Working…" : activity) - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(Color(red: 0.63, green: 0.63, blue: 0.67)) - .lineLimit(1) - Spacer() + private var plusButton: some View { + Menu { Button { - Task { if let sessionId { try? await api.stop(sessionId: sessionId) } } + showPhotoPicker = true } label: { - HStack(spacing: 6) { - RoundedRectangle(cornerRadius: 2).fill(Color(red: 0.97, green: 0.44, blue: 0.44)).frame(width: 8, height: 8) - Text("Stop") + Label("Photo Library", systemImage: "photo.on.rectangle") + } + .disabled(!modelSupportsImages) + if UIImagePickerController.isSourceTypeAvailable(.camera) { + Button { + showCamera = true + } label: { + Label("Take Photo", systemImage: "camera") } - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(Color(red: 0.95, green: 0.63, blue: 0.63)) - .padding(.horizontal, 12).padding(.vertical, 5) - .overlay(Capsule().strokeBorder(Color.white.opacity(0.13), lineWidth: 1)) + .disabled(!modelSupportsImages) } + } label: { + Image(systemName: "plus") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(Theme.text) + .frame(width: 28, height: 28) } - .padding(.horizontal, 4) - } - - private var canSend: Bool { !draft.trimmingCharacters(in: .whitespaces).isEmpty && !running } - - private var composerPlaceholder: String { - running ? "Agent is working…" : "Message \(workspace.branch ?? workspace.name)" } - private var modelPill: some View { + private var approvalButton: some View { Menu { - // One submenu per provider — Menu drops Section titles on iOS 26, - // and providers like openrouter have hundreds of models. - ForEach(api.modelGroups ?? HarnessModels.fallback, id: \.title) { group in - Menu(group.title) { - Picker(group.title, selection: $model) { - ForEach(group.models, id: \.self) { m in - Text(prettyModel(m)).tag(String?.some(m)) - } - } + Picker("Approval", selection: $approvalMode) { + ForEach(ApprovalMode.allCases) { mode in + Text(mode.label).tag(mode) } } } label: { + Image(systemName: approvalMode == .ask ? "exclamationmark.shield.fill" : "checkmark.shield") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(approvalMode == .ask + ? Color(red: 0.95, green: 0.72, blue: 0.45) + : Theme.textTertiary) + .frame(width: 28, height: 28) + } + .onChange(of: approvalMode) { _, mode in + guard mode == .ask else { return } + Task { + let installed = (try? await api.approvalExtensionStatus())?.installed ?? false + // Ask already works via bundled -e for phone turns; offer optional + // install so desktop `pi` sessions get the same gate. + if !installed { showInstallApproval = true } + } + } + } + + private var modelButton: some View { + Button { showModelPicker = true } label: { HStack(spacing: 5) { - Text(prettyModel(model ?? session?.model)) - Image(systemName: "chevron.down") - .font(.system(size: 8, weight: .semibold)) - .opacity(0.5) + Text(prettyModel(selectedModelId)) + .foregroundStyle(Theme.text) + if let thinking, modelSupportsThinking { + Text(ThinkingLevel(rawValue: thinking)?.label ?? thinking.capitalized) + .foregroundStyle(Theme.textTertiary) + } + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) + } + .font(.system(size: 14, weight: .medium)) + .lineLimit(1) + } + .onChange(of: model) { _, newValue in + let info = HarnessModels.info(for: newValue ?? session?.model, in: api.modelGroups) + if info?.supportsThinking != true { + thinking = nil + } else if let thinking, !info!.thinkingLevels.contains(thinking) { + self.thinking = nil } - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(Theme.textSecondary) - .padding(.horizontal, 11).padding(.vertical, 6) - .background(Color.white.opacity(0.06), in: Capsule()) } } - private var composer: some View { - HStack(spacing: 6) { - TextField(composerPlaceholder, text: $draft, axis: .vertical) - .lineLimit(1...5) - .font(.system(size: 14.5)) - .foregroundStyle(Theme.text) - .padding(.horizontal, 12) - .onSubmit { send() } - Button(action: send) { - Image(systemName: "arrow.up") - .font(.system(size: 14, weight: .bold)) - .foregroundStyle(canSend ? Theme.bg : Theme.textMuted) - .frame(width: 34, height: 34) - .background(canSend ? Theme.accent : Color.white.opacity(0.07), in: Circle()) + private var micButton: some View { + Button { + dictation.toggle(into: &draft) + } label: { + Image(systemName: dictation.isListening ? "mic.fill" : "mic") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(dictation.isListening ? Theme.accent : Theme.text) + .frame(width: 28, height: 28) + } + } + + private var sendButton: some View { + Button { + if running { + Task { if let sessionId { try? await api.stop(sessionId: sessionId) } } + } else { + send() } - .disabled(!canSend) + } label: { + ZStack { + if running { + ProgressView() + .controlSize(.small) + .tint(Color(red: 0.12, green: 0.12, blue: 0.13)) + } else { + Image(systemName: "arrow.up") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(canSend ? Color(red: 0.12, green: 0.12, blue: 0.13) : Theme.textMuted) + } + } + .frame(width: 34, height: 34) + .background( + running ? Theme.accent + : (canSend ? Color.white : Color.white.opacity(0.08)), + in: Circle() + ) } - .padding(6) - .background(Color(red: 0.47, green: 0.47, blue: 0.5).opacity(0.13), in: RoundedRectangle(cornerRadius: 22)) - .overlay(RoundedRectangle(cornerRadius: 22).strokeBorder(Color.white.opacity(0.13), lineWidth: 0.5)) + .disabled(!running && !canSend) + .accessibilityLabel(running ? "Stop" : "Send") } private func send() { guard canSend else { return } - let text = draft.trimmingCharacters(in: .whitespaces) + if dictation.isListening { dictation.stop() } + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + let images = pendingImages.map { + PromptImage(data: $0.data.base64EncodedString(), mimeType: $0.mimeType) + } + let imageCount = images.count draft = "" + pendingImages = [] + photoItems = [] + // Drop any prior optimistic rows so image placeholders don't stack. + messages.removeAll { $0.id.hasPrefix("local-") } + turnBaselineIds = Set(messages.map(\.id)) + // Show the user bubble immediately — refresh() remaps the pin to the + // server id. The turn frame id stays "active-turn" so nothing jumps. + // Image-only sends use a placeholder (server used to omit empty user + // text); both sides now emit "Photo" / "N Photos" for pinning. + let localId = "local-\(UUID().uuidString)" + let localContent = text.isEmpty + ? (imageCount == 1 ? "Photo" : "\(imageCount) Photos") + : text + messages.append(ChatMessage( + id: localId, + role: "user", + content: localContent, + createdAt: Date() + )) + beginTurn(pinning: localId, content: localContent) running = true Task { // A workspace with no chats yet has no session — create one on first send. @@ -169,10 +506,28 @@ struct ChatView: View { } guard let sid else { running = false + endTurnPin() draft = text + messages.removeAll { $0.id.hasPrefix("local-") } + return + } + do { + try await api.send( + sessionId: sid, + text: text, + model: model, + thinking: thinking, + approvalMode: approvalMode.rawValue, + images: images + ) + } catch { + alertMessage = error.localizedDescription + running = false + endTurnPin() + draft = text + messages.removeAll { $0.id.hasPrefix("local-") } return } - try? await api.send(sessionId: sid, text: text, model: model) await refresh() await poll() } @@ -186,19 +541,61 @@ struct ChatView: View { // ponytail: any failed status check stops polling; pull-to-refresh restarts it guard let status = try? await api.status(sessionId: sessionId) else { running = false + pendingUI = nil + showApprovalPrompt = false return } activity = status.activity + if let ui = status.pendingUI, pendingUI?.id != ui.id { + pendingUI = ui + showApprovalPrompt = true + } await refresh() - if !status.running { running = false } + if !status.running { + running = false + // Keep the active-turn frame and re-assert the scroll target so + // short replies don't settle near the composer. Bottom landing + // only happens after leaving the chat. + if pinnedMessageId != nil { + scrollPosition.scrollTo(id: Self.activeTurnID, anchor: .top) + } + pendingUI = nil + showApprovalPrompt = false + } } } + private func answerUI(id: String, confirmed: Bool? = nil, value: String? = nil, cancelled: Bool? = nil) async { + guard let sessionId else { return } + showApprovalPrompt = false + pendingUI = nil + try? await api.respondUI(sessionId: sessionId, id: id, confirmed: confirmed, value: value, cancelled: cancelled) + } + + private func addImage(_ image: UIImage) { + let rendered = image.resizedForUpload() + guard let data = rendered.jpegData(compressionQuality: 0.82) else { return } + pendingImages.append(PendingImage(image: rendered, mimeType: "image/jpeg", data: data)) + } + + private func ingestPhotoItems(_ items: [PhotosPickerItem]) async { + for item in items { + if let data = try? await item.loadTransferable(type: Data.self), + let image = UIImage(data: data) { + addImage(image) + } + } + photoItems = [] + } + private func load() async { + endTurnPin() sessions = (try? await api.sessions(workspaceId: workspace.id)) ?? sessions if session == nil { session = sessions.first } diffStat = try? await api.diffStat(workspaceId: workspace.id) await refresh() + // Opening a chat always shows the most recent messages. + scrollToLatest() if let sessionId, let status = try? await api.status(sessionId: sessionId), status.running { running = true activity = status.activity @@ -208,7 +605,88 @@ struct ChatView: View { private func refresh() async { guard let sessionId else { return } - messages = (try? await api.messages(sessionId: sessionId)) ?? messages + let latest = (try? await api.messages(sessionId: sessionId)) ?? messages + // Keep the optimistic user row until a *new* server copy exists (same + // text as an older turn must not suppress or steal the pin). + let locals = messages.filter { $0.id.hasPrefix("local-") } + var merged = latest + for local in locals { + let hasNewCopy = merged.contains { + $0.role == "user" + && $0.content == local.content + && !turnBaselineIds.contains($0.id) + } + if !hasNewCopy { merged.append(local) } + } + messages = merged + + if let pin = pinnedMessageId, !merged.contains(where: { $0.id == pin }) { + // Remap local-* → server id; turn frame id stays "active-turn". + if let content = pinnedContent, + let match = merged.last(where: { + $0.role == "user" && $0.content == content && !turnBaselineIds.contains($0.id) + }) { + pinnedMessageId = match.id + } + } + } +} + +private extension UIImage { + func resizedForUpload(maxDimension: CGFloat = 1600) -> UIImage { + let longest = max(size.width, size.height) + guard longest > maxDimension else { return self } + let scale = maxDimension / longest + let newSize = CGSize(width: size.width * scale, height: size.height * scale) + let renderer = UIGraphicsImageRenderer(size: newSize) + return renderer.image { _ in draw(in: CGRect(origin: .zero, size: newSize)) } + } +} + +private struct ChatOverlays: ViewModifier { + @Binding var alertMessage: String? + @Binding var showInstallApproval: Bool + @Binding var showApprovalPrompt: Bool + let pendingUI: PendingUI? + let onInstallApproval: () -> Void + let onAnswerUI: (String, Bool?, String?, Bool?) async -> Void + + private var showError: Binding { + Binding(get: { alertMessage != nil }, set: { if !$0 { alertMessage = nil } }) + } + + func body(content: Content) -> some View { + content + .alert("Something went wrong", isPresented: showError) { + Button("OK", role: .cancel) { alertMessage = nil } + } message: { + Text(alertMessage ?? "") + } + .alert("Install approval extension?", isPresented: $showInstallApproval) { + Button("Install", action: onInstallApproval) + Button("Not Now", role: .cancel) {} + } message: { + Text("Ask mode already works for phone turns. Install the bundled extension if you also want the same confirmations in desktop pi.") + } + .confirmationDialog( + pendingUI?.title ?? "Allow tool?", + isPresented: $showApprovalPrompt, + titleVisibility: .visible + ) { + if let ui = pendingUI { + if ui.method == "select", let options = ui.options { + ForEach(options, id: \.self) { opt in + Button(opt) { Task { await onAnswerUI(ui.id, nil, opt, nil) } } + } + } else { + Button("Allow") { Task { await onAnswerUI(ui.id, true, nil, nil) } } + Button("Deny", role: .destructive) { Task { await onAnswerUI(ui.id, false, nil, nil) } } + } + Button("Cancel", role: .cancel) { Task { await onAnswerUI(ui.id, nil, nil, true) } } + } + } message: { + Text(pendingUI?.message ?? "") + } } } @@ -350,11 +828,7 @@ struct MessageRow: View { case "tool": ToolRow(content: message.content) default: - Text(LocalizedStringKey(message.content)) - .font(.system(size: 14.5)) - .foregroundStyle(Color(red: 0.84, green: 0.84, blue: 0.86)) - .lineSpacing(3) - .frame(maxWidth: .infinity, alignment: .leading) + MarkdownText(markdown: message.content) .copyable(message.content) } } diff --git a/PiMobile/Views/ModelPickerSheet.swift b/PiMobile/Views/ModelPickerSheet.swift new file mode 100644 index 0000000..9cbde15 --- /dev/null +++ b/PiMobile/Views/ModelPickerSheet.swift @@ -0,0 +1,117 @@ +import SwiftUI + +/// Searchable model catalog + thinking level (only when the selected model supports it). +struct ModelPickerSheet: View { + let groups: [ModelGroup] + @Binding var model: String? + @Binding var thinking: String? + var sessionModel: String? + @Environment(\.dismiss) private var dismiss + @State private var query = "" + + private var selectedId: String? { model ?? sessionModel } + + private var filtered: [ModelGroup] { + let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !q.isEmpty else { return groups } + return groups.compactMap { group in + let models = group.models.filter { + $0.id.lowercased().contains(q) || prettyModel($0.id).lowercased().contains(q) + || group.title.lowercased().contains(q) + } + return models.isEmpty ? nil : ModelGroup(title: group.title, models: models) + } + } + + private var selectedInfo: ModelInfo? { + HarnessModels.info(for: selectedId, in: groups) + } + + private var selectedThinkingLevels: [ThinkingLevel] { + (selectedInfo?.thinkingLevels ?? []).compactMap(ThinkingLevel.init(rawValue:)) + } + + var body: some View { + NavigationStack { + List { + if let info = selectedInfo, info.supportsThinking, !selectedThinkingLevels.isEmpty { + Section("Thinking") { + thinkingRow(label: "Default", value: nil) + ForEach(selectedThinkingLevels) { level in + thinkingRow(label: level.label, value: level.rawValue) + } + } + } + + ForEach(filtered, id: \.title) { group in + Section(group.title) { + ForEach(group.models) { m in + Button { + model = m.id + if !m.supportsThinking { + thinking = nil + } else if let thinking, !m.thinkingLevels.contains(thinking) { + self.thinking = nil + } + } label: { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(prettyModel(m.id)) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Theme.text) + Text(m.id) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Theme.textMuted) + .lineLimit(1) + } + Spacer() + if m.supportsThinking { + Text("Think") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Theme.textTertiary) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Color.white.opacity(0.06), in: Capsule()) + } + if m.id == selectedId { + Image(systemName: "checkmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.accent) + } + } + } + } + } + } + } + .listStyle(.insetGrouped) + .scrollContentBackground(.hidden) + .background(Theme.bg) + .searchable(text: $query, prompt: "Search models") + .navigationTitle("Model") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .fontWeight(.semibold) + } + } + } + .preferredColorScheme(.dark) + } + + private func thinkingRow(label: String, value: String?) -> some View { + Button { + thinking = value + } label: { + HStack { + Text(label).foregroundStyle(Theme.text) + Spacer() + if thinking == value { + Image(systemName: "checkmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.accent) + } + } + } + } +} diff --git a/README.md b/README.md index f2c6c7f..a54a335 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ Pi is a terminal agent, so this app pairs a small companion server on your Mac ( - **Full chat history** — messages, thinking blocks, tool calls, inline images - **Send messages** — continue any session from your phone; because the phone and the terminal share the same session file, `pi -c` on the Mac picks up exactly where you left off — no restarts, no forked conversations - **New sessions & workspaces** — start a fresh session, or spin up a new git worktree, from the phone -- **Model picker** — Pi's full model catalog (15+ providers), switchable per-send, mid-session +- **Model picker** — Pi's full model catalog (15+ providers), switchable per-send, mid-session, with optional thinking level +- **Ask approval mode** — confirm tool calls on the phone (Auto stays the default; Ask loads a bundled Pi extension) +- **Photos & dictation** — attach images and dictate into the composer - **Diffs** — review each workspace's uncommitted/branch changes on your phone ## How it works @@ -55,7 +57,7 @@ Open `PiMobile.xcodeproj` in Xcode (26+), build to your device (iOS 26+). Then e ## Caveats -- Turns started from the phone run with Pi's default tool permissions — there's no remote approval UI yet. +- Turns default to **Auto** tool permissions. **Ask** mode confirms each tool on the phone via the bundled `server/pi-mobile-approval` extension (loaded for that turn; optional `pi install` for desktop use too). - A session that's open in a terminal `pi` and driven from the phone at the same time can race; finish one before the other (Pi's file format keeps history safe either way). - Session titles are derived from the first user message (Pi sessions have no title field). diff --git a/server/install.sh b/server/install.sh index cf7f209..88e80c2 100755 --- a/server/install.sh +++ b/server/install.sh @@ -22,11 +22,28 @@ BUN="$(command -v bun || echo "$HOME/.bun/bin/bun")" mkdir -p "$LOG_DIR" "$HOME/Library/LaunchAgents" -# Running via `curl | bash` (no server.ts beside this script)? Download it. -if [[ ! -f "$DIR/server.ts" ]]; then - DIR="$LOG_DIR" +# Always install into ~/.pi-companion so LaunchAgent has a stable path, and so +# checkout updates (server.ts + approval extension) are picked up on reinstall. +REPO_RAW="https://raw.githubusercontent.com/MRL-00/pi-mobile/main/server" +if [[ -f "$DIR/server.ts" ]]; then + cp "$DIR/server.ts" "$LOG_DIR/server.ts" + if [[ -d "$DIR/pi-mobile-approval" ]]; then + rm -rf "$LOG_DIR/pi-mobile-approval" + cp -R "$DIR/pi-mobile-approval" "$LOG_DIR/pi-mobile-approval" + fi +else echo "Downloading server.ts…" - curl -fsSL "https://raw.githubusercontent.com/MRL-00/pi-mobile/main/server/server.ts" -o "$DIR/server.ts" + curl -fsSL "$REPO_RAW/server.ts" -o "$LOG_DIR/server.ts" + echo "Downloading pi-mobile-approval…" + mkdir -p "$LOG_DIR/pi-mobile-approval" + curl -fsSL "$REPO_RAW/pi-mobile-approval/extension.ts" -o "$LOG_DIR/pi-mobile-approval/extension.ts" + curl -fsSL "$REPO_RAW/pi-mobile-approval/package.json" -o "$LOG_DIR/pi-mobile-approval/package.json" +fi +DIR="$LOG_DIR" +# Ask mode fails closed without this package — don't launch a half-installed agent. +if [[ ! -f "$DIR/pi-mobile-approval/extension.ts" || ! -f "$DIR/pi-mobile-approval/package.json" ]]; then + echo "error: pi-mobile-approval package missing in $DIR (needed for Ask mode)." >&2 + exit 1 fi # caffeinate -s keeps the Mac awake (on AC power) so agents can run while you're away diff --git a/server/pi-mobile-approval/extension.ts b/server/pi-mobile-approval/extension.ts new file mode 100644 index 0000000..409bc26 --- /dev/null +++ b/server/pi-mobile-approval/extension.ts @@ -0,0 +1,30 @@ +/** + * Pi Mobile approval gate — when PI_MOBILE_APPROVAL_MODE=ask, confirm each + * tool call via RPC extension UI (surfaced on the phone). Auto is a no-op. + */ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const summarize = (input: Record | undefined) => { + if (!input) return ""; + const v = + input.file_path ?? input.path ?? input.command ?? input.pattern ?? input.description ?? ""; + return String(v).slice(0, 200); +}; + +export default function (pi: ExtensionAPI) { + const mode = (process.env.PI_MOBILE_APPROVAL_MODE ?? "auto").toLowerCase(); + if (mode !== "ask") return; + + pi.on("tool_call", async (event, ctx) => { + if (!ctx.hasUI) { + return { block: true, reason: "Ask mode requires UI confirmation (unavailable)" }; + } + const detail = summarize(event.input as Record); + const confirmed = await ctx.ui.confirm( + `Allow ${event.toolName}?`, + detail || "No arguments", + ); + if (!confirmed) return { block: true, reason: "Denied from phone" }; + return undefined; + }); +} diff --git a/server/pi-mobile-approval/package.json b/server/pi-mobile-approval/package.json new file mode 100644 index 0000000..aac1875 --- /dev/null +++ b/server/pi-mobile-approval/package.json @@ -0,0 +1,9 @@ +{ + "name": "pi-mobile-approval", + "version": "1.0.0", + "private": true, + "keywords": ["pi-package"], + "pi": { + "extensions": ["./extension.ts"] + } +} diff --git a/server/server.ts b/server/server.ts index 030caf1..a6576d3 100644 --- a/server/server.ts +++ b/server/server.ts @@ -181,6 +181,14 @@ const textOf = (msg: any) => .join("") .trim(); +const userImageBlocks = (msg: any) => + (msg?.content ?? []).filter((b: any) => + b?.type === "image" || b?.type === "image_url" || b?.type === "input_image" + || (typeof b?.mimeType === "string" && String(b.mimeType).startsWith("image/"))); + +const userImageCount = (msg: any) => userImageBlocks(msg).length; +const userHasImages = (msg: any) => userImageCount(msg) > 0; + function sessionSummary(file: string, workspaceId: string) { const entries = readEntries(file); let title = "Untitled"; @@ -222,7 +230,19 @@ function displayMessages(sessionId: string) { if (msg.role === "user") { turnStart = new Date(e.timestamp).getTime(); const t = textOf(msg); - if (t) out.push({ id: e.id, role: "user", content: t, created_at: createdAt }); + if (t) { + out.push({ id: e.id, role: "user", content: t, created_at: createdAt }); + } else if (userHasImages(msg)) { + // Image-only prompts have no text; still surface a row so the phone + // can pin the turn and show something in history. + const n = userImageCount(msg); + out.push({ + id: e.id, + role: "user", + content: n === 1 ? "Photo" : `${n} Photos`, + created_at: createdAt, + }); + } } else if (msg.role === "assistant") { for (const [i, b] of (msg.content ?? []).entries()) { if (b.type === "text" && b.text?.trim()) @@ -246,28 +266,132 @@ function displayMessages(sessionId: string) { } // ── Models ────────────────────────────────────────────────────────────────── -// Pi's model catalog is first-class: `pi --list-models` prints provider/model -// rows. Ids are "provider/model" and pass straight to `pi --model`. -let modelCache: { title: string; models: string[] }[] = []; -function refreshModels() { - try { - const p = Bun.spawnSync([PI, "--list-models"], { stdout: "pipe", stderr: "pipe" }); - if (p.exitCode !== 0) return; - const groups = new Map(); - for (const line of p.stdout.toString().split("\n").slice(1)) { - const m = line.match(/^(\S+)\s+(\S+)/); - if (!m) continue; - const list = groups.get(m[1]) ?? []; - list.push(`${m[1]}/${m[2]}`); - groups.set(m[1], list); +// Prefer Pi RPC `get_available_models` so we can expose each model's real +// thinking-level set (same rules as Pi's getSupportedThinkingLevels). Falls +// back to `pi --list-models` if RPC isn't available. +type ModelInfo = { id: string; thinking: boolean; images: boolean; thinkingLevels: string[] }; +let modelCache: { title: string; models: ModelInfo[] }[] = []; + +const EXTENDED_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; +function supportedThinkingLevels(model: { reasoning?: boolean; thinkingLevelMap?: Record }) { + if (!model.reasoning) return [] as string[]; // no thinking control in the phone UI + return EXTENDED_THINKING_LEVELS.filter((level) => { + const mapped = model.thinkingLevelMap?.[level]; + if (mapped === null) return false; + if (level === "xhigh" || level === "max") return mapped !== undefined; + return true; + }); +} + +function refreshModelsFromList() { + const p = Bun.spawnSync([PI, "--list-models"], { stdout: "pipe", stderr: "pipe" }); + if (p.exitCode !== 0) return false; + const groups = new Map(); + for (const line of p.stdout.toString().split("\n").slice(1)) { + const parts = line.trim().split(/\s+/); + if (parts.length < 4) continue; + const [provider, modelId] = parts; + if (!provider || !modelId || provider === "provider") continue; + const images = parts[parts.length - 1] === "yes"; + const reasoning = parts[parts.length - 2] === "yes"; + // Coarse yes/no only — without a level map don't invent controls. + const thinkingLevels = reasoning ? ["off", "minimal", "low", "medium", "high"] : []; + const list = groups.get(provider) ?? []; + list.push({ + id: `${provider}/${modelId}`, + thinking: thinkingLevels.length > 0, + images, + thinkingLevels, + }); + groups.set(provider, list); + } + if (!groups.size) return false; + modelCache = [...groups].map(([title, models]) => ({ title, models })); + return true; +} + +async function refreshModelsFromRpc() { + // Keep stdin open until the response arrives (closing early makes pi exit + // before answering). Ignore fire-and-forget extension_ui_request noise. + const proc = Bun.spawn([PI, "--mode", "rpc", "--no-session"], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + proc.stdin.write(JSON.stringify({ id: "models", type: "get_available_models" }) + "\n"); + proc.stdin.flush(); + + const dec = new TextDecoder(); + let buf = ""; + const apply = (models: any[]) => { + const groups = new Map(); + for (const m of models) { + const provider = m.provider ?? "unknown"; + const thinkingLevels = supportedThinkingLevels(m); + const list = groups.get(provider) ?? []; + list.push({ + id: `${provider}/${m.id}`, + thinking: thinkingLevels.length > 0, + images: Array.isArray(m.input) && m.input.includes("image"), + thinkingLevels, + }); + groups.set(provider, list); } - if (groups.size) modelCache = [...groups].map(([title, models]) => ({ title, models })); + if (!groups.size) return false; + modelCache = [...groups].map(([title, ms]) => ({ title, models: ms })); + return true; + }; + + try { + const result = await Promise.race([ + (async () => { + for await (const chunk of proc.stdout as AsyncIterable) { + buf += dec.decode(chunk, { stream: true }); + const lines = buf.split("\n"); + buf = lines.pop()!; + for (const line of lines) { + if (!line.trim()) continue; + let ev: any; + try { ev = JSON.parse(line); } catch { continue; } + if (ev.type === "response" && ev.command === "get_available_models" && ev.success) { + return apply(ev.data?.models ?? []); + } + } + } + return false; + })(), + new Promise((resolve) => setTimeout(() => resolve(false), 15_000)), + ]); + return result; + } finally { + try { proc.stdin.end(); } catch {} + proc.kill(); + } +} + +async function refreshModels() { + try { + if (await refreshModelsFromRpc()) return; + refreshModelsFromList(); } catch { - // pi missing from PATH — keep whatever cache we have; phone falls back to static list + try { refreshModelsFromList(); } catch { + // pi missing from PATH — keep whatever cache we have; phone falls back to static list + } } } -refreshModels(); -setInterval(refreshModels, 10 * 60_000); +await refreshModels(); +setInterval(() => { void refreshModels(); }, 10 * 60_000); + +// Bundled Ask-mode gate (also installable via POST /approval-extension/install). +const approvalExt = resolve(import.meta.dir, "pi-mobile-approval/extension.ts"); +const approvalPkg = resolve(import.meta.dir, "pi-mobile-approval"); +const approvalInstalled = () => { + try { + const settings = JSON.parse(readFileSync(`${homedir()}/.pi/agent/settings.json`, "utf8")); + const pkgs: string[] = settings.packages ?? []; + return pkgs.some((p) => p.includes("pi-mobile-approval") || resolve(p) === approvalExt || resolve(p) === approvalPkg); + } catch { return false; } +}; // ── Pi version ────────────────────────────────────────────────────────────── // Same contract Pi's own CLI uses (`pi.dev/api/latest-version`). Surfaces an @@ -380,10 +504,28 @@ function createWorkspace(repoId: string) { } // ── Running turns via Pi RPC ──────────────────────────────────────────────── -type Turn = { proc: ReturnType | null; running: boolean; activity: string; cwd: string }; +type PendingUI = { + id: string; + method: string; + title?: string; + message?: string; + options?: string[]; +}; +type PromptImage = { data: string; mimeType: string }; +type Turn = { + proc: ReturnType | null; + running: boolean; + activity: string; + cwd: string; + pendingUI: PendingUI | null; +}; const turns = new Map(); -function sendMessage(sessionId: string, text: string, model?: string) { +function sendMessage( + sessionId: string, + text: string, + opts: { model?: string; thinking?: string; approvalMode?: string; images?: PromptImage[] } = {}, +) { const existing = turns.get(sessionId); if (existing?.running) return { error: "agent is already working", status: 409 }; @@ -392,17 +534,54 @@ function sendMessage(sessionId: string, text: string, model?: string) { if (!cwd) return { error: "session not found", status: 404 }; if (!existsSync(cwd)) return { error: "workspace directory not found on this Mac", status: 400 }; + const approvalMode = (opts.approvalMode ?? "auto").toLowerCase() === "ask" ? "ask" : "auto"; const args = [PI, "--mode", "rpc"]; if (found) args.push("--session", found.file); else args.push("--session-id", sessionId); - if (model) args.push("--model", model); + if (opts.model) args.push("--model", opts.model); + if (opts.thinking) args.push("--thinking", opts.thinking); + // Ask must fail closed — never start a turn that looks like Ask without the gate. + if (approvalMode === "ask") { + if (!existsSync(approvalExt)) { + return { error: "Ask mode requires the bundled approval extension (re-run server/install.sh)", status: 500 }; + } + args.push("-e", approvalExt); + } - const turn: Turn = { proc: null, running: true, activity: "Starting agent…", cwd }; + // Leave activity empty so the phone shows its "Working…" placeholder until + // the first real Pi event (Thinking… / tool name) arrives. + const turn: Turn = { proc: null, running: true, activity: "", cwd, pendingUI: null }; turns.set(sessionId, turn); - const proc = Bun.spawn(args, { cwd, stdin: "pipe", stdout: "pipe", stderr: "pipe" }); + let proc: ReturnType; + try { + proc = Bun.spawn(args, { + cwd, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, PI_MOBILE_APPROVAL_MODE: approvalMode }, + }); + } catch (e) { + turns.delete(sessionId); + return { error: `failed to start pi: ${String(e)}`, status: 500 }; + } turn.proc = proc; - proc.stdin.write(JSON.stringify({ id: "1", type: "prompt", message: text }) + "\n"); - proc.stdin.flush(); + const images = (opts.images ?? []) + .filter((img) => img?.data && img?.mimeType) + .map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType })); + try { + proc.stdin.write(JSON.stringify({ + id: "1", + type: "prompt", + message: text, + ...(images.length ? { images } : {}), + }) + "\n"); + proc.stdin.flush(); + } catch (e) { + try { proc.kill(); } catch {} + turns.delete(sessionId); + return { error: `failed to send prompt: ${String(e)}`, status: 500 }; + } (async () => { let buf = ""; @@ -414,13 +593,24 @@ function sendMessage(sessionId: string, text: string, model?: string) { if (!line.trim()) continue; let ev: any; try { ev = JSON.parse(line); } catch { continue; } - if (ev.type === "tool_execution_start") + if (ev.type === "extension_ui_request" && (ev.method === "confirm" || ev.method === "select")) { + turn.pendingUI = { + id: ev.id, + method: ev.method, + title: ev.title, + message: ev.message, + options: ev.options, + }; + turn.activity = ev.title ?? "Waiting for approval…"; + } else if (ev.type === "tool_execution_start") { + turn.pendingUI = null; turn.activity = `${ev.toolName ?? "tool"} ${summarizeInput(ev.args)}`; - else if (ev.type === "message_start" && ev.message?.role === "assistant") + } else if (ev.type === "message_start" && ev.message?.role === "assistant") turn.activity = "Thinking…"; else if (ev.type === "response" && ev.success === false) turn.activity = `⚠️ ${ev.error ?? "command failed"}`; else if (ev.type === "agent_end") { + turn.pendingUI = null; proc.stdin.end(); proc.kill(); // pi stays resident waiting for more commands; the turn is done } @@ -430,11 +620,34 @@ function sendMessage(sessionId: string, text: string, model?: string) { pendingSessions.delete(sessionId); // it's on disk now turn.running = false; turn.activity = ""; + turn.pendingUI = null; })(); return { ok: true }; } +function respondUI(sessionId: string, body: any) { + const turn = turns.get(sessionId); + if (!turn?.running || !turn.proc?.stdin) return { error: "no active turn", status: 404 }; + if (!turn.pendingUI || (body.id && body.id !== turn.pendingUI.id)) + return { error: "no pending approval", status: 409 }; + const id = body.id ?? turn.pendingUI.id; + let payload: Record = { type: "extension_ui_response", id }; + if (body.cancelled) payload.cancelled = true; + else if (turn.pendingUI.method === "confirm") payload.confirmed = !!body.confirmed; + else if (body.value !== undefined) payload.value = body.value; + else payload.cancelled = true; + try { + turn.proc.stdin.write(JSON.stringify(payload) + "\n"); + turn.proc.stdin.flush(); + } catch { + return { error: "failed to write response", status: 500 }; + } + turn.pendingUI = null; + turn.activity = "Continuing…"; + return { ok: true }; +} + // ── HTTP API (same shapes the phone already speaks) ───────────────────────── const repos = () => { const { workspaces, repoOf, repoRoots } = scanned(); @@ -497,11 +710,29 @@ Bun.serve({ let m: RegExpMatchArray | null; try { if (req.method === "POST" && (m = path.match(/^\/sessions\/([^/]+)\/send$/))) { - const { text, model } = await req.json(); - if (!text?.trim()) return Response.json({ error: "empty message" }, { status: 400 }); - const r = sendMessage(m[1], text.trim(), model); + const { text, model, thinking, approvalMode, images } = await req.json(); + const hasImages = Array.isArray(images) && images.length > 0; + if (!text?.trim() && !hasImages) return Response.json({ error: "empty message" }, { status: 400 }); + const r = sendMessage(m[1], (text ?? "").trim(), { model, thinking, approvalMode, images }); return Response.json(r, { status: "status" in r ? (r.status as number) : 200 }); } + if (req.method === "POST" && (m = path.match(/^\/sessions\/([^/]+)\/ui-response$/))) { + const r = respondUI(m[1], await req.json()); + return Response.json(r, { status: "status" in r ? (r.status as number) : 200 }); + } + if (path === "/approval-extension") { + return Response.json({ installed: approvalInstalled(), path: approvalPkg }); + } + if (req.method === "POST" && path === "/approval-extension/install") { + if (!existsSync(approvalPkg)) + return Response.json({ error: "bundled approval package missing" }, { status: 500 }); + const p = Bun.spawnSync([PI, "install", approvalPkg], { stdout: "pipe", stderr: "pipe" }); + if (p.exitCode !== 0) + return Response.json({ + error: p.stderr.toString().trim() || p.stdout.toString().trim() || "install failed", + }, { status: 500 }); + return Response.json({ ok: true, installed: true }); + } // Deleting a chat moves its session file to ~/.pi-companion/trash (recoverable). if (req.method === "DELETE" && (m = path.match(/^\/sessions\/([^/]+)$/))) { const found = findSessionFile(m[1]); @@ -582,7 +813,11 @@ Bun.serve({ } if ((m = path.match(/^\/sessions\/([^/]+)\/status$/))) { const t = turns.get(m[1]); - return Response.json({ running: !!t?.running, activity: t?.activity ?? "" }); + return Response.json({ + running: !!t?.running, + activity: t?.activity ?? "", + pending_ui: t?.pendingUI ?? null, + }); } if (path === "/models") return Response.json(modelCache); if (path === "/pi-version") return Response.json(piVersionCache);