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);