diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3c61b4..55ac403 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,7 +76,9 @@ jobs: - name: Install cross-compiler and cmake (Linux ARM64) if: matrix.goos == 'linux' && matrix.goarch == 'arm64' - run: sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu cmake + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu cmake - name: Build whisper-cpp from source (Linux AMD64) if: matrix.goos == 'linux' && matrix.goarch == 'amd64' @@ -223,7 +225,9 @@ jobs: - name: Package app run: | APP_PATH="build/Build/Products/Release/Orbitor.app" - ditto -c -k --keepParent "$APP_PATH" orbitor-desktop-macos.zip + mkdir -p /tmp/orbitor-desktop + cp -R "$APP_PATH" /tmp/orbitor-desktop/ + ditto -c -k --keepParent /tmp/orbitor-desktop orbitor-desktop-macos.zip - name: Upload desktop artifact uses: actions/upload-artifact@v4 @@ -272,6 +276,7 @@ jobs: SHA_DARWIN_AMD64=$(grep 'orbitor-darwin-amd64' dist/checksums.txt | awk '{print $1}') SHA_LINUX_ARM64=$(grep 'orbitor-linux-arm64' dist/checksums.txt | awk '{print $1}') SHA_LINUX_AMD64=$(grep 'orbitor-linux-amd64' dist/checksums.txt | awk '{print $1}') + SHA_DESKTOP_MACOS=$(grep 'orbitor-desktop-macos.zip' dist/checksums.txt | awk '{print $1}') # Strip leading 'v' from tag for the formula version field SEMVER="${VERSION#v}" @@ -283,10 +288,12 @@ jobs: sed \ -e "s|version \".*\"|version \"${SEMVER}\"|" \ + -e "s|/releases/download/v[0-9][^/]*/orbitor-desktop|/releases/download/v${SEMVER}/orbitor-desktop|" \ -e "s|PLACEHOLDER_DARWIN_ARM64_SHA256|${SHA_DARWIN_ARM64}|" \ -e "s|PLACEHOLDER_DARWIN_AMD64_SHA256|${SHA_DARWIN_AMD64}|" \ -e "s|PLACEHOLDER_LINUX_ARM64_SHA256|${SHA_LINUX_ARM64}|" \ -e "s|PLACEHOLDER_LINUX_AMD64_SHA256|${SHA_LINUX_AMD64}|" \ + -e "s|PLACEHOLDER_DESKTOP_MACOS_SHA256|${SHA_DESKTOP_MACOS}|" \ Formula/orbitor.rb > /tmp/tap/Formula/orbitor.rb cd /tmp/tap diff --git a/Formula/orbitor.rb b/Formula/orbitor.rb index 1d30b81..330617c 100644 --- a/Formula/orbitor.rb +++ b/Formula/orbitor.rb @@ -12,6 +12,11 @@ class Orbitor < Formula url "https://github.com/will-osborne/orbitor/releases/download/v#{version}/orbitor-darwin-amd64" sha256 "PLACEHOLDER_DARWIN_AMD64_SHA256" end + + resource "desktop" do + url "https://github.com/will-osborne/orbitor/releases/download/v0.1.0/orbitor-desktop-macos.zip" + sha256 "PLACEHOLDER_DESKTOP_MACOS_SHA256" + end end on_linux do @@ -27,6 +32,12 @@ class Orbitor < Formula def install bin.install Dir["orbitor-*"].first => "orbitor" + + if OS.mac? + resource("desktop").stage do + prefix.install "Orbitor.app" + end + end end def post_install @@ -34,6 +45,26 @@ def post_install # Restart the background service after upgrade so the new binary is used. # quiet_system avoids errors when the service isn't running yet. quiet_system "brew", "services", "restart", "orbitor" + + if OS.mac? + user_apps = Pathname.new(ENV["HOME"]) / "Applications" + user_apps.mkpath + app_dest = user_apps / "Orbitor.app" + app_src = opt_prefix / "Orbitor.app" + if app_dest.exist? + # macOS 13+ sets com.apple.provenance on app bundles; remove it so + # the bundle can be deleted. On macOS 15+ (Darwin 24+), ~/Applications + # is TCC-protected and brew's subprocess may lack permission — fall back + # to a manual-install instruction in that case. + quiet_system "xattr", "-d", "com.apple.provenance", app_dest.to_s + quiet_system "rm", "-rf", app_dest.to_s + end + unless quiet_system("ditto", app_src.to_s, app_dest.to_s) + opoo "Could not update #{app_dest.basename} automatically (macOS permission restriction)." + opoo "Run this from your terminal to update it:" + opoo " ditto #{app_src} #{app_dest}" + end + end end service do @@ -50,12 +81,12 @@ def caveats orbitor setup To start the server as a background service: - orbitor service install - or via Homebrew services: brew services start orbitor Open the TUI: orbitor + + The macOS desktop app has been installed to ~/Applications/Orbitor.app. EOS end diff --git a/acp.go b/acp.go index f2729b4..eeb1ec0 100644 --- a/acp.go +++ b/acp.go @@ -229,9 +229,12 @@ func (c *ACPClient) Initialize() (*InitializeResult, error) { return &result, nil } -// SessionNew creates a new ACP session. -func (c *ACPClient) SessionNew(cwd string) (string, error) { - resp, err := c.Call("session/new", SessionNewParams{CWD: cwd, MCPServers: []any{}}) +// SessionNew creates a new ACP session with optional MCP server configurations. +func (c *ACPClient) SessionNew(cwd string, mcpServers []any) (string, error) { + if mcpServers == nil { + mcpServers = []any{} + } + resp, err := c.Call("session/new", SessionNewParams{CWD: cwd, MCPServers: mcpServers}) if err != nil { return "", err } diff --git a/client_config.go b/client_config.go index 579959f..b145af8 100644 --- a/client_config.go +++ b/client_config.go @@ -59,6 +59,116 @@ func ClientConfigPath() (string, error) { return filepath.Join(home, ".orbitor", "config.json"), nil } +// LoadMCPServers reads MCP server definitions from the backend's native config +// file and returns them as a slice suitable for the ACP session/new mcpServers +// parameter. Returns an empty slice (never nil) if no servers are configured. +// +// Claude Code: ~/.claude.json → { "mcpServers": { "name": { ... } } } +// Copilot CLI: ~/.copilot/mcp-config.json → { "mcpServers": { "name": { ... } } } +// +// Both also support a project-local .mcp.json in the working directory. +func LoadMCPServers(backend, workingDir string) []any { + home, err := os.UserHomeDir() + if err != nil { + return []any{} + } + + // Determine config file paths to read (global + project-local). + var paths []string + switch backend { + case "claude": + paths = append(paths, filepath.Join(home, ".claude.json")) + case "copilot": + paths = append(paths, filepath.Join(home, ".copilot", "mcp-config.json")) + } + if workingDir != "" { + paths = append(paths, filepath.Join(workingDir, ".mcp.json")) + } + + // Merge servers from all config files (later files override earlier ones). + merged := map[string]json.RawMessage{} + for _, p := range paths { + data, err := os.ReadFile(p) + if err != nil { + continue + } + var cfg struct { + MCPServers map[string]json.RawMessage `json:"mcpServers"` + } + if json.Unmarshal(data, &cfg) != nil || cfg.MCPServers == nil { + continue + } + for name, server := range cfg.MCPServers { + merged[name] = server + } + } + + if len(merged) == 0 { + return []any{} + } + + // Convert the name→config map into the ACP array format. + // ACP expects a different schema than the native config files: + // - headers: array of [key, value] pairs (not an object) + // - env: array of [key, value] pairs (not an object) + // - type "local" → "stdio" + // - Extra fields (source, sourcePath, tools) are stripped. + var servers []any + for name, raw := range merged { + var obj map[string]any + if json.Unmarshal(raw, &obj) != nil { + continue + } + obj["name"] = name + + // Normalize type: "local" is an alias for "stdio" in some configs. + if t, ok := obj["type"].(string); ok && t == "local" { + obj["type"] = "stdio" + } + + // Convert headers object → array of [key, value] pairs. + if h, ok := obj["headers"].(map[string]any); ok { + pairs := make([]any, 0, len(h)) + for k, v := range h { + pairs = append(pairs, []any{k, v}) + } + obj["headers"] = pairs + } else if obj["headers"] == nil { + obj["headers"] = []any{} + } + + // Convert env object → array of {name, value} objects. + if e, ok := obj["env"].(map[string]any); ok { + entries := make([]any, 0, len(e)) + for k, v := range e { + entries = append(entries, map[string]any{"name": k, "value": v}) + } + obj["env"] = entries + } + + // Ensure required fields for stdio servers. + if t, _ := obj["type"].(string); t == "stdio" { + if obj["args"] == nil { + obj["args"] = []any{} + } + if obj["env"] == nil { + obj["env"] = []any{} + } + } + + // Strip fields that are not part of the ACP schema. + delete(obj, "source") + delete(obj, "sourcePath") + delete(obj, "tools") + + servers = append(servers, obj) + } + if servers == nil { + return []any{} + } + return servers +} + // LoadClientConfig reads ~/.orbitor/config.json and merges it with built-in // defaults. Missing or unreadable config silently falls back to defaults. func LoadClientConfig() ClientConfig { diff --git a/desktop/Orbitor/Orbitor.xcodeproj/project.pbxproj b/desktop/Orbitor/Orbitor.xcodeproj/project.pbxproj index e03f71d..e3e14a2 100644 --- a/desktop/Orbitor/Orbitor.xcodeproj/project.pbxproj +++ b/desktop/Orbitor/Orbitor.xcodeproj/project.pbxproj @@ -29,6 +29,10 @@ A1B2C3D4E5F60001000A0012 /* SessionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002000A0012 /* SessionListView.swift */; }; A1B2C3D4E5F60001000A0013 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002000A0013 /* Assets.xcassets */; }; A656BEB21F4D28778B492BC9 /* Speech.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 47063F531C80C12E9B85DACC /* Speech.framework */; }; + A1B2C3D4E5F60001000A0016 /* DiffView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002000A0016 /* DiffView.swift */; }; + A1B2C3D4E5F60001000A0017 /* CommandPaletteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002000A0017 /* CommandPaletteView.swift */; }; + A1B2C3D4E5F60001000A0018 /* RunRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002000A0018 /* RunRecord.swift */; }; + A1B2C3D4E5F60001000A0019 /* RunHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002000A0019 /* RunHistoryView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -56,6 +60,10 @@ A1B2C3D4E5F60002000A0013 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A1B2C3D4E5F60002000A0014 /* Orbitor.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Orbitor.entitlements; sourceTree = ""; }; A1B2C3D4E5F60002000A0015 /* HoverEffects.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverEffects.swift; sourceTree = ""; }; + A1B2C3D4E5F60002000A0016 /* DiffView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffView.swift; sourceTree = ""; }; + A1B2C3D4E5F60002000A0017 /* CommandPaletteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteView.swift; sourceTree = ""; }; + A1B2C3D4E5F60002000A0018 /* RunRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunRecord.swift; sourceTree = ""; }; + A1B2C3D4E5F60002000A0019 /* RunHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunHistoryView.swift; sourceTree = ""; }; A1B2C3D4E5F60003000A0001 /* Orbitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Orbitor.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -101,6 +109,7 @@ children = ( A1B2C3D4E5F60002000A0002 /* SessionInfo.swift */, A1B2C3D4E5F60002000A0003 /* WSMessage.swift */, + A1B2C3D4E5F60002000A0018 /* RunRecord.swift */, ); path = Models; sourceTree = ""; @@ -138,6 +147,7 @@ children = ( A1B2C3D4E5F60002000A000A /* ContentView.swift */, A1B2C3D4E5F60005000A0008 /* Chat */, + A1B2C3D4E5F60005000A000C /* CommandPalette */, A1B2C3D4E5F60005000A0009 /* Inspector */, A1B2C3D4E5F60005000A000A /* Shared */, A1B2C3D4E5F60005000A000B /* Sidebar */, @@ -159,6 +169,7 @@ isa = PBXGroup; children = ( A1B2C3D4E5F60002000A000E /* InspectorView.swift */, + A1B2C3D4E5F60002000A0019 /* RunHistoryView.swift */, ); path = Inspector; sourceTree = ""; @@ -167,12 +178,21 @@ isa = PBXGroup; children = ( A1B2C3D4E5F60002000A000F /* CodeBlockView.swift */, + A1B2C3D4E5F60002000A0016 /* DiffView.swift */, A1B2C3D4E5F60002000A0015 /* HoverEffects.swift */, A1B2C3D4E5F60002000A0010 /* StatusBadge.swift */, ); path = Shared; sourceTree = ""; }; + A1B2C3D4E5F60005000A000C /* CommandPalette */ = { + isa = PBXGroup; + children = ( + A1B2C3D4E5F60002000A0017 /* CommandPaletteView.swift */, + ); + path = CommandPalette; + sourceTree = ""; + }; A1B2C3D4E5F60005000A000B /* Sidebar */ = { isa = PBXGroup; children = ( @@ -287,6 +307,10 @@ A1B2C3D4E5F60001000A0011 /* NewSessionSheet.swift in Sources */, A1B2C3D4E5F60001000A0012 /* SessionListView.swift in Sources */, 143763597DA538F7889F8A20 /* DictationState.swift in Sources */, + A1B2C3D4E5F60001000A0016 /* DiffView.swift in Sources */, + A1B2C3D4E5F60001000A0017 /* CommandPaletteView.swift in Sources */, + A1B2C3D4E5F60001000A0018 /* RunRecord.swift in Sources */, + A1B2C3D4E5F60001000A0019 /* RunHistoryView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/desktop/Orbitor/Orbitor/Models/RunRecord.swift b/desktop/Orbitor/Orbitor/Models/RunRecord.swift new file mode 100644 index 0000000..f479e63 --- /dev/null +++ b/desktop/Orbitor/Orbitor/Models/RunRecord.swift @@ -0,0 +1,18 @@ +import Foundation + +struct RunRecord: Codable, Identifiable { + let id: String + let prompt: String + let startedAt: Date + let completedAt: Date? + let files: [FileChange] +} + +struct FileChange: Codable, Identifiable { + let path: String + let relativePath: String + let before: String + let after: String + + var id: String { path } +} diff --git a/desktop/Orbitor/Orbitor/Models/WSMessage.swift b/desktop/Orbitor/Orbitor/Models/WSMessage.swift index cb840e3..244b47a 100644 --- a/desktop/Orbitor/Orbitor/Models/WSMessage.swift +++ b/desktop/Orbitor/Orbitor/Models/WSMessage.swift @@ -23,6 +23,8 @@ enum ChatMessage: Identifiable { case error(id: UUID, message: String, timestamp: Date) /// Bulk history load — all messages arrive at once to avoid per-message re-renders. case historyBatch(id: UUID, messages: [ChatMessage], timestamp: Date) + /// Session status change from the server (e.g. "respawning"). + case sessionStatus(id: UUID, status: String, timestamp: Date) var id: UUID { switch self { @@ -35,7 +37,8 @@ enum ChatMessage: Identifiable { .runComplete(let id, _, _), .interrupted(let id, _), .error(let id, _, _), - .historyBatch(let id, _, _): + .historyBatch(let id, _, _), + .sessionStatus(let id, _, _): return id } } @@ -51,7 +54,8 @@ enum ChatMessage: Identifiable { .runComplete(_, _, let t), .interrupted(_, let t), .error(_, _, let t), - .historyBatch(_, _, let t): + .historyBatch(_, _, let t), + .sessionStatus(_, _, let t): return t } } diff --git a/desktop/Orbitor/Orbitor/Networking/APIClient.swift b/desktop/Orbitor/Orbitor/Networking/APIClient.swift index 9ff697b..19355ee 100644 --- a/desktop/Orbitor/Orbitor/Networking/APIClient.swift +++ b/desktop/Orbitor/Orbitor/Networking/APIClient.swift @@ -95,6 +95,44 @@ final class APIClient: Sendable { let (data, _) = try await session.data(from: components.url!) return try decoder.decode([BrowseEntry].self, from: data) } + + // MARK: - LLM helpers + + /// Rewrites a rough prompt into a more precise instruction. + func enhancePrompt(_ text: String) async throws -> String { + var request = URLRequest(url: baseURL.appendingPathComponent("api/enhance-prompt")) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(["text": text]) + let (data, _) = try await session.data(for: request) + let resp = try JSONDecoder().decode([String: String].self, from: data) + return resp["enhanced"] ?? text + } + + /// Returns a post-run debrief summary for a session. + func sessionDebrief(id: String) async throws -> String { + let url = baseURL.appendingPathComponent("api/sessions/\(id)/debrief") + let (data, _) = try await session.data(from: url) + let resp = try JSONDecoder().decode([String: String].self, from: data) + return resp["debrief"] ?? "" + } + + /// Returns up to 3 follow-up prompt suggestions for a session. + func sessionSuggestions(id: String) async throws -> [String] { + let url = baseURL.appendingPathComponent("api/sessions/\(id)/suggestions") + let (data, _) = try await session.data(from: url) + struct Resp: Decodable { let suggestions: [String] } + let resp = try JSONDecoder().decode(Resp.self, from: data) + return resp.suggestions + } + + /// Returns the per-run file change history for a session. + func sessionRunHistory(id: String) async throws -> [RunRecord] { + let url = baseURL.appendingPathComponent("api/sessions/\(id)/run-history") + let (data, _) = try await session.data(from: url) + struct Resp: Decodable { let runs: [RunRecord] } + return try decoder.decode(Resp.self, from: data).runs + } } struct BrowseEntry: Codable, Identifiable { diff --git a/desktop/Orbitor/Orbitor/Networking/WebSocketClient.swift b/desktop/Orbitor/Orbitor/Networking/WebSocketClient.swift index 0ce4412..b41a604 100644 --- a/desktop/Orbitor/Orbitor/Networking/WebSocketClient.swift +++ b/desktop/Orbitor/Orbitor/Networking/WebSocketClient.swift @@ -110,11 +110,9 @@ final class WebSocketClient: @unchecked Sendable { let now = Date() - if envelope.type == "history", let messages = envelope.messages { - let parsed = messages.compactMap { parseEnvelope($0, timestamp: now) } - if !parsed.isEmpty { - continuation?.yield(.historyBatch(id: UUID(), messages: parsed, timestamp: now)) - } + if envelope.type == "history" { + let parsed = (envelope.messages ?? []).compactMap { parseEnvelope($0, timestamp: now) } + continuation?.yield(.historyBatch(id: UUID(), messages: parsed, timestamp: now)) return } @@ -196,6 +194,10 @@ final class WebSocketClient: @unchecked Sendable { let msg = envelope.data?["message"]?.stringValue ?? "Unknown error" return .error(id: id, message: msg, timestamp: timestamp) + case "status": + let status = envelope.data?["status"]?.stringValue ?? "" + return .sessionStatus(id: id, status: status, timestamp: timestamp) + default: return nil } diff --git a/desktop/Orbitor/Orbitor/OrbitorApp.swift b/desktop/Orbitor/Orbitor/OrbitorApp.swift index c268aae..0d878ec 100644 --- a/desktop/Orbitor/Orbitor/OrbitorApp.swift +++ b/desktop/Orbitor/Orbitor/OrbitorApp.swift @@ -1,7 +1,23 @@ import SwiftUI +import UserNotifications + +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + let center = UNUserNotificationCenter.current() + center.delegate = NotificationDelegate.shared + center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + if let error { + print("[Notifications] authorization error: \(error)") + } else if !granted { + print("[Notifications] permission denied") + } + } + } +} @main struct OrbitorApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @State private var appState = AppState() var body: some Scene { @@ -48,6 +64,13 @@ struct AppCommands: Commands { .disabled(appState.sessionList.selectedSessionID == nil) } + CommandGroup(after: .newItem) { + Button("Command Palette") { + appState.showCommandPalette = true + } + .keyboardShortcut("k", modifiers: .command) + } + CommandMenu("Session") { Button("Interrupt") { Task { await appState.chat.interrupt() } diff --git a/desktop/Orbitor/Orbitor/State/AppState.swift b/desktop/Orbitor/Orbitor/State/AppState.swift index 6f974f9..010d205 100644 --- a/desktop/Orbitor/Orbitor/State/AppState.swift +++ b/desktop/Orbitor/Orbitor/State/AppState.swift @@ -13,6 +13,7 @@ final class AppState { var showNewSession = false var showForkSheet = false + var showCommandPalette = false var fontSize: CGFloat { didSet { UserDefaults.standard.set(fontSize, forKey: "fontSize") } } diff --git a/desktop/Orbitor/Orbitor/State/ChatState.swift b/desktop/Orbitor/Orbitor/State/ChatState.swift index 45d9833..5ab3cc9 100644 --- a/desktop/Orbitor/Orbitor/State/ChatState.swift +++ b/desktop/Orbitor/Orbitor/State/ChatState.swift @@ -32,6 +32,23 @@ final class ChatState { /// Queued prompts waiting to be sent when the current run finishes. var queuedPrompts: [String] = [] + /// True while a reconnect delay is counting down. + var isReconnecting = false + /// Number of reconnect attempts since last clean connect (for backoff). + private var reconnectAttempts = 0 + /// Tracks whether at least one message has arrived on the current connection. + private var hasReceivedFirstMessage = false + + // MARK: - Run analytics + /// When the current (or most recent) run started. + var runStartedAt: Date? + /// Duration of the most recently completed run. + var lastRunDuration: TimeInterval? + /// Unique file paths touched in this session (from tool call titles for write/edit ops). + var filesTouched: [String] = [] + /// Number of error messages encountered in this session. + var errorCount: Int = 0 + private var baseURL: URL private var wsClient: WebSocketClient? private var streamTask: Task? @@ -49,24 +66,9 @@ final class ChatState { init(baseURL: URL) { self.baseURL = baseURL - requestNotificationPermission() - } - - private func requestNotificationPermission() { - let center = UNUserNotificationCenter.current() - center.delegate = NotificationDelegate.shared - center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in - if let error { - print("[Notifications] authorization error: \(error)") - } else if !granted { - print("[Notifications] user denied notification permission") - } - } } private func postNotification(title: String, body: String) { - // Skip if the app window is currently active - guard !NSApp.isActive else { return } let content = UNMutableNotificationContent() content.title = title content.body = body @@ -96,20 +98,30 @@ final class ChatState { @MainActor func connectToSession(_ sessionID: String) { guard sessionID != activeSessionID else { return } - streamTask?.cancel() wsClient?.disconnect() + reconnectAttempts = 0 + setupConnection(sessionID: sessionID) + } + @MainActor + private func setupConnection(sessionID: String) { activeSessionID = sessionID allMessages = [] messages = [] visibleStart = 0 isRunning = false isConnecting = true + isReconnecting = false pendingPermission = nil toolCallCache = [:] queuedPrompts = [] isLoadingHistory = true + hasReceivedFirstMessage = false + runStartedAt = nil + lastRunDuration = nil + filesTouched = [] + errorCount = 0 let client = WebSocketClient(baseURL: baseURL) wsClient = client @@ -120,9 +132,29 @@ final class ChatState { guard !Task.isCancelled else { break } await self?.handleMessage(message) } + // Stream ended — schedule a reconnect if still on this session and not cancelled + guard !Task.isCancelled, let self else { return } + await self.handleStreamEnded(forSession: sessionID) } } + /// Called when the WebSocket stream ends unexpectedly. Schedules a reconnect with + /// exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s. + @MainActor + private func handleStreamEnded(forSession sessionID: String) async { + guard activeSessionID == sessionID else { return } + reconnectAttempts += 1 + let delay = min(pow(2.0, Double(reconnectAttempts - 1)), 30.0) + isReconnecting = true + isConnecting = false + isLoadingHistory = false + try? await Task.sleep(for: .seconds(delay)) + guard activeSessionID == sessionID else { return } + streamTask?.cancel() + wsClient?.disconnect() + setupConnection(sessionID: sessionID) + } + @MainActor func disconnect() { streamTask?.cancel() @@ -183,7 +215,13 @@ final class ChatState { @MainActor private func handleMessage(_ message: ChatMessage) { - isConnecting = false + // Reset reconnect backoff on first successful message + if !hasReceivedFirstMessage { + hasReceivedFirstMessage = true + reconnectAttempts = 0 + } + // Don't clear isConnecting for status messages — they may set it to true. + if case .sessionStatus = message { } else { isConnecting = false } switch message { case .agentText(_, let text, _): @@ -218,6 +256,13 @@ final class ChatState { } else { toolCallCache[call.toolCallId] = allMessages.count appendLive(message) + // Track file-touching operations + let fileKinds: Set = ["write", "edit", "create", "patch"] + if fileKinds.contains(call.kind.lowercased()), !call.title.isEmpty { + if !filesTouched.contains(call.title) { + filesTouched.append(call.title) + } + } } isLoadingHistory = false @@ -233,18 +278,24 @@ final class ChatState { case .promptSent: isRunning = true isLoadingHistory = false + runStartedAt = Date() appendLive(message) case .runComplete: + if let start = runStartedAt { + lastRunDuration = Date().timeIntervalSince(start) + } isRunning = false appendLive(message) - postNotification(title: "Agent Finished", body: "Session completed") // Send next queued prompt if any Task { @MainActor in await drainQueue() } case .interrupted: + if let start = runStartedAt { + lastRunDuration = Date().timeIntervalSince(start) + } isRunning = false appendLive(message) // Send next queued prompt if any @@ -268,6 +319,17 @@ final class ChatState { trimToTail() isLoadingHistory = false + case .sessionStatus(_, let status, _): + if status == "respawning" { + isConnecting = true + isRunning = false + isLoadingHistory = true + } + + case .error: + errorCount += 1 + appendLive(message) + default: appendLive(message) } diff --git a/desktop/Orbitor/Orbitor/State/DictationState.swift b/desktop/Orbitor/Orbitor/State/DictationState.swift index d4ae271..ec5d794 100644 --- a/desktop/Orbitor/Orbitor/State/DictationState.swift +++ b/desktop/Orbitor/Orbitor/State/DictationState.swift @@ -21,8 +21,12 @@ final class DictationState { private let holdThreshold: TimeInterval = 0.4 // hold 400ms to activate /// Called when dictation finishes — set by PromptInputView to append text. var onDictationComplete: ((String) -> Void)? + /// Called to insert a space when user does a quick tap (not a hold). + var onInsertSpace: (() -> Void)? /// Whether the prompt input is empty (only start dictation when empty). var promptIsEmpty = true + /// Whether we swallowed the initial space press and are waiting to see if it's a hold. + private var pendingSpace = false init() { SFSpeechRecognizer.requestAuthorization { [weak self] status in @@ -67,15 +71,20 @@ final class DictationState { // Check if we've held long enough if !spaceIsHeld, let downTime = spaceDownTime, Date().timeIntervalSince(downTime) >= holdThreshold, - promptIsEmpty, isAvailable { + isAvailable { spaceIsHeld = true + pendingSpace = false DispatchQueue.main.async { self.startRecording() } } - return spaceIsHeld ? nil : event + return (spaceIsHeld || pendingSpace) ? nil : event } - // Initial press + // Initial press — swallow if prompt is empty and dictation could activate spaceDownTime = Date() spaceIsHeld = false + if promptIsEmpty && isAvailable { + pendingSpace = true + return nil + } return event } @@ -83,6 +92,7 @@ final class DictationState { if isRecording || spaceIsHeld { spaceIsHeld = false spaceDownTime = nil + pendingSpace = false DispatchQueue.main.async { let result = self.stopRecording() if !result.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -91,6 +101,15 @@ final class DictationState { } return nil } + // Quick tap — insert the space we swallowed + if pendingSpace { + pendingSpace = false + spaceDownTime = nil + DispatchQueue.main.async { + self.onInsertSpace?() + } + return nil + } spaceDownTime = nil spaceIsHeld = false return event diff --git a/desktop/Orbitor/Orbitor/State/SessionListState.swift b/desktop/Orbitor/Orbitor/State/SessionListState.swift index 3cb02c4..ffd09e0 100644 --- a/desktop/Orbitor/Orbitor/State/SessionListState.swift +++ b/desktop/Orbitor/Orbitor/State/SessionListState.swift @@ -1,4 +1,6 @@ +import AppKit import Foundation +import UserNotifications @Observable final class SessionListState { @@ -6,14 +8,22 @@ final class SessionListState { var selectedSessionID: String? var isLoading = false var error: String? + /// Sessions that have completed a run since the user last viewed them. + var unreadSessionIDs: Set = [] private var api: APIClient private var pollingTask: Task? + /// Previous isRunning state per session, used to detect run completions. + private var prevRunningStates: [String: Bool] = [:] init(api: APIClient) { self.api = api } + func markRead(_ id: String) { + unreadSessionIDs.remove(id) + } + func updateAPI(_ newAPI: APIClient) { self.api = newAPI } @@ -41,17 +51,45 @@ final class SessionListState { func refresh() async { do { let fetched = try await api.listSessions() - // Preserve selection even if list order changes - sessions = fetched.sorted { ($0.createdAt) > ($1.createdAt) } + sessions = fetched.sorted { $0.createdAt > $1.createdAt } error = nil if selectedSessionID == nil, let first = sessions.first { selectedSessionID = first.id } + detectCompletions(in: fetched) } catch { self.error = error.localizedDescription } } + /// Detect sessions that transitioned from running → idle since the last poll + /// and mark them unread / fire a notification. + @MainActor + private func detectCompletions(in fetched: [SessionInfo]) { + for session in fetched { + let wasRunning = prevRunningStates[session.id] ?? false + prevRunningStates[session.id] = session.isRunning + + guard wasRunning && !session.isRunning else { continue } + // Session just finished — mark unread if not currently selected. + if session.id != selectedSessionID { + unreadSessionIDs.insert(session.id) + } + // Skip notification only when the user is actively watching this + // exact session. The NotificationDelegate shows banners even when + // the app is focused, so other sessions still notify. + if session.id == selectedSessionID && NSApp.isActive { continue } + let content = UNMutableNotificationContent() + content.title = "Agent Finished" + content.body = session.displayTitle + content.sound = .default + let req = UNNotificationRequest(identifier: "run-\(session.id)-\(Date().timeIntervalSince1970)", content: content, trigger: nil) + UNUserNotificationCenter.current().add(req) { err in + if let err { print("[Notifications] \(err)") } + } + } + } + @MainActor func createSession(workingDir: String, backend: String, model: String, skip: Bool = false, plan: Bool = false) async { do { diff --git a/desktop/Orbitor/Orbitor/Views/Chat/ChatView.swift b/desktop/Orbitor/Orbitor/Views/Chat/ChatView.swift index 92c5af0..e47f97a 100644 --- a/desktop/Orbitor/Orbitor/Views/Chat/ChatView.swift +++ b/desktop/Orbitor/Orbitor/Views/Chat/ChatView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers // MARK: - Turn grouping @@ -94,8 +95,11 @@ struct ChatView: View { @Environment(AppState.self) private var appState @Environment(\.theme) private var theme @State private var promptText = "" + @State private var pendingAttachments: [PromptAttachment] = [] @State private var scrollToBottom = true @State private var suppressNextScroll = false + @State private var isAtBottom = true + @State private var showRunCard = false var body: some View { let displayItems = buildDisplayItems(from: appState.chat.messages) @@ -108,7 +112,7 @@ struct ChatView: View { // Message list ScrollViewReader { proxy in - ZStack { + ZStack(alignment: .bottomTrailing) { // Connecting overlay if appState.chat.isConnecting && appState.chat.messages.isEmpty { VStack(spacing: 16) { @@ -174,10 +178,16 @@ struct ChatView: View { Color.clear .frame(height: 1) .id("bottom") + .onAppear { isAtBottom = true } + .onDisappear { isAtBottom = false } } .padding() } .background(theme.panel) + .onDrop(of: [UTType.fileURL, UTType.image, UTType.png, UTType.jpeg], isTargeted: nil) { providers in + handleDrop(providers) + return true + } .onChange(of: appState.chat.messages.count) { if suppressNextScroll { suppressNextScroll = false @@ -189,15 +199,48 @@ struct ChatView: View { } } } + .onChange(of: appState.chat.activeSessionID) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + proxy.scrollTo("bottom", anchor: .bottom) + } + } + + // Jump-to-bottom floating button + if !isAtBottom { + Button { + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo("bottom", anchor: .bottom) + } + } label: { + Image(systemName: "arrow.down.circle.fill") + .font(.title2) + .foregroundStyle(theme.accent) + .shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2) + } + .buttonStyle(.plain) + .padding(12) + .transition(.scale.combined(with: .opacity)) + } } // ZStack } Divider().background(theme.sep) + // Post-run card (shown after a run completes) + if showRunCard && !appState.chat.isRunning { + RunCompleteCard( + duration: appState.chat.lastRunDuration, + filesTouched: appState.chat.filesTouched, + sessionID: appState.chat.activeSessionID, + onDismiss: { showRunCard = false } + ) + } + // Working indicator - if appState.chat.isRunning || appState.chat.isConnecting || appState.chat.isLoadingHistory { + if appState.chat.isRunning || appState.chat.isConnecting || appState.chat.isLoadingHistory || appState.chat.isReconnecting { WorkingIndicator( isConnecting: appState.chat.isConnecting || appState.chat.isLoadingHistory, + isReconnecting: appState.chat.isReconnecting, queuedPrompts: appState.chat.queuedPrompts, onRemoveQueued: { index in appState.chat.removeQueuedPrompt(at: index) @@ -206,22 +249,87 @@ struct ChatView: View { } // Input area - PromptInputView(text: $promptText, onSubmit: { - Task { - let text = promptText - promptText = "" - await appState.chat.sendPrompt(text) - } - }, onForkSubmit: { - guard let sourceID = appState.sessionList.selectedSessionID else { return } - let text = promptText - promptText = "" - Task { - await appState.sessionList.forkSession(sourceID: sourceID, prompt: text) + PromptInputView( + text: $promptText, + attachments: $pendingAttachments, + onSubmit: { fullText in + showRunCard = false + Task { await appState.chat.sendPrompt(fullText) } + }, + onForkSubmit: { fullText in + guard let sourceID = appState.sessionList.selectedSessionID else { return } + showRunCard = false + Task { await appState.sessionList.forkSession(sourceID: sourceID, prompt: fullText) } } - }) + ) } .background(theme.panel) + .onChange(of: appState.chat.isRunning) { _, running in + if !running && appState.chat.lastRunDuration != nil { + showRunCard = true + } + } + } + + // MARK: - Drop handling + + private func handleDrop(_ providers: [NSItemProvider]) { + // Use a temporary PromptInputView-style helper via a local closure + let imageExts: Set = ["png", "jpg", "jpeg", "gif", "webp", "heic", "bmp", "tiff", "tif"] + + for provider in providers { + if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in + guard let data = item as? Data, + let urlStr = String(data: data, encoding: .utf8), + let url = URL(string: urlStr) else { return } + let ext = url.pathExtension.lowercased() + if imageExts.contains(ext) { + let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("orbitor_\(UUID().uuidString).\(ext)") + try? FileManager.default.copyItem(at: url, to: tempURL) + let thumb = NSImage(contentsOf: url) + DispatchQueue.main.async { + pendingAttachments.append(PromptAttachment( + name: url.lastPathComponent, + content: tempURL.path, + isImage: true, + thumbnail: thumb + )) + } + } else { + guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? Int, size < 500_000, + let content = try? String(contentsOf: url, encoding: .utf8) else { return } + DispatchQueue.main.async { + pendingAttachments.append(PromptAttachment( + name: url.lastPathComponent, + content: content, + isImage: false, + thumbnail: nil + )) + } + } + } + } else { + // Direct image drop (e.g. from browser or image viewer) + let typeID = UTType.png.identifier + provider.loadDataRepresentation(forTypeIdentifier: typeID) { data, _ in + guard let data, let image = NSImage(data: data) else { return } + let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("orbitor_drop_\(UUID().uuidString).png") + try? data.write(to: tempURL) + DispatchQueue.main.async { + pendingAttachments.append(PromptAttachment( + name: "dropped_image.png", + content: tempURL.path, + isImage: true, + thumbnail: image + )) + } + } + } + } } } @@ -364,6 +472,7 @@ private struct WorkingStatusLabel: View { struct WorkingIndicator: View { var isConnecting: Bool + var isReconnecting: Bool = false var queuedPrompts: [String] var onRemoveQueued: (Int) -> Void @Environment(\.theme) private var theme @@ -371,7 +480,9 @@ struct WorkingIndicator: View { @State private var showQueue = false private var label: String { - if isConnecting { + if isReconnecting { + return "Reconnecting…" + } else if isConnecting { return "Connecting…" } else if !queuedPrompts.isEmpty { return "Working… (\(queuedPrompts.count) queued)" @@ -482,3 +593,144 @@ struct QueuedPromptRow: View { } } } + +// MARK: - Run Complete Card + +private struct RunCompleteCard: View { + let duration: TimeInterval? + let filesTouched: [String] + let sessionID: String? + let onDismiss: () -> Void + @Environment(AppState.self) private var appState + @Environment(\.theme) private var theme + @State private var debriefText: String? = nil + @State private var isLoadingDebrief = false + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Divider().background(theme.border) + HStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(theme.green) + .font(.callout) + + if let dur = duration { + Text("Run complete · \(formatDuration(dur))") + .font(.caption.weight(.medium)) + .foregroundStyle(theme.text) + } + + if !filesTouched.isEmpty { + Text("· \(filesTouched.count) file\(filesTouched.count == 1 ? "" : "s") changed") + .font(.caption) + .foregroundStyle(theme.muted) + } + + Spacer() + + if sessionID != nil { + Button { + // Check before toggling: if we're about to expand and haven't loaded yet, fetch + let willExpand = !expanded + withAnimation(.easeInOut(duration: 0.15)) { + expanded.toggle() + } + if willExpand && debriefText == nil { + loadDebrief() + } + } label: { + HStack(spacing: 3) { + Image(systemName: "sparkles") + .font(.caption2) + Text("Debrief") + .font(.caption) + } + .foregroundStyle(theme.accent) + } + .buttonStyle(.plain) + } + + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .font(.caption2) + .foregroundStyle(theme.muted) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + + if expanded { + VStack(alignment: .leading, spacing: 6) { + if !filesTouched.isEmpty { + Text("Files changed:") + .font(.caption.weight(.semibold)) + .foregroundStyle(theme.muted) + ForEach(filesTouched.prefix(8), id: \.self) { path in + HStack(spacing: 4) { + Image(systemName: "pencil.circle.fill") + .font(.system(size: 9)) + .foregroundStyle(theme.cyan) + Text(path) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(theme.text) + .lineLimit(1) + .truncationMode(.middle) + } + } + if filesTouched.count > 8 { + Text("…and \(filesTouched.count - 8) more") + .font(.caption2) + .foregroundStyle(theme.muted) + } + } + + if isLoadingDebrief { + HStack(spacing: 6) { + ProgressView().controlSize(.mini) + Text("Generating summary…") + .font(.caption) + .foregroundStyle(theme.muted) + } + } else if let text = debriefText, !text.isEmpty { + Divider().background(theme.border) + ScrollView { + MarkdownTextView(text: text) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 180) + } else if !isLoadingDebrief { + Text("No summary available.") + .font(.caption) + .foregroundStyle(theme.muted) + } + } + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + } + .background(theme.green.opacity(0.05)) + .animation(.easeInOut(duration: 0.15), value: expanded) + } + + private func loadDebrief() { + guard let id = sessionID else { return } + isLoadingDebrief = true + Task { + let text = try? await appState.api.sessionDebrief(id: id) + await MainActor.run { + debriefText = text + isLoadingDebrief = false + } + } + } + + private func formatDuration(_ t: TimeInterval) -> String { + let total = Int(t) + if total < 60 { return "\(total)s" } + return "\(total / 60)m \(total % 60)s" + } +} diff --git a/desktop/Orbitor/Orbitor/Views/Chat/MessageView.swift b/desktop/Orbitor/Orbitor/Views/Chat/MessageView.swift index dca2ae6..2c77a63 100644 --- a/desktop/Orbitor/Orbitor/Views/Chat/MessageView.swift +++ b/desktop/Orbitor/Orbitor/Views/Chat/MessageView.swift @@ -44,6 +44,9 @@ struct MessageView: View { case .historyBatch: EmptyView() + + case .sessionStatus: + EmptyView() } } } @@ -221,10 +224,17 @@ struct ToolCallView: View { // Content (expandable) if isExpanded, let content = call.content, !content.isEmpty { Divider().padding(.horizontal, 10) - CodeBlockView(code: content, language: call.kind) - .padding(.horizontal, 6) - .padding(.bottom, 6) - .transition(.opacity.combined(with: .scale(scale: 0.98, anchor: .top))) + if looksLikeDiff(content) { + DiffView(diff: content) + .padding(.horizontal, 6) + .padding(.bottom, 6) + .transition(.opacity.combined(with: .scale(scale: 0.98, anchor: .top))) + } else { + CodeBlockView(code: content, language: call.kind) + .padding(.horizontal, 6) + .padding(.bottom, 6) + .transition(.opacity.combined(with: .scale(scale: 0.98, anchor: .top))) + } } } .background(theme.selBg.opacity(0.3)) diff --git a/desktop/Orbitor/Orbitor/Views/Chat/PromptInputView.swift b/desktop/Orbitor/Orbitor/Views/Chat/PromptInputView.swift index 078e123..2f9ead9 100644 --- a/desktop/Orbitor/Orbitor/Views/Chat/PromptInputView.swift +++ b/desktop/Orbitor/Orbitor/Views/Chat/PromptInputView.swift @@ -1,137 +1,233 @@ +import AppKit import SwiftUI +import UniformTypeIdentifiers + +// MARK: - Attachment model + +struct PromptAttachment: Identifiable { + let id = UUID() + let name: String + /// For images: path to the temp file. For text: the file content to embed. + let content: String + let isImage: Bool + let thumbnail: NSImage? +} + +// MARK: - Prompt input view struct PromptInputView: View { @Binding var text: String - var onSubmit: () -> Void - var onForkSubmit: (() -> Void)? + @Binding var attachments: [PromptAttachment] + /// Called with the fully-built prompt text (includes attachment content). + var onSubmit: (String) -> Void + var onForkSubmit: ((String) -> Void)? @Environment(AppState.self) private var appState @Environment(\.theme) private var theme @FocusState private var isFocused: Bool + @State private var historyIndex = -1 + @State private var suggestions: [String] = [] + @State private var isEnhancing = false + + private var estimatedTokens: Int { max(1, text.count / 4) } var body: some View { - HStack(alignment: .bottom, spacing: 8) { - ZStack(alignment: .topLeading) { - if text.isEmpty && !appState.dictation.isRecording { - Text("Type a prompt and press ⌘Enter...") - .foregroundStyle(theme.muted) - .padding(.horizontal, 8) - .padding(.top, 8) - } - if appState.dictation.isRecording { - HStack(spacing: 8) { - Image(systemName: "mic.fill") - .foregroundStyle(theme.red) - .symbolEffect(.pulse) - Text(appState.dictation.transcribedText.isEmpty ? "Listening... (release Space to stop)" : appState.dictation.transcribedText) - .foregroundStyle(theme.text) - .italic() - } - .padding(.horizontal, 8) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, minHeight: 36, alignment: .leading) - } else { - TextEditor(text: $text) - .font(.system(size: appState.fontSize, design: .monospaced)) - .foregroundStyle(theme.text) - .scrollContentBackground(.hidden) - .focused($isFocused) - .frame(minHeight: 36, maxHeight: 120) - .fixedSize(horizontal: false, vertical: true) + VStack(spacing: 0) { + // Suggestion chips (shown when input is empty and suggestions are loaded) + if text.isEmpty && !suggestions.isEmpty { + SuggestionChipsView(suggestions: suggestions, theme: theme) { chip in + text = chip + suggestions = [] + isFocused = true } + .padding(.horizontal, 12) + .padding(.top, 6) } - .padding(4) - .background(appState.dictation.isRecording ? theme.red.opacity(0.1) : theme.selBg) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .strokeBorder( - appState.dictation.isRecording ? theme.red : - (isFocused ? theme.accent : theme.border), - lineWidth: 1 - ) - ) - HStack(spacing: 6) { - // Dictation button - if appState.dictation.isAvailable { - Button { - if appState.dictation.isRecording { - let result = appState.dictation.stopRecording() - if !result.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - text += result - } - } else { - appState.dictation.startRecording() + // Attachment chips (shown when attachments are present) + if !attachments.isEmpty { + AttachmentChipsView(attachments: $attachments, theme: theme) + .padding(.horizontal, 12) + .padding(.top, 6) + } + + HStack(alignment: .bottom, spacing: 8) { + ZStack(alignment: .topLeading) { + if text.isEmpty && !appState.dictation.isRecording { + Text("Type a prompt and press ⌘Enter…") + .foregroundStyle(theme.muted) + .padding(.horizontal, 8) + .padding(.top, 8) + } + if appState.dictation.isRecording { + HStack(spacing: 8) { + Image(systemName: "mic.fill") + .foregroundStyle(theme.red) + .symbolEffect(.pulse) + Text(appState.dictation.transcribedText.isEmpty + ? "Listening… (release Space to stop)" + : appState.dictation.transcribedText) + .foregroundStyle(theme.text) + .italic() } - } label: { - Image(systemName: appState.dictation.isRecording ? "mic.fill" : "mic") - .font(.body) - .foregroundStyle(appState.dictation.isRecording ? theme.red : theme.muted) + .padding(.horizontal, 8) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, minHeight: 36, alignment: .leading) + } else { + TextEditor(text: $text) + .font(.system(size: appState.fontSize, design: .monospaced)) + .foregroundStyle(theme.text) + .scrollContentBackground(.hidden) + .focused($isFocused) + .frame(minHeight: 36, maxHeight: 120) + .fixedSize(horizontal: false, vertical: true) } - .buttonStyle(.plain) - .hoverScale(1.15) - .help(appState.dictation.isRecording ? "Stop dictation" : "Start dictation (or hold Space)") } + .padding(4) + .background(appState.dictation.isRecording ? theme.red.opacity(0.1) : theme.selBg) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder( + appState.dictation.isRecording ? theme.red : + (isFocused ? theme.accent : theme.border), + lineWidth: 1 + ) + ) - // Fork button (Option+Enter) - if appState.sessionList.selectedSessionID != nil { + HStack(spacing: 6) { + // File attachment button Button { - onForkSubmit?() + openFilePicker() } label: { - Image(systemName: "arrow.triangle.branch") + Image(systemName: "paperclip") .font(.body) - .foregroundStyle(text.isEmpty ? theme.muted : theme.cyan) + .foregroundStyle(attachments.isEmpty ? theme.muted : theme.accent) } .buttonStyle(.plain) .hoverScale(1.15) - .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - .keyboardShortcut(.return, modifiers: .option) - .help("Fork & Send (⌥Enter)") - } + .help("Attach file") + + // Dictation button + if appState.dictation.isAvailable { + Button { + if appState.dictation.isRecording { + let result = appState.dictation.stopRecording() + if !result.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + text += result + } + } else { + appState.dictation.startRecording() + } + } label: { + Image(systemName: appState.dictation.isRecording ? "mic.fill" : "mic") + .font(.body) + .foregroundStyle(appState.dictation.isRecording ? theme.red : theme.muted) + } + .buttonStyle(.plain) + .hoverScale(1.15) + .help(appState.dictation.isRecording ? "Stop dictation" : "Start dictation (or hold Space)") + } - // Interrupt button - if appState.chat.isRunning { + // Prompt enhancer button + if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Button { + enhancePrompt() + } label: { + if isEnhancing { + ProgressView().controlSize(.mini).tint(theme.violet) + } else { + Image(systemName: "sparkles") + .font(.body) + .foregroundStyle(theme.violet) + } + } + .buttonStyle(.plain) + .hoverScale(1.15) + .disabled(isEnhancing) + .help("Enhance prompt with AI (⌘E)") + .keyboardShortcut("e", modifiers: .command) + } + + // Char/token counter (shown when text is non-empty) + if !text.isEmpty { + Text("~\(estimatedTokens)t") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(estimatedTokens > 2000 ? theme.orange : theme.muted.opacity(0.6)) + .help("\(text.count) characters · ~\(estimatedTokens) tokens") + } + + // Fork button (Option+Enter) + if appState.sessionList.selectedSessionID != nil { + Button { + doForkSubmit() + } label: { + Image(systemName: "arrow.triangle.branch") + .font(.body) + .foregroundStyle(text.isEmpty ? theme.muted : theme.cyan) + } + .buttonStyle(.plain) + .hoverScale(1.15) + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && attachments.isEmpty) + .keyboardShortcut(.return, modifiers: .option) + .help("Fork & Send (⌥Enter)") + } + + // Interrupt button + if appState.chat.isRunning { + Button { + Task { await appState.chat.interrupt() } + } label: { + Image(systemName: "stop.circle.fill") + .font(.body) + .foregroundStyle(theme.orange) + } + .buttonStyle(.plain) + .hoverScale(1.15) + .help("Interrupt (⌘.)") + } + + // Send button (Cmd+Enter) Button { - Task { await appState.chat.interrupt() } + doSubmit() } label: { - Image(systemName: "stop.circle.fill") - .font(.body) - .foregroundStyle(theme.orange) + Image(systemName: "arrow.up.circle.fill") + .font(.title3) + .foregroundStyle((text.isEmpty && attachments.isEmpty) ? theme.muted : theme.accent) } .buttonStyle(.plain) - .hoverScale(1.15) - .help("Interrupt (⌘.)") - } - - // Send button (Cmd+Enter) - Button { - onSubmit() - } label: { - Image(systemName: "arrow.up.circle.fill") - .font(.title3) - .foregroundStyle(text.isEmpty ? theme.muted : theme.accent) + .hoverScale() + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && attachments.isEmpty) + .keyboardShortcut(.return, modifiers: .command) + .help("Send (⌘Enter)") } - .buttonStyle(.plain) - .hoverScale() - .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - .keyboardShortcut(.return, modifiers: .command) - .help("Send (⌘Enter)") } + .padding(.horizontal, 12) + .padding(.vertical, 8) } - .padding(.horizontal, 12) - .padding(.vertical, 8) .background(theme.panel) + // Handle image paste (text is handled natively by TextEditor) + .onPasteCommand(of: [UTType.image, UTType.png, UTType.jpeg, UTType.tiff]) { providers in + handleImagePaste(providers) + } + // Prompt history navigation: ⌘↑ / ⌘↓ + .onKeyPress(.upArrow, phases: .down) { event in + guard event.modifiers.contains(.command) else { return .ignored } + navigateHistory(direction: -1) + return .handled + } + .onKeyPress(.downArrow, phases: .down) { event in + guard event.modifiers.contains(.command) else { return .ignored } + navigateHistory(direction: 1) + return .handled + } .onAppear { isFocused = true appState.dictation.promptIsEmpty = text.isEmpty - appState.dictation.onDictationComplete = { [weak appState] result in - // Remove any spaces that leaked into the text field during hold - if let appState { - // Trim leading spaces that accumulated while holding - let cleaned = text.replacingOccurrences(of: #"^\s+"#, with: "", options: .regularExpression) - text = cleaned + result - _ = appState // keep reference alive - } + appState.dictation.onDictationComplete = { result in + text += result + } + appState.dictation.onInsertSpace = { + text += " " } appState.dictation.installEventMonitor() } @@ -140,6 +236,305 @@ struct PromptInputView: View { } .onChange(of: text) { _, newValue in appState.dictation.promptIsEmpty = newValue.isEmpty + if newValue.isEmpty { historyIndex = -1 } + } + .onChange(of: appState.chat.activeSessionID) { _, _ in + isFocused = true + suggestions = [] + historyIndex = -1 + } + .onChange(of: appState.chat.isRunning) { _, running in + // Fetch suggestions after a run completes + if !running, let id = appState.chat.activeSessionID { + Task { + let chips = try? await appState.api.sessionSuggestions(id: id) + await MainActor.run { suggestions = chips ?? [] } + } + } + } + } + + // MARK: - Prompt history navigation + + private func navigateHistory(direction: Int) { + let history = appState.chat.promptHistory + guard !history.isEmpty else { return } + let newIndex = historyIndex + direction + if direction == -1 { + // Go back in history + let clamped = min(max(newIndex, 0), history.count - 1) + historyIndex = clamped + text = history[history.count - 1 - clamped] + } else { + // Go forward + if newIndex < 0 { + historyIndex = -1 + text = "" + } else { + historyIndex = -1 + text = "" + } + } + } + + // MARK: - Prompt enhancer + + private func enhancePrompt() { + let original = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !original.isEmpty else { return } + isEnhancing = true + Task { + let enhanced = try? await appState.api.enhancePrompt(original) + await MainActor.run { + if let e = enhanced, !e.isEmpty { text = e } + isEnhancing = false + } + } + } + + // MARK: - Submit helpers + + private func doSubmit() { + let full = buildFullPrompt() + guard !full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + text = "" + attachments = [] + suggestions = [] + historyIndex = -1 + onSubmit(full) + } + + private func doForkSubmit() { + let full = buildFullPrompt() + guard !full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + text = "" + attachments = [] + suggestions = [] + historyIndex = -1 + onForkSubmit?(full) + } + + /// Combines attachment content with the user's typed text into a single prompt string. + private func buildFullPrompt() -> String { + var parts: [String] = [] + for att in attachments { + if att.isImage { + parts.append("[Attached image: \(att.content)]") + } else { + let lang = att.name.components(separatedBy: ".").last ?? "" + parts.append("[Attached file: \(att.name)]\n```\(lang)\n\(att.content)\n```") + } + } + if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + parts.append(text) + } + return parts.joined(separator: "\n\n") + } + + // MARK: - File picker + + private func openFilePicker() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.begin { response in + guard response == .OK else { return } + for url in panel.urls { + handleFileURL(url) + } + } + } + + // MARK: - File handling + + func handleFileURL(_ url: URL) { + let ext = url.pathExtension.lowercased() + let imageExts: Set = ["png", "jpg", "jpeg", "gif", "webp", "heic", "bmp", "tiff", "tif"] + + if imageExts.contains(ext) { + let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("orbitor_\(UUID().uuidString).\(ext)") + do { + try FileManager.default.copyItem(at: url, to: tempURL) + let thumbnail = NSImage(contentsOf: url) + DispatchQueue.main.async { + attachments.append(PromptAttachment( + name: url.lastPathComponent, + content: tempURL.path, + isImage: true, + thumbnail: thumbnail + )) + } + } catch { + print("[Attachment] Failed to copy image: \(error)") + } + } else { + // Read as text (skip if too large: >500 KB) + guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? Int, size < 500_000 else { + print("[Attachment] File too large or unreadable: \(url.lastPathComponent)") + return + } + guard let content = try? String(contentsOf: url, encoding: .utf8) else { return } + DispatchQueue.main.async { + attachments.append(PromptAttachment( + name: url.lastPathComponent, + content: content, + isImage: false, + thumbnail: nil + )) + } + } + } + + // MARK: - Image paste + + private func handleImagePaste(_ providers: [NSItemProvider]) { + for provider in providers { + let pngType = UTType.png.identifier + let jpegType = UTType.jpeg.identifier + let tiffType = UTType.tiff.identifier + let preferred = [pngType, jpegType, tiffType].first { provider.hasItemConformingToTypeIdentifier($0) } + guard let typeID = preferred else { continue } + let ext = typeID == jpegType ? "jpg" : (typeID == tiffType ? "tiff" : "png") + + provider.loadDataRepresentation(forTypeIdentifier: typeID) { data, error in + guard let data, let image = NSImage(data: data) else { return } + let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("orbitor_paste_\(UUID().uuidString).\(ext)") + do { + try data.write(to: tempURL) + DispatchQueue.main.async { + attachments.append(PromptAttachment( + name: "pasted_image.\(ext)", + content: tempURL.path, + isImage: true, + thumbnail: image + )) + } + } catch { + print("[Attachment] Failed to write pasted image: \(error)") + } + } + } + } +} + +// MARK: - Suggestion chips + +private struct SuggestionChipsView: View { + let suggestions: [String] + let theme: OrbitorTheme + let onSelect: (String) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(suggestions, id: \.self) { chip in + Button { + onSelect(chip) + } label: { + HStack(spacing: 4) { + Image(systemName: "sparkle") + .font(.system(size: 9)) + .foregroundStyle(theme.violet) + Text(chip) + .font(.caption) + .foregroundStyle(theme.text) + .lineLimit(1) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(theme.violet.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(theme.violet.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + } + } +} + +// MARK: - Attachment chips + +private struct AttachmentChipsView: View { + @Binding var attachments: [PromptAttachment] + let theme: OrbitorTheme + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(attachments) { att in + AttachmentChip(attachment: att, theme: theme) { + attachments.removeAll { $0.id == att.id } + } + } + } + } + } +} + +private struct AttachmentChip: View { + let attachment: PromptAttachment + let theme: OrbitorTheme + let onRemove: () -> Void + @State private var isHovered = false + + var body: some View { + HStack(spacing: 5) { + if attachment.isImage, let thumb = attachment.thumbnail { + Image(nsImage: thumb) + .resizable() + .scaledToFill() + .frame(width: 18, height: 18) + .clipShape(RoundedRectangle(cornerRadius: 3)) + } else { + Image(systemName: fileIcon) + .font(.caption2) + .foregroundStyle(theme.accent) + } + + Text(attachment.name) + .font(.caption) + .foregroundStyle(theme.text) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 120) + + Button { + onRemove() + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(theme.muted) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(theme.selBg) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(theme.border, lineWidth: 1) + ) + .onHover { isHovered = $0 } + .scaleEffect(isHovered ? 1.02 : 1.0) + .animation(.easeOut(duration: 0.1), value: isHovered) + } + + private var fileIcon: String { + let ext = attachment.name.components(separatedBy: ".").last?.lowercased() ?? "" + switch ext { + case "swift", "py", "go", "ts", "js", "rs": return "doc.text" + case "json", "yaml", "yml", "toml": return "doc.badge.gearshape" + case "md", "txt": return "doc.plaintext" + default: return "doc" } } } diff --git a/desktop/Orbitor/Orbitor/Views/CommandPalette/CommandPaletteView.swift b/desktop/Orbitor/Orbitor/Views/CommandPalette/CommandPaletteView.swift new file mode 100644 index 0000000..47a67ed --- /dev/null +++ b/desktop/Orbitor/Orbitor/Views/CommandPalette/CommandPaletteView.swift @@ -0,0 +1,236 @@ +import SwiftUI + +struct CommandPaletteView: View { + @Binding var isPresented: Bool + @Environment(AppState.self) private var appState + @Environment(\.theme) private var theme + @State private var query = "" + @State private var selectedIndex = 0 + @FocusState private var searchFocused: Bool + + struct PaletteItem: Identifiable { + let id = UUID() + let icon: String + let title: String + let subtitle: String? + let shortcut: String? + let action: () -> Void + } + + private func makeItems() -> [PaletteItem] { + var items: [PaletteItem] = [] + + // --- Actions --- + items.append(PaletteItem(icon: "plus.circle", title: "New Session", subtitle: nil, shortcut: "⌘N") { + appState.showNewSession = true + isPresented = false + }) + + if appState.sessionList.selectedSessionID != nil { + items.append(PaletteItem(icon: "arrow.triangle.branch", title: "Fork Session", subtitle: nil, shortcut: "⇧⌘N") { + appState.showForkSheet = true + isPresented = false + }) + + if appState.chat.isRunning { + items.append(PaletteItem(icon: "stop.circle", title: "Interrupt Agent", subtitle: nil, shortcut: "⌘.") { + Task { await appState.chat.interrupt() } + isPresented = false + }) + } + + if let id = appState.sessionList.selectedSessionID { + items.append(PaletteItem( + icon: "trash", + title: "Delete Session", + subtitle: appState.sessionList.selectedSession?.displayTitle, + shortcut: "⌘⌫" + ) { + Task { await appState.sessionList.deleteSession(id) } + isPresented = false + }) + } + } + + if appState.sessionList.sessions.count > 1 { + items.append(PaletteItem(icon: "chevron.down", title: "Next Session", subtitle: nil, shortcut: "⌘]") { + appState.sessionList.selectNext() + isPresented = false + }) + items.append(PaletteItem(icon: "chevron.up", title: "Previous Session", subtitle: nil, shortcut: "⌘[") { + appState.sessionList.selectPrevious() + isPresented = false + }) + } + + // --- Sessions --- + for session in appState.sessionList.sessions { + let isCurrent = session.id == appState.sessionList.selectedSessionID + items.append(PaletteItem( + icon: isCurrent ? "terminal.fill" : "terminal", + title: session.displayTitle, + subtitle: "\(session.stateLabel) \(session.shortDir)", + shortcut: nil + ) { + appState.sessionList.selectedSessionID = session.id + isPresented = false + }) + } + + // --- Themes --- + for t in OrbitorTheme.all { + let isCurrent = t.id == appState.selectedThemeID + items.append(PaletteItem( + icon: isCurrent ? "checkmark.circle.fill" : "paintbrush", + title: "Theme: \(t.name)", + subtitle: nil, + shortcut: nil + ) { + appState.selectedThemeID = t.id + isPresented = false + }) + } + + return items + } + + private var filtered: [PaletteItem] { + let all = makeItems() + guard !query.trimmingCharacters(in: .whitespaces).isEmpty else { return all } + let q = query.lowercased() + return all.filter { + $0.title.lowercased().contains(q) || + ($0.subtitle?.lowercased().contains(q) ?? false) + } + } + + var body: some View { + ZStack { + // Backdrop — tap to dismiss + Color.black.opacity(0.45) + .ignoresSafeArea() + .onTapGesture { isPresented = false } + + // Floating panel + VStack(spacing: 0) { + // Search bar + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .foregroundStyle(theme.muted) + TextField("Search commands, sessions…", text: $query) + .textFieldStyle(.plain) + .font(.system(size: 15)) + .foregroundStyle(theme.text) + .focused($searchFocused) + .onSubmit { executeSelected() } + .onChange(of: query) { _, _ in selectedIndex = 0 } + .onKeyPress(.upArrow) { selectedIndex = max(0, selectedIndex - 1); return .handled } + .onKeyPress(.downArrow) { selectedIndex = min(filtered.count - 1, selectedIndex + 1); return .handled } + .onKeyPress(.escape) { isPresented = false; return .handled } + } + .padding(.horizontal, 16) + .padding(.vertical, 13) + + Divider().background(theme.border) + + // Results list + if filtered.isEmpty { + Text("No results") + .font(.caption) + .foregroundStyle(theme.muted) + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(filtered.enumerated()), id: \.element.id) { idx, item in + PaletteRow(item: item, isSelected: idx == selectedIndex) + .id(idx) + .contentShape(Rectangle()) + .onTapGesture { item.action() } + .onHover { hovered in if hovered { selectedIndex = idx } } + } + } + } + .frame(maxHeight: 380) + .onChange(of: selectedIndex) { _, new in + withAnimation(.easeOut(duration: 0.1)) { + proxy.scrollTo(new, anchor: .center) + } + } + } + } + } + .background(theme.panel) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(theme.border, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.5), radius: 30, x: 0, y: 10) + .frame(width: 540) + .padding(.horizontal, 40) + } + .onAppear { + searchFocused = true + selectedIndex = 0 + } + } + + private func executeSelected() { + guard selectedIndex < filtered.count else { return } + filtered[selectedIndex].action() + } +} + +// MARK: - Palette row + +private struct PaletteRow: View { + let item: CommandPaletteView.PaletteItem + let isSelected: Bool + @Environment(\.theme) private var theme + + var body: some View { + HStack(spacing: 10) { + Image(systemName: item.icon) + .font(.system(size: 14)) + .foregroundStyle(isSelected ? theme.accent : theme.muted) + .frame(width: 22, alignment: .center) + + VStack(alignment: .leading, spacing: 1) { + Text(item.title) + .font(.system(size: 13)) + .foregroundStyle(theme.text) + if let sub = item.subtitle { + Text(sub) + .font(.caption) + .foregroundStyle(theme.muted) + .lineLimit(1) + } + } + + Spacer() + + if let shortcut = item.shortcut { + Text(shortcut) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(theme.muted) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(theme.selBg) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(isSelected ? theme.accent.opacity(0.13) : Color.clear) + .overlay(alignment: .leading) { + if isSelected { + RoundedRectangle(cornerRadius: 2) + .fill(theme.accent) + .frame(width: 3) + } + } + } +} diff --git a/desktop/Orbitor/Orbitor/Views/ContentView.swift b/desktop/Orbitor/Orbitor/Views/ContentView.swift index e406cab..9a9cb0f 100644 --- a/desktop/Orbitor/Orbitor/Views/ContentView.swift +++ b/desktop/Orbitor/Orbitor/Views/ContentView.swift @@ -14,18 +14,22 @@ struct ContentView: View { SessionListView(showNewSession: $state.showNewSession) .navigationSplitViewColumnWidth(min: 200, ideal: 260, max: 360) } detail: { - if appState.sessionList.selectedSessionID != nil { - HStack(spacing: 0) { - ChatView() - - if inspectorPresented { - Divider() - InspectorView() - .frame(width: 280) + VStack(spacing: 0) { + if appState.sessionList.selectedSessionID != nil { + HStack(spacing: 0) { + ChatView() + + if inspectorPresented { + Divider() + InspectorView() + .frame(width: 280) + } } + } else { + EmptyStateView(showNewSession: $state.showNewSession) } - } else { - EmptyStateView(showNewSession: $state.showNewSession) + + StatusBar() } } .background(theme.panel) @@ -66,9 +70,17 @@ struct ContentView: View { .sheet(isPresented: $state.showForkSheet) { ForkSessionSheet() } + .overlay { + if appState.showCommandPalette { + CommandPaletteView(isPresented: $state.showCommandPalette) + .transition(.opacity.combined(with: .scale(scale: 0.97))) + } + } + .animation(.easeOut(duration: 0.12), value: appState.showCommandPalette) .onChange(of: appState.sessionList.selectedSessionID) { _, newID in if let id = newID { appState.chat.connectToSession(id) + appState.sessionList.markRead(id) } } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in @@ -80,6 +92,89 @@ struct ContentView: View { } } +// MARK: - Status Bar + +private struct StatusBar: View { + @Environment(AppState.self) private var appState + @Environment(\.theme) private var theme + + private var runningSessions: Int { + appState.sessionList.sessions.filter { $0.isRunning }.count + } + + private var serverLabel: String { + switch appState.connectionStatus { + case .connected: return "Connected" + case .connecting: return "Connecting…" + case .disconnected: return "Disconnected" + case .error(let msg): return "Error: \(msg)" + } + } + + private var serverColor: Color { + switch appState.connectionStatus { + case .connected: return theme.green + case .connecting: return theme.yellow + case .disconnected, .error: return theme.red + } + } + + var body: some View { + HStack(spacing: 10) { + // Server status + HStack(spacing: 4) { + Circle() + .fill(serverColor) + .frame(width: 6, height: 6) + Text(serverLabel) + .font(.system(size: 10)) + .foregroundStyle(theme.muted) + } + + Divider().frame(height: 12) + + // Session count + HStack(spacing: 4) { + Image(systemName: "terminal") + .font(.system(size: 9)) + .foregroundStyle(theme.muted) + Text("\(appState.sessionList.sessions.count) session\(appState.sessionList.sessions.count == 1 ? "" : "s")") + .font(.system(size: 10)) + .foregroundStyle(theme.muted) + } + + // Running count (shown when any are running) + if runningSessions > 0 { + Divider().frame(height: 12) + HStack(spacing: 4) { + Circle() + .fill(theme.orange) + .frame(width: 6, height: 6) + Text("\(runningSessions) running") + .font(.system(size: 10)) + .foregroundStyle(theme.orange) + } + } + + Spacer() + + // Server URL (right-aligned) + Text(appState.serverURL) + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(theme.muted.opacity(0.5)) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 200) + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background(theme.panel) + .overlay(alignment: .top) { + Divider().background(theme.sep) + } + } +} + struct EmptyStateView: View { @Binding var showNewSession: Bool @Environment(\.theme) private var theme diff --git a/desktop/Orbitor/Orbitor/Views/Inspector/InspectorView.swift b/desktop/Orbitor/Orbitor/Views/Inspector/InspectorView.swift index 83726a9..55c673f 100644 --- a/desktop/Orbitor/Orbitor/Views/Inspector/InspectorView.swift +++ b/desktop/Orbitor/Orbitor/Views/Inspector/InspectorView.swift @@ -3,6 +3,8 @@ import SwiftUI struct InspectorView: View { @Environment(AppState.self) private var appState @Environment(\.theme) private var theme + @State private var gitBranch: String? = nil + @State private var showHistory = false var body: some View { if let session = appState.sessionList.selectedSession { @@ -14,6 +16,20 @@ struct InspectorView: View { .font(.headline) .foregroundStyle(theme.text) Spacer() + if appState.chat.errorCount > 0 { + Label("\(appState.chat.errorCount) error\(appState.chat.errorCount == 1 ? "" : "s")", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(theme.red) + } + Button { + showHistory = true + } label: { + Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") + .font(.caption) + .foregroundStyle(theme.muted) + } + .buttonStyle(.plain) + .help("File History") } // Session info grid @@ -46,10 +62,23 @@ struct InspectorView: View { if session.queueDepth > 0 { DetailRow(label: "Queue", value: "\(session.queueDepth) pending", theme: theme) } + if let dur = appState.chat.lastRunDuration { + DetailRow(label: "Last run", value: formatDuration(dur), theme: theme) + } } DetailSection(title: "Project") { DetailRow(label: "Dir", value: session.shortDir, theme: theme) + if let branch = gitBranch { + DetailRow(label: "Branch", theme: theme) { + Image(systemName: "arrow.triangle.branch") + .font(.caption2) + .foregroundStyle(theme.cyan) + Text(branch) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(theme.cyan) + } + } if let tool = session.currentTool, !tool.isEmpty { DetailRow(label: "Tool", value: tool, theme: theme) } @@ -98,6 +127,29 @@ struct InspectorView: View { } } + // Files touched this session + if !appState.chat.filesTouched.isEmpty { + DetailSection(title: "Files Changed (\(appState.chat.filesTouched.count))") { + ForEach(appState.chat.filesTouched.prefix(10), id: \.self) { path in + HStack(spacing: 4) { + Image(systemName: "pencil.circle.fill") + .font(.system(size: 9)) + .foregroundStyle(theme.cyan) + Text(path) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(theme.text) + .lineLimit(1) + .truncationMode(.head) + } + } + if appState.chat.filesTouched.count > 10 { + Text("…and \(appState.chat.filesTouched.count - 10) more") + .font(.caption2) + .foregroundStyle(theme.muted) + } + } + } + // Sub-agents if let agents = session.subAgents, !agents.isEmpty { DetailSection(title: "Sub-Agents (\(agents.count))") { @@ -122,11 +174,34 @@ struct InspectorView: View { } } + // Delete + DetailSection(title: "Danger Zone") { + Button(role: .destructive) { + Task { await appState.sessionList.deleteSession(session.id) } + } label: { + HStack(spacing: 4) { + Image(systemName: "trash") + Text("Delete Session") + } + .font(.caption) + .foregroundStyle(theme.red) + } + .buttonStyle(.plain) + .hoverScale(1.05) + } + Spacer() } .padding() } .background(theme.panel) + .onAppear { loadGitBranch(for: session) } + .onChange(of: session.id) { _, _ in loadGitBranch(for: session) } + .sheet(isPresented: $showHistory) { + RunHistoryView(sessionID: session.id) + .environment(appState) + .environment(\.theme, theme) + } } else { VStack { Text("No session") @@ -136,6 +211,27 @@ struct InspectorView: View { .background(theme.panel) } } + + private func loadGitBranch(for session: SessionInfo) { + let headPath = session.workingDir + "/.git/HEAD" + Task { + let branch = await Task.detached(priority: .background) { + guard let content = try? String(contentsOfFile: headPath, encoding: .utf8) else { return nil as String? } + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("ref: refs/heads/") { + return String(trimmed.dropFirst("ref: refs/heads/".count)) + } + return String(trimmed.prefix(8)) // detached HEAD + }.value + await MainActor.run { gitBranch = branch } + } + } + + private func formatDuration(_ t: TimeInterval) -> String { + let total = Int(t) + if total < 60 { return "\(total)s" } + return "\(total / 60)m \(total % 60)s" + } } // MARK: - Detail helpers @@ -202,6 +298,15 @@ struct ModelPickerRow: View { @Environment(AppState.self) private var appState @Environment(\.theme) private var theme @State private var selectedModel: String = "" + @State private var isUserChange = false + + private func syncModel() { + let current = session.model ?? "" + let resolved = modelsForBackend(session.backend).contains(current) ? current : "(default)" + if selectedModel != resolved { + selectedModel = resolved + } + } var body: some View { HStack { @@ -217,7 +322,7 @@ struct ModelPickerRow: View { .pickerStyle(.menu) .controlSize(.small) .onChange(of: selectedModel) { _, newValue in - guard !newValue.isEmpty else { return } + guard isUserChange, !newValue.isEmpty else { return } let modelToSend = newValue == "(default)" ? defaultModelForBackend(session.backend) : newValue Task { try? await appState.api.updateSession(id: session.id, model: modelToSend) @@ -226,11 +331,17 @@ struct ModelPickerRow: View { } } .onAppear { - let current = session.model ?? "" - if modelsForBackend(session.backend).contains(current) { - selectedModel = current - } else { - selectedModel = "(default)" + syncModel() + // Delay enabling user changes to avoid triggering on initial sync + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isUserChange = true + } + } + .onChange(of: session.model) { _, _ in + isUserChange = false + syncModel() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isUserChange = true } } } diff --git a/desktop/Orbitor/Orbitor/Views/Inspector/RunHistoryView.swift b/desktop/Orbitor/Orbitor/Views/Inspector/RunHistoryView.swift new file mode 100644 index 0000000..9721692 --- /dev/null +++ b/desktop/Orbitor/Orbitor/Views/Inspector/RunHistoryView.swift @@ -0,0 +1,296 @@ +import SwiftUI + +struct RunHistoryView: View { + let sessionID: String + @Environment(AppState.self) private var appState + @Environment(\.theme) private var theme + @Environment(\.dismiss) private var dismiss + + @State private var runs: [RunRecord] = [] + @State private var isLoading = false + @State private var selectedRunID: String? + @State private var selectedFile: FileChange? + + private var selectedRun: RunRecord? { + runs.first { $0.id == selectedRunID } + } + + var body: some View { + VStack(spacing: 0) { + // Title bar + HStack { + Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") + .foregroundStyle(theme.accent) + Text("File History") + .font(.headline) + .foregroundStyle(theme.text) + Spacer() + if isLoading { + ProgressView().controlSize(.small) + } + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(theme.muted) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(theme.panel) + + Divider().background(theme.sep) + + if runs.isEmpty && !isLoading { + emptyState + } else { + HSplitView { + // Left: run list + runList + .frame(minWidth: 200, idealWidth: 240, maxWidth: 300) + + // Right: file list + diff + rightPanel + .frame(minWidth: 400) + } + } + } + .background(theme.panel) + .frame(minWidth: 800, minHeight: 500) + .task { await load() } + } + + // MARK: - Subviews + + private var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "clock.badge.xmark") + .font(.system(size: 36)) + .foregroundStyle(theme.muted) + Text("No file changes recorded yet") + .font(.headline) + .foregroundStyle(theme.muted) + Text("File changes will appear here after the agent writes files.") + .font(.caption) + .foregroundStyle(theme.muted.opacity(0.7)) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var runList: some View { + VStack(spacing: 0) { + Text("RUNS") + .font(.caption2.bold()) + .foregroundStyle(theme.muted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider().background(theme.sep) + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(runs) { run in + RunRowView(run: run, isSelected: run.id == selectedRunID) + .contentShape(Rectangle()) + .onTapGesture { + selectedRunID = run.id + selectedFile = run.files.first + } + } + } + } + } + .background(theme.panel) + } + + private var rightPanel: some View { + Group { + if let run = selectedRun { + VSplitView { + // File list + fileList(for: run) + .frame(minHeight: 80, idealHeight: 120, maxHeight: 200) + + // Diff viewer + diffPanel + } + } else { + VStack { + Text("Select a run to view changes") + .foregroundStyle(theme.muted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + private func fileList(for run: RunRecord) -> some View { + VStack(spacing: 0) { + HStack { + Text("FILES CHANGED") + .font(.caption2.bold()) + .foregroundStyle(theme.muted) + Spacer() + Text("\(run.files.count) file\(run.files.count == 1 ? "" : "s")") + .font(.caption2) + .foregroundStyle(theme.muted) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider().background(theme.sep) + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(run.files) { file in + FileRowView(file: file, isSelected: file.id == selectedFile?.id) + .contentShape(Rectangle()) + .onTapGesture { selectedFile = file } + } + } + } + } + .background(theme.panel) + } + + private var diffPanel: some View { + VStack(spacing: 0) { + if let file = selectedFile { + HStack(spacing: 8) { + Image(systemName: file.before.isEmpty ? "plus.circle.fill" : "pencil.circle.fill") + .font(.caption) + .foregroundStyle(file.before.isEmpty ? Color.green : theme.cyan) + Text(file.relativePath) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(theme.text) + Spacer() + let added = countAdded(file) + let removed = countRemoved(file) + if added > 0 { + Text("+\(added)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(Color.green) + } + if removed > 0 { + Text("-\(removed)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(Color.red) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider().background(theme.sep) + + FileDiffView(before: file.before, after: file.after) + } else { + VStack { + Text("Select a file to view diff") + .foregroundStyle(theme.muted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + // MARK: - Helpers + + private func load() async { + isLoading = true + defer { isLoading = false } + if let records = try? await appState.api.sessionRunHistory(id: sessionID) { + runs = records + selectedRunID = runs.first?.id + selectedFile = runs.first?.files.first + } + } + + private func countAdded(_ file: FileChange) -> Int { + computeDiff(before: file.before, after: file.after).filter { $0.kind == .added }.count + } + + private func countRemoved(_ file: FileChange) -> Int { + computeDiff(before: file.before, after: file.after).filter { $0.kind == .removed }.count + } +} + +// MARK: - Row views + +private struct RunRowView: View { + let run: RunRecord + let isSelected: Bool + @Environment(\.theme) private var theme + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Circle() + .fill(isSelected ? theme.accent : theme.muted.opacity(0.5)) + .frame(width: 6, height: 6) + Text(run.startedAt, style: .time) + .font(.caption2) + .foregroundStyle(theme.muted) + Spacer() + Text("\(run.files.count) file\(run.files.count == 1 ? "" : "s")") + .font(.caption2) + .foregroundStyle(theme.muted) + } + + Text(run.prompt.isEmpty ? "(continuation)" : run.prompt) + .font(.caption) + .foregroundStyle(theme.text) + .lineLimit(2) + .padding(.leading, 12) + + if let dur = runDuration { + Text(dur) + .font(.caption2) + .foregroundStyle(theme.muted.opacity(0.7)) + .padding(.leading, 12) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isSelected ? theme.accent.opacity(0.12) : Color.clear) + .overlay(alignment: .leading) { + if isSelected { + Rectangle() + .fill(theme.accent) + .frame(width: 2) + } + } + } + + private var runDuration: String? { + guard let end = run.completedAt else { return nil } + let secs = Int(end.timeIntervalSince(run.startedAt)) + if secs < 60 { return "\(secs)s" } + return "\(secs / 60)m \(secs % 60)s" + } +} + +private struct FileRowView: View { + let file: FileChange + let isSelected: Bool + @Environment(\.theme) private var theme + + var body: some View { + HStack(spacing: 8) { + Image(systemName: file.before.isEmpty ? "plus.circle.fill" : "pencil.circle.fill") + .font(.system(size: 9)) + .foregroundStyle(file.before.isEmpty ? Color.green : theme.cyan) + Text(file.relativePath) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(theme.text) + .lineLimit(1) + .truncationMode(.head) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 5) + .background(isSelected ? theme.accent.opacity(0.12) : Color.clear) + } +} diff --git a/desktop/Orbitor/Orbitor/Views/Shared/DiffView.swift b/desktop/Orbitor/Orbitor/Views/Shared/DiffView.swift new file mode 100644 index 0000000..91cbf7c --- /dev/null +++ b/desktop/Orbitor/Orbitor/Views/Shared/DiffView.swift @@ -0,0 +1,333 @@ +import SwiftUI + +// MARK: - LCS Diff Engine (before/after text) + +struct DiffLine: Identifiable { + enum Kind { case added, removed, unchanged } + let id = UUID() + let kind: Kind + let oldNumber: Int? + let newNumber: Int? + let text: String +} + +/// Computes a line-level unified diff between `before` and `after`. +/// Returns DiffLine entries with ±context lines of context around each change. +func computeDiff(before: String, after: String, context: Int = 3) -> [DiffLine] { + let oldLines = before.components(separatedBy: "\n") + let newLines = after.components(separatedBy: "\n") + + let m = oldLines.count + let n = newLines.count + var dp = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1) + for i in 1...m { + for j in 1...n { + if oldLines[i - 1] == newLines[j - 1] { + dp[i][j] = dp[i - 1][j - 1] + 1 + } else { + dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) + } + } + } + + enum Edit { case unchanged(Int, Int), removed(Int), added(Int) } + var edits: [Edit] = [] + var i = m, j = n + while i > 0 || j > 0 { + if i > 0 && j > 0 && oldLines[i - 1] == newLines[j - 1] { + edits.append(.unchanged(i - 1, j - 1)) + i -= 1; j -= 1 + } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) { + edits.append(.added(j - 1)) + j -= 1 + } else { + edits.append(.removed(i - 1)) + i -= 1 + } + } + edits.reverse() + + var changedOld = Set() + var changedNew = Set() + for edit in edits { + switch edit { + case .removed(let oi): changedOld.insert(oi) + case .added(let ni): changedNew.insert(ni) + default: break + } + } + + var showOld = Set() + var showNew = Set() + for oi in changedOld { + for k in max(0, oi - context)...min(m - 1, oi + context) { showOld.insert(k) } + } + for ni in changedNew { + for k in max(0, ni - context)...min(n - 1, ni + context) { showNew.insert(k) } + } + + var result: [DiffLine] = [] + var prevOld = -1 + var prevNew = -1 + + for edit in edits { + switch edit { + case .unchanged(let oi, let ni): + let show = showOld.contains(oi) || showNew.contains(ni) + if show { + if prevOld >= 0 && (oi > prevOld + 1 || ni > prevNew + 1) { + result.append(DiffLine(kind: .unchanged, oldNumber: nil, newNumber: nil, text: "")) + } + result.append(DiffLine(kind: .unchanged, oldNumber: oi + 1, newNumber: ni + 1, text: oldLines[oi])) + prevOld = oi; prevNew = ni + } + case .removed(let oi): + result.append(DiffLine(kind: .removed, oldNumber: oi + 1, newNumber: nil, text: oldLines[oi])) + prevOld = oi + case .added(let ni): + result.append(DiffLine(kind: .added, oldNumber: nil, newNumber: ni + 1, text: newLines[ni])) + prevNew = ni + } + } + + return result +} + +// MARK: - FileDiffView (before/after strings → computed diff) + +struct FileDiffView: View { + let before: String + let after: String + @Environment(\.theme) private var theme + + private var lines: [DiffLine] { computeDiff(before: before, after: after) } + + var body: some View { + ScrollView([.vertical, .horizontal]) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(lines) { line in + FileDiffLineRow(line: line) + } + } + .padding(.vertical, 4) + } + .background(theme.panel) + } +} + +private struct FileDiffLineRow: View { + let line: DiffLine + @Environment(\.theme) private var theme + + private var bg: Color { + switch line.kind { + case .added: return Color.green.opacity(0.15) + case .removed: return Color.red.opacity(0.15) + case .unchanged: return Color.clear + } + } + + private var prefix: String { + switch line.kind { + case .added: return "+" + case .removed: return "-" + case .unchanged: return line.oldNumber == nil ? "…" : " " + } + } + + private var prefixColor: Color { + switch line.kind { + case .added: return Color.green + case .removed: return Color.red + case .unchanged: return line.oldNumber == nil ? Color.gray : Color.gray.opacity(0.5) + } + } + + var body: some View { + if line.oldNumber == nil && line.newNumber == nil && line.text.isEmpty { + HStack(spacing: 0) { + Text(" ···") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Color.gray.opacity(0.5)) + .padding(.horizontal, 8) + .padding(.vertical, 2) + Spacer() + } + .background(Color.gray.opacity(0.05)) + } else { + HStack(spacing: 0) { + Text(line.oldNumber.map { "\($0)" } ?? "") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(Color.gray.opacity(0.5)) + .frame(width: 36, alignment: .trailing) + .padding(.trailing, 4) + + Text(line.newNumber.map { "\($0)" } ?? "") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(Color.gray.opacity(0.5)) + .frame(width: 36, alignment: .trailing) + .padding(.trailing, 6) + + Text(prefix) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(prefixColor) + .frame(width: 14, alignment: .center) + + Text(line.text) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(line.kind == .unchanged ? Color.primary.opacity(0.7) : Color.primary) + .textSelection(.enabled) + + Spacer(minLength: 0) + } + .padding(.vertical, 1) + .background(bg) + } + } +} + +// MARK: - Pre-formatted unified diff (used by chat messages) + +/// Returns true if `text` looks like a unified diff (has file headers or hunk markers). +func looksLikeDiff(_ text: String) -> Bool { + let lines = text.components(separatedBy: "\n") + let fileHeaders = lines.filter { $0.hasPrefix("--- ") || $0.hasPrefix("+++ ") }.count + let hunkHeaders = lines.filter { $0.hasPrefix("@@ ") || ($0.hasPrefix("@@") && $0.count > 2) }.count + return fileHeaders >= 2 || hunkHeaders >= 1 +} + +/// Renders a unified diff with colored lines and a copy button. +struct DiffView: View { + let diff: String + @Environment(\.theme) private var theme + @Environment(AppState.self) private var appState + @State private var isCopied = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header bar + HStack { + Text("diff") + .font(.caption2) + .foregroundStyle(theme.muted) + Spacer() + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(diff, forType: .string) + isCopied = true + Task { + try? await Task.sleep(for: .seconds(2)) + isCopied = false + } + } label: { + Image(systemName: isCopied ? "checkmark" : "doc.on.doc") + .font(.caption2) + .foregroundStyle(isCopied ? theme.green : theme.muted) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 10) + .padding(.top, 6) + .padding(.bottom, 4) + + Divider() + + // Diff lines + ScrollView(.horizontal, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(diff.components(separatedBy: "\n").enumerated()), id: \.offset) { _, line in + DiffLineView(line: line) + } + } + .padding(.vertical, 4) + } + } + .background(Color(hex: "1E1E1E").opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(theme.border, lineWidth: 1) + ) + } +} + +// MARK: - Single diff line + +private struct DiffLineView: View { + let line: String + @Environment(\.theme) private var theme + @Environment(AppState.self) private var appState + + private enum Kind { case added, removed, hunkHeader, fileHeader, context } + + private var kind: Kind { + if line.hasPrefix("+++ ") || line.hasPrefix("--- ") { return .fileHeader } + if line.hasPrefix("@@") { return .hunkHeader } + if line.hasPrefix("+") { return .added } + if line.hasPrefix("-") { return .removed } + return .context + } + + var body: some View { + HStack(spacing: 0) { + // Gutter (+/-) + Text(gutterText) + .font(.system(size: max(appState.fontSize - 2, 8), design: .monospaced)) + .foregroundStyle(gutterColor) + .frame(width: 14, alignment: .center) + .padding(.leading, 8) + + // Line content (strip leading +/- for added/removed) + Text(displayText) + .font(.system(size: max(appState.fontSize - 1, 9), design: .monospaced)) + .foregroundStyle(textColor) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6) + .padding(.vertical, 1) + } + .background(bgColor) + } + + private var gutterText: String { + switch kind { + case .added: return "+" + case .removed: return "-" + default: return "" + } + } + + private var displayText: String { + switch kind { + case .added, .removed: return String(line.dropFirst(1)) + default: return line + } + } + + private var bgColor: Color { + switch kind { + case .added: return Color.green.opacity(0.10) + case .removed: return Color.red.opacity(0.10) + case .hunkHeader: return Color.blue.opacity(0.07) + default: return Color.clear + } + } + + private var textColor: Color { + switch kind { + case .added: return theme.green + case .removed: return theme.red + case .hunkHeader: return theme.cyan + case .fileHeader: return theme.muted + case .context: return theme.text + } + } + + private var gutterColor: Color { + switch kind { + case .added: return theme.green + case .removed: return theme.red + default: return theme.muted + } + } +} diff --git a/desktop/Orbitor/Orbitor/Views/Shared/StatusBadge.swift b/desktop/Orbitor/Orbitor/Views/Shared/StatusBadge.swift index 5771cc3..89f6255 100644 --- a/desktop/Orbitor/Orbitor/Views/Shared/StatusBadge.swift +++ b/desktop/Orbitor/Orbitor/Views/Shared/StatusBadge.swift @@ -3,12 +3,18 @@ import SwiftUI struct StatusBadge: View { let state: String @Environment(\.theme) private var theme + @State private var pulse = false + + private var isAnimated: Bool { + state == "working" || state == "waiting-input" || state == "starting" + } var body: some View { HStack(spacing: 4) { Circle() .fill(theme.stateColor(state)) .frame(width: 6, height: 6) + .opacity(isAnimated && pulse ? 0.3 : 1.0) Text(state) .font(.system(.caption2, design: .monospaced)) .foregroundStyle(theme.stateColor(state)) @@ -18,5 +24,25 @@ struct StatusBadge: View { .background(theme.stateColor(state).opacity(0.1)) .clipShape(Capsule()) .hoverScale(1.08) + .onAppear { + if isAnimated { + withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { + pulse = true + } + } + } + .onChange(of: state) { _, newState in + let animated = newState == "working" || newState == "waiting-input" || newState == "starting" + if animated { + pulse = false + withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { + pulse = true + } + } else { + withAnimation(.default) { + pulse = false + } + } + } } } diff --git a/desktop/Orbitor/Orbitor/Views/Sidebar/SessionListView.swift b/desktop/Orbitor/Orbitor/Views/Sidebar/SessionListView.swift index ba15c2c..9888907 100644 --- a/desktop/Orbitor/Orbitor/Views/Sidebar/SessionListView.swift +++ b/desktop/Orbitor/Orbitor/Views/Sidebar/SessionListView.swift @@ -11,12 +11,17 @@ struct SessionListView: View { List(selection: $sessionList.selectedSessionID) { ForEach(appState.sessionList.sessions) { session in - SessionRowView(session: session) + SessionRowView(session: session, isUnread: appState.sessionList.unreadSessionIDs.contains(session.id)) .tag(session.id) .listRowBackground( session.id == appState.sessionList.selectedSessionID ? theme.selBg : Color.clear ) + .contextMenu { + Button("Delete Session", role: .destructive) { + Task { await appState.sessionList.deleteSession(session.id) } + } + } } } .listStyle(.sidebar) @@ -67,8 +72,12 @@ struct SessionListView: View { struct SessionRowView: View { let session: SessionInfo + var isUnread: Bool = false @Environment(\.theme) private var theme @State private var isHovered = false + @State private var runStart: Date? = nil + @State private var elapsed: TimeInterval = 0 + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var body: some View { VStack(alignment: .leading, spacing: 3) { @@ -78,8 +87,21 @@ struct SessionRowView: View { .foregroundStyle(theme.text) .lineLimit(1) + if isUnread { + Circle() + .fill(theme.accent) + .frame(width: 8, height: 8) + } + Spacer() + // Live run timer + if session.isRunning, elapsed > 0 { + Text(formatElapsed(elapsed)) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(theme.orange) + } + StatusBadge(state: session.stateLabel) } @@ -96,6 +118,29 @@ struct SessionRowView: View { .font(.caption2) .lineLimit(1) } + + Spacer() + + // PR badge + if let prUrl = session.prUrl, !prUrl.isEmpty { + Label("PR", systemImage: "arrow.triangle.branch") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(theme.cyan) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(theme.cyan.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 3)) + .onTapGesture { + if let url = URL(string: prUrl) { + NSWorkspace.shared.open(url) + } + } + } + + // Last-activity (session age) + Text(relativeTime(session.createdAt)) + .font(.caption2) + .foregroundStyle(theme.muted.opacity(0.7)) } .foregroundStyle(theme.muted) } @@ -104,5 +149,33 @@ struct SessionRowView: View { .brightness(isHovered ? 0.05 : 0) .animation(.easeOut(duration: 0.15), value: isHovered) .onHover { isHovered = $0 } + .onReceive(timer) { now in + guard session.isRunning else { elapsed = 0; return } + let start = runStart ?? now + elapsed = now.timeIntervalSince(start) + } + .onChange(of: session.isRunning) { _, running in + if running { + runStart = Date() + elapsed = 0 + } else { + runStart = nil + elapsed = 0 + } + } + } + + private func formatElapsed(_ t: TimeInterval) -> String { + let m = Int(t) / 60 + let s = Int(t) % 60 + return String(format: "%d:%02d", m, s) + } + + private func relativeTime(_ date: Date) -> String { + let seconds = Int(Date().timeIntervalSince(date)) + if seconds < 60 { return "just now" } + if seconds < 3600 { return "\(seconds / 60)m ago" } + if seconds < 86400 { return "\(seconds / 3600)h ago" } + return "\(seconds / 86400)d ago" } } diff --git a/handlers.go b/handlers.go index c9dab69..54dd608 100644 --- a/handlers.go +++ b/handlers.go @@ -157,8 +157,8 @@ func (h *Handlers) DeleteSession(w http.ResponseWriter, r *http.Request) { func (h *Handlers) UpdateSession(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req struct { - SkipPermissions bool `json:"skipPermissions"` - PlanMode bool `json:"planMode"` + SkipPermissions *bool `json:"skipPermissions,omitempty"` + PlanMode *bool `json:"planMode,omitempty"` Model *string `json:"model,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -569,6 +569,88 @@ func (h *Handlers) MissionSummary(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"title": title, "summary": summary}) } +// EnhancePrompt rewrites a rough prompt into a more precise instruction using +// the local LLM summarizer. POST /api/enhance-prompt {"text": "..."}. +func (h *Handlers) EnhancePrompt(w http.ResponseWriter, r *http.Request) { + if h.sm == nil || h.sm.summarizer == nil { + http.Error(w, `{"error":"summarizer not available"}`, http.StatusServiceUnavailable) + return + } + var req struct { + Text string `json:"text"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Text) == "" { + http.Error(w, `{"error":"text required"}`, http.StatusBadRequest) + return + } + enhanced := h.sm.summarizer.EnhancePrompt(req.Text) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"enhanced": enhanced}) +} + +// SessionDebrief returns a post-run summary of a session's conversation. +// GET /api/sessions/{id}/debrief +func (h *Handlers) SessionDebrief(w http.ResponseWriter, r *http.Request) { + if h.sm == nil || h.sm.summarizer == nil { + http.Error(w, `{"error":"summarizer not available"}`, http.StatusServiceUnavailable) + return + } + id := r.PathValue("id") + session := h.sm.Get(id) + if session == nil { + http.Error(w, `{"error":"session not found"}`, http.StatusNotFound) + return + } + var msgs []WSMessage + if session.store != nil { + msgs, _ = session.store.LoadMessages(session.ID) + } + debrief := h.sm.summarizer.Debrief(msgs) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"debrief": debrief}) +} + +// SessionSuggestions returns up to 3 follow-up prompt suggestions for a session. +// GET /api/sessions/{id}/suggestions +func (h *Handlers) SessionSuggestions(w http.ResponseWriter, r *http.Request) { + if h.sm == nil || h.sm.summarizer == nil { + http.Error(w, `{"error":"summarizer not available"}`, http.StatusServiceUnavailable) + return + } + id := r.PathValue("id") + session := h.sm.Get(id) + if session == nil { + http.Error(w, `{"error":"session not found"}`, http.StatusNotFound) + return + } + var msgs []WSMessage + if session.store != nil { + msgs, _ = session.store.LoadMessages(session.ID) + } + suggestions := h.sm.summarizer.Suggestions(msgs) + if suggestions == nil { + suggestions = []string{} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"suggestions": suggestions}) +} + +// SessionRunHistory returns the per-run file change history for a session. +func (h *Handlers) SessionRunHistory(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + s := h.sm.Get(id) + if s == nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + records := s.history.Records() + if records == nil { + records = []RunRecord{} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"runs": records}) +} + // EventsWebSocket is a global WebSocket endpoint that broadcasts cross-session // events (permission requests) so the mobile app can show notifications // regardless of which session is currently open. diff --git a/main.go b/main.go index 1f8625d..3cce3d0 100644 --- a/main.go +++ b/main.go @@ -268,6 +268,10 @@ func runServerMode() { } h.SendAPK(w, r) }) + mux.HandleFunc("POST /api/enhance-prompt", h.EnhancePrompt) + mux.HandleFunc("GET /api/sessions/{id}/debrief", h.SessionDebrief) + mux.HandleFunc("GET /api/sessions/{id}/suggestions", h.SessionSuggestions) + mux.HandleFunc("GET /api/sessions/{id}/run-history", h.SessionRunHistory) mux.HandleFunc("GET /api/whatsapp/status", h.WhatsAppStatus) mux.HandleFunc("POST /api/whatsapp/pair", h.WhatsAppPair) mux.HandleFunc("GET /api/whatsapp/qr", h.WhatsAppQR) diff --git a/protocol.go b/protocol.go index 634e526..ff4e561 100644 --- a/protocol.go +++ b/protocol.go @@ -147,6 +147,20 @@ type ToolCallResultUpdate struct { Result json.RawMessage `json:"result,omitempty"` } +// toolCallMeta extracts the _meta.claudeCode fields embedded in tool_call_update +// events. Claude Code embeds the original file content in toolResponse when a +// file-writing tool (Edit, Write) completes. +type toolCallMeta struct { + Meta *struct { + ClaudeCode *struct { + ToolResponse *struct { + FilePath string `json:"filePath"` + OriginalFile string `json:"originalFile"` + } `json:"toolResponse,omitempty"` + } `json:"claudeCode,omitempty"` + } `json:"_meta,omitempty"` +} + type PermissionRequestParams struct { SessionID string `json:"sessionId"` ToolCall PermissionToolCall `json:"toolCall"` diff --git a/run_history.go b/run_history.go new file mode 100644 index 0000000..627384f --- /dev/null +++ b/run_history.go @@ -0,0 +1,106 @@ +package main + +import ( + "path/filepath" + "sync" + "time" + + "github.com/google/uuid" +) + +// FileChange records a single file's before/after content within a run. +type FileChange struct { + Path string `json:"path"` + RelativePath string `json:"relativePath"` + Before string `json:"before"` + After string `json:"after"` +} + +// RunRecord captures all file changes that occurred during one prompt→run_complete cycle. +type RunRecord struct { + ID string `json:"id"` + Prompt string `json:"prompt"` + StartedAt time.Time `json:"startedAt"` + CompletedAt *time.Time `json:"completedAt,omitempty"` + Files []FileChange `json:"files"` +} + +// RunHistory tracks per-session run records in memory. +type RunHistory struct { + mu sync.Mutex + records []*RunRecord + current *RunRecord +} + +func newRunHistory() *RunHistory { + return &RunHistory{} +} + +// StartRun begins a new run record for the given prompt text. +func (h *RunHistory) StartRun(prompt string) { + h.mu.Lock() + defer h.mu.Unlock() + h.current = &RunRecord{ + ID: uuid.New().String()[:8], + Prompt: prompt, + StartedAt: time.Now(), + Files: []FileChange{}, + } +} + +// RecordFileChange adds or updates a file entry in the current run. +// before is the file content before the write; after is the content after. +// If before == after (no actual change) the entry is skipped. +func (h *RunHistory) RecordFileChange(path, workingDir, before, after string) { + h.mu.Lock() + defer h.mu.Unlock() + if h.current == nil || before == after { + return + } + rel := path + if workingDir != "" { + if r, err := filepath.Rel(workingDir, path); err == nil { + rel = r + } + } + // If the same file was written multiple times in this run, update the after content. + for i, f := range h.current.Files { + if f.Path == path { + h.current.Files[i].After = after + return + } + } + h.current.Files = append(h.current.Files, FileChange{ + Path: path, + RelativePath: rel, + Before: before, + After: after, + }) +} + +// CompleteRun finalises the current run record and appends it to history. +// Only saved if at least one file was changed. +func (h *RunHistory) CompleteRun() { + h.mu.Lock() + defer h.mu.Unlock() + if h.current == nil { + return + } + now := time.Now() + h.current.CompletedAt = &now + if len(h.current.Files) > 0 { + h.records = append(h.records, h.current) + } + h.current = nil +} + +// Records returns a snapshot of all completed run records, newest first. +func (h *RunHistory) Records() []RunRecord { + h.mu.Lock() + defer h.mu.Unlock() + out := make([]RunRecord, len(h.records)) + for i := range h.records { + out[i] = *h.records[len(h.records)-1-i] + } + return out +} diff --git a/screenshots/tui-main.png b/screenshots/tui-main.png new file mode 100644 index 0000000..4923a2f Binary files /dev/null and b/screenshots/tui-main.png differ diff --git a/screenshots/tui-sessions.png b/screenshots/tui-sessions.png new file mode 100644 index 0000000..fcd1f91 Binary files /dev/null and b/screenshots/tui-sessions.png differ diff --git a/session.go b/session.go index 7adc368..f2e2ede 100644 --- a/session.go +++ b/session.go @@ -8,6 +8,7 @@ import ( "log" "net" "net/http" + "os" "os/exec" "path/filepath" "regexp" @@ -93,6 +94,7 @@ type Session struct { eventHub *Hub // global event hub for cross-session notifications summarizer *Summarizer // optional LLM summarizer (nil = disabled) fcm *FCMSender // optional FCM push sender (nil = disabled) + history *RunHistory // per-session file change history } func (s *Session) persistSession() { @@ -251,6 +253,7 @@ func NewSessionManager(store *Store, summarizer *Summarizer) *SessionManager { allocPort: sm.AllocPort, eventHub: eventHub, summarizer: summarizer, + history: newRunHistory(), } // Seed hub history from DB before starting Run to avoid race conditions. @@ -414,6 +417,7 @@ func (sm *SessionManager) Create(workingDir, backend, model string, skipPermissi eventHub: sm.EventHub, summarizer: sm.summarizer, fcm: sm.fcm, + history: newRunHistory(), } go s.hub.Run() @@ -467,6 +471,16 @@ func (s *Session) startCopilot(workingDir string, port int) { args = append(args, "--reasoning-effort", "high") } } + // Pass MCP server config from ~/.copilot/mcp-config.json via CLI flag. + // Copilot's ACP implementation doesn't support mcpServers in session/new, + // so we load any project-local .mcp.json and pass it as inline JSON. + if workingDir != "" { + mcpPath := filepath.Join(workingDir, ".mcp.json") + if data, err := os.ReadFile(mcpPath); err == nil { + args = append(args, "--additional-mcp-config", string(data)) + } + } + // If we have a persisted session id, ask the agent to resume it. // Prefer ResumeSession (the original conversation ID preserved across // respawns) over ACPSession (which may have been replaced by a new @@ -759,8 +773,18 @@ func (s *Session) finishACPSetup(workingDir string) { } } if acpSessionID == "" { + // Only pass MCP servers via ACP for backends that support it (Claude). + // Copilot reads ~/.copilot/mcp-config.json natively and gets project- + // local .mcp.json via --additional-mcp-config CLI flag. + var mcpServers []any + if s.Backend != "copilot" { + mcpServers = LoadMCPServers(s.Backend, workingDir) + if len(mcpServers) > 0 { + log.Printf("session %s: loading %d MCP server(s) from native config", s.ID, len(mcpServers)) + } + } var err error - acpSessionID, err = s.acp.SessionNew(workingDir) + acpSessionID, err = s.acp.SessionNew(workingDir, mcpServers) if err != nil { log.Printf("session %s: session/new failed: %v", s.ID, err) s.Status = "error" @@ -835,6 +859,9 @@ func (s *Session) finishACPSetup(workingDir string) { s.currentPrompt = item.Text s.isRunning = true s.summaryMu.Unlock() + if !isContinuation { + s.history.StartRun(item.Text) + } s.persistSession() var promptID int64 if !isContinuation { @@ -878,6 +905,7 @@ func (s *Session) finishACPSetup(workingDir string) { s.isRunning = false prURL := s.prURL s.summaryMu.Unlock() + s.history.CompleteRun() s.persistSession() completePayload := map[string]string{"stopReason": result.StopReason, "prUrl": prURL} // Route run_complete through the coalescer so it is strictly ordered @@ -1060,7 +1088,7 @@ func (sm *SessionManager) ReviveSession(id string) error { // UpdateSession updates mutable session properties and applies runtime changes. // For copilot, model changes require respawn so process args are updated. -func (sm *SessionManager) UpdateSession(id string, skipPermissions, planMode bool, model *string) (*Session, error) { +func (sm *SessionManager) UpdateSession(id string, skipPermissions, planMode *bool, model *string) (*Session, error) { sm.mu.RLock() s, ok := sm.sessions[id] sm.mu.RUnlock() @@ -1076,8 +1104,12 @@ func (sm *SessionManager) UpdateSession(id string, skipPermissions, planMode boo s.Model = next } } - s.SkipPermissions = skipPermissions - s.PlanMode = planMode + if skipPermissions != nil { + s.SkipPermissions = *skipPermissions + } + if planMode != nil { + s.PlanMode = *planMode + } if sm.store != nil { _ = sm.store.UpsertSession(s) } @@ -1091,20 +1123,20 @@ func (sm *SessionManager) UpdateSession(id string, skipPermissions, planMode boo _ = s.acp.SessionSetConfigOption(s.ACPSession, "model", s.Model) } } - if s.SkipPermissions { - _ = s.acp.SessionSetConfigOption(s.ACPSession, "mode", "bypassPermissions") - } else if s.PlanMode { - _ = s.acp.SessionSetConfigOption(s.ACPSession, "mode", "plan") + if skipPermissions != nil || planMode != nil { + if s.SkipPermissions { + _ = s.acp.SessionSetConfigOption(s.ACPSession, "mode", "bypassPermissions") + } else if s.PlanMode { + _ = s.acp.SessionSetConfigOption(s.ACPSession, "mode", "plan") + } else { + _ = s.acp.SessionSetConfigOption(s.ACPSession, "mode", "default") + } } } - // Kill the backend process. The respawn monitor goroutine (watching - // <-s.acp.Done()) will detect the death and restart the process with - // updated runtime config. - // // Copilot model changes must respawn to ensure CLI startup flags match. - shouldRespawn := modelChanged && s.Backend == "copilot" - if shouldRespawn || s.process != nil && s.process.Process != nil || s.procID != 0 { + // All other changes are applied live via ACP session/configure. + if modelChanged && s.Backend == "copilot" { s.hub.Broadcast("status", map[string]string{"status": "respawning"}) if s.process != nil && s.process.Process != nil { _ = s.process.Process.Signal(syscall.SIGTERM) @@ -1339,6 +1371,25 @@ func (s *Session) handleSessionUpdate(params json.RawMessage) { } s.summaryMu.Unlock() } + // Track file changes from Edit/Write tool completions. + // Claude Code writes files internally and sends the original file content + // in _meta.claudeCode.toolResponse.originalFile on completion. + if base.SessionUpdate == "tool_call_update" { + var meta toolCallMeta + if json.Unmarshal(p.Update, &meta) == nil && + meta.Meta != nil && meta.Meta.ClaudeCode != nil && + meta.Meta.ClaudeCode.ToolResponse != nil { + tr := meta.Meta.ClaudeCode.ToolResponse + if tr.FilePath != "" { + after := "" + if data, err := os.ReadFile(tr.FilePath); err == nil { + after = string(data) + } + s.history.RecordFileChange(tr.FilePath, s.WorkingDir, tr.OriginalFile, after) + } + } + } + toolPayload := WSToolCall{ ToolCallID: tc.ToolCallID, Title: tc.Title, @@ -1408,11 +1459,23 @@ func (s *Session) handleAgentRequest(method string, id *json.RawMessage, params } _ = s.acp.Respond(id, result) case "fs/write_text_file": + var wp FSWriteParams + before := "" + if json.Unmarshal(params, &wp) == nil && wp.Path != "" { + if data, err := os.ReadFile(wp.Path); err == nil { + before = string(data) + } + } result, err := handleFSWrite(params) if err != nil { _ = s.acp.RespondError(id, -32603, err.Error()) return } + if wp.Path != "" { + if after, err := os.ReadFile(wp.Path); err == nil { + s.history.RecordFileChange(wp.Path, s.WorkingDir, before, string(after)) + } + } _ = s.acp.Respond(id, result) case "terminal/create": result, err := s.terminal.Create(params) @@ -1579,17 +1642,16 @@ func (s *Session) Interrupt() { s.interruptMu.Lock() cancel := s.interruptCancel s.interruptMu.Unlock() - if cancel == nil { - return + // Cancel the in-flight context if a prompt is still blocking so + // SessionPromptContext unblocks immediately. + if cancel != nil { + cancel() } - // Cancel the in-flight context first so SessionPromptContext unblocks - // immediately, then send the graceful ACP notification. - cancel() if s.queue != nil { s.queue.Clear() } - // Send SIGINT to the agent process immediately so it stops regardless of - // whether the ACP notification is processed in time. + // Send SIGINT to the agent process so it stops even if the prompt call + // already returned and the agent is mid-response. if s.process != nil && s.process.Process != nil { _ = s.process.Process.Signal(syscall.SIGINT) } diff --git a/summarizer.go b/summarizer.go index 1cbfc44..1b61e73 100644 --- a/summarizer.go +++ b/summarizer.go @@ -418,6 +418,154 @@ func llmThreads() int { return n } +// EnhancePrompt rewrites a rough prompt into a clearer, more detailed coding +// instruction. Returns the original text unchanged on any error. +func (s *Summarizer) EnhancePrompt(text string) string { + if strings.TrimSpace(text) == "" { + return text + } + apiBase, model, err := s.ensureServer() + if err != nil { + return text + } + type msg struct { + Role string `json:"role"` + Content string `json:"content"` + } + prompt := "You are helping a developer write a clear coding instruction for an AI coding assistant.\n" + + "Rewrite the following rough note into a precise, actionable instruction. " + + "Keep the same intent but add helpful specificity. " + + "Respond with only the improved instruction text, no preamble.\n\nInput: " + text + reqBody, _ := json.Marshal(map[string]any{ + "model": model, + "messages": []msg{{Role: "user", Content: prompt}}, + "temperature": 0.3, + "max_tokens": 300, + }) + resp, err := s.callHTTP.Post(apiBase+"/v1/chat/completions", "application/json", bytes.NewReader(reqBody)) + if err != nil { + return text + } + defer resp.Body.Close() + var result struct { + Choices []struct { + Message struct{ Content string `json:"content"` } `json:"message"` + } `json:"choices"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || len(result.Choices) == 0 { + return text + } + enhanced := strings.TrimSpace(result.Choices[0].Message.Content) + if enhanced == "" { + return text + } + return enhanced +} + +// Debrief generates a structured post-run summary. Returns empty string on error. +func (s *Summarizer) Debrief(messages []WSMessage) string { + ctx := buildSummaryContext(messages) + if ctx == "" { + return "" + } + apiBase, model, err := s.ensureServer() + if err != nil { + return "" + } + type msg struct { + Role string `json:"role"` + Content string `json:"content"` + } + prompt := "You are summarizing a completed AI coding session. " + + "Given the conversation below, write a brief debrief with three short sections:\n" + + "1. What was asked (one sentence)\n" + + "2. What was done (1-3 bullet points starting with •)\n" + + "3. Open questions or next steps (one sentence, or 'None' if complete)\n" + + "Be concise. No markdown headers, just plain text.\n\nConversation:\n" + ctx + reqBody, _ := json.Marshal(map[string]any{ + "model": model, + "messages": []msg{{Role: "user", Content: prompt}}, + "temperature": 0.2, + "max_tokens": 250, + }) + resp, err := s.callHTTP.Post(apiBase+"/v1/chat/completions", "application/json", bytes.NewReader(reqBody)) + if err != nil { + return "" + } + defer resp.Body.Close() + var result struct { + Choices []struct { + Message struct{ Content string `json:"content"` } `json:"message"` + } `json:"choices"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || len(result.Choices) == 0 { + return "" + } + return strings.TrimSpace(result.Choices[0].Message.Content) +} + +// Suggestions generates up to 3 natural follow-up prompts. Returns nil on error. +func (s *Summarizer) Suggestions(messages []WSMessage) []string { + ctx := buildSummaryContext(messages) + if ctx == "" { + return nil + } + apiBase, model, err := s.ensureServer() + if err != nil { + return nil + } + type msg struct { + Role string `json:"role"` + Content string `json:"content"` + } + prompt := "Based on this AI coding session, suggest exactly 3 short follow-up prompts the developer might send next. " + + "Respond with a JSON array of 3 strings, each under 10 words. " + + "Respond ONLY with the JSON array, e.g. [\"Add unit tests\",\"Fix lint errors\",\"Update docs\"].\n\nConversation:\n" + ctx + reqBody, _ := json.Marshal(map[string]any{ + "model": model, + "messages": []msg{{Role: "user", Content: prompt}}, + "temperature": 0.4, + "max_tokens": 120, + }) + resp, err := s.callHTTP.Post(apiBase+"/v1/chat/completions", "application/json", bytes.NewReader(reqBody)) + if err != nil { + return nil + } + defer resp.Body.Close() + var result struct { + Choices []struct { + Message struct{ Content string `json:"content"` } `json:"message"` + } `json:"choices"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || len(result.Choices) == 0 { + return nil + } + content := strings.TrimSpace(result.Choices[0].Message.Content) + var suggestions []string + if err := json.Unmarshal([]byte(content), &suggestions); err == nil { + return clampSuggestions(suggestions) + } + var wrapped struct{ Suggestions []string `json:"suggestions"` } + if err := json.Unmarshal([]byte(content), &wrapped); err == nil && len(wrapped.Suggestions) > 0 { + return clampSuggestions(wrapped.Suggestions) + } + if i := strings.Index(content, "["); i != -1 { + if j := strings.LastIndex(content, "]"); j > i { + if err := json.Unmarshal([]byte(content[i:j+1]), &suggestions); err == nil { + return clampSuggestions(suggestions) + } + } + } + return nil +} + +func clampSuggestions(s []string) []string { + if len(s) > 3 { + return s[:3] + } + return s +} + // buildSummaryContext extracts user prompts and agent responses from message // history into a compact text string suitable for the LLM prompt. func buildSummaryContext(messages []WSMessage) string {