From 3eb13003ade2feb81baee24dcdf21130a3f5ffa6 Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Tue, 28 Apr 2026 19:32:19 +0200 Subject: [PATCH 01/15] Add Codex tab and session support --- README.md | 101 ++- Sources/App/AppDelegate.swift | 19 +- Sources/App/ShortcutNames.swift | 2 + Sources/Control/ControlSocket.swift | 1 + Sources/Detection/ContextMonitor.swift | 636 +++++++++++++++++- Sources/Detection/ProcessMonitor.swift | 19 +- Sources/Session/BookmarkManager.swift | 24 +- Sources/Session/SessionExplorerModels.swift | 27 +- .../Session/SessionExplorerTimelineView.swift | 61 +- .../SessionExplorerWindowController.swift | 87 +-- Sources/Session/SessionState.swift | 75 ++- Sources/Session/SummaryManager.swift | 80 ++- Sources/Terminal/TerminalSurface.swift | 1 + Sources/Window/DeckardWindowController.swift | 277 ++++++-- Sources/Window/QuotaView.swift | 11 +- Sources/Window/SettingsWindow.swift | 30 +- Sources/Window/SidebarController.swift | 6 +- Sources/Window/SidebarViews.swift | 55 +- Sources/Window/TabBarController.swift | 20 +- Sources/Window/TabBarViews.swift | 8 +- 20 files changed, 1325 insertions(+), 215 deletions(-) diff --git a/README.md b/README.md index 7de2799..e2ffe0d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Deckard -A terminal built for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Deckard is a native macOS app that treats Claude Code sessions as first-class objects. Each tab knows whether Claude is thinking, waiting for input, or needs tool approval, and tracks context window usage so you know when a session is running low. +A native macOS workspace for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), OpenAI Codex, and classic terminal tabs. Deckard treats agent sessions as first-class tabs: Claude Code and Codex can both be created, resumed, forked, explored, bookmarked, summarized, and restored across app launches. -Run multiple sessions side by side in a single window with tabs, projects, and session persistence. Built with Swift and AppKit. Terminal rendering powered by [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). +Run multiple agents side by side in a single window with project-aware tabs, session persistence, status badges, and usage telemetry when the underlying CLI exposes it. Built with Swift and AppKit. Terminal rendering is powered by [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm).

@@ -19,51 +19,81 @@ Run multiple sessions side by side in a single window with tabs, projects, and s ## Features -### Multi-tab sessions +### Claude, Codex, and Terminal Tabs -Open multiple Claude Code (and plain terminal) tabs per project. Switch between them with Cmd+1–9 or drag to reorder. +Open Claude Code, Codex, and plain terminal tabs inside the same project workspace. Agent tabs launch the right CLI directly, while terminal tabs remain normal shells. Switch between tabs with Cmd+1-9 or drag tabs to reorder them. -Tab bar with Claude and terminal tabs +Tab bar with agent and terminal tabs -### Project sidebar +### Project Sidebar -Each open directory gets its own set of tabs, persisted across restarts. Group related projects into collapsible sidebar folders for organization (e.g., by client). +Each open directory gets its own persisted tab set. Group related projects into collapsible sidebar folders for organization, and keep different agent runs attached to the project they belong to. Project sidebar with folders -### Session state detection +### Provider-Specific Status Badges -Tab badges show whether Claude is thinking, waiting for input, needs tool permission, or has errored. Terminal tabs show real-time CPU and disk activity for the foreground process. +Claude Code and Codex have separate badge states and customizable colors. Claude badges use Deckard's Claude hooks to show thinking, waiting, permission, error, and done-unvisited states. Codex badges are read from Codex rollout events and show idle, working, error, and done-unvisited states. Terminal tabs use their own process-activity badges. Status indicator dots -### Session explorer +### Session Explorer -Browse all past Claude sessions with Cmd+Shift+E. View the full conversation timeline, resume or fork from any point, and bookmark favorites with a star toggle. Optionally summarize sessions and per-turn actions with Haiku — summaries are cached and incrementally updated when sessions are continued. +Browse past Claude Code and Codex sessions with Cmd+Shift+E. The explorer lists both providers for the current project, lets you resume or fork any session, and supports bookmark stars, timeline views, action extraction, and cached summaries. -Session explorer window +Fork-at-turn works for both agent providers. For Claude Code, Deckard truncates the Claude session JSONL and resumes with Claude's fork support. For Codex, Deckard creates a truncated Codex rollout file, registers it with Codex's local state database, and launches `codex fork` or `codex resume` as appropriate. -### 486 color themes +Session explorer window -Ships with 486 built-in themes (Ghostty format) and loads custom themes from `~/.config/ghostty/themes`. Search and preview in Settings. Status indicator shapes, colors, and blink are fully customizable. +### Context, Quota, and Token Rate -Theme settings with status indicators +Agent usage stats appear only on tabs where Deckard can read real provider data. -### Context & quota tracking +| Metric | Claude Code tabs | Codex tabs | Terminal tabs | +| --- | --- | --- | --- | +| Context usage bar | Yes, from Claude session usage entries | Yes, from Codex `token_count` rollout events when present | No | +| 5-hour quota | Yes, from Claude status-line hook data | Yes, from Codex rollout rate-limit data when present | No | +| 7-day quota | Yes, from Claude status-line hook data | Yes, from Codex rollout rate-limit data when present | No | +| Tokens per minute | Yes, from recent Claude output token usage | Yes, from recent Codex generated token usage | No | -A progress bar shows context window usage. A sparkline visualizes token rate over time, and rate limit indicators show 5-hour and 7-day quota consumption. +Classic terminal tabs intentionally do not show agent context, quota, or token-rate panels. Context and quota tracking popover +### 486 Color Themes + +Ships with 486 built-in themes in Ghostty format and loads custom themes from `~/.config/ghostty/themes`. Search and preview in Settings. Status indicator shapes, colors, and blink behavior are fully customizable per provider. + +Theme settings with status indicators + ### More -- **Session persistence**: Claude sessions resume via `--resume`. Tab structure and working directories are preserved across restarts. -- **Customizable shortcuts**: All keyboard shortcuts are rebindable in Settings > Shortcuts. -- **tmux integration**: When tmux is installed, terminal tabs are transparently wrapped in tmux sessions. Quit and relaunch Deckard to resume exactly where you left off — full shell state, scrollback, running processes, and environment preserved. tmux options are editable in Settings > Terminal. Works as a progressive enhancement; no tmux required. -- **Drag and drop**: Drag files from Finder into the terminal — paths are automatically shell-escaped and inserted. +- **Session persistence**: Claude Code sessions resume with `claude --resume`; Codex sessions resume with `codex resume`. Tab structure and working directories are preserved across restarts. +- **Forking workflows**: Claude Code and Codex sessions can be forked from the explorer, including from a specific user turn. +- **Bookmarks and summaries**: Claude Code and Codex sessions use separate bookmark and summary caches so provider sessions with similar IDs do not collide. +- **Customizable shortcuts**: All keyboard shortcuts are rebindable in Settings > Shortcuts, including new Claude, Codex, and terminal tab commands. +- **tmux integration**: When tmux is installed, classic terminal tabs are transparently wrapped in tmux sessions. Quit and relaunch Deckard to resume shell state, scrollback, running processes, and environment. tmux options are editable in Settings > Terminal. +- **Drag and drop**: Drag files from Finder into any terminal surface. Paths are shell-escaped and inserted automatically. - **Auto-updates**: Built-in update checking via [Sparkle](https://sparkle-project.org/). New releases are delivered automatically. - **Terminal rendering**: Powered by [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm), a self-contained terminal emulator with VT100/xterm emulation, IME support, and mouse reporting. +## Agent Support Matrix + +| Workflow | Claude Code | Codex | +| --- | --- | --- | +| Create new agent tab | Yes | Yes | +| Resume existing session | Yes | Yes | +| Fork existing session | Yes | Yes | +| Fork from a specific turn | Yes | Yes | +| Session explorer listing | Yes | Yes | +| Timeline and action view | Yes | Yes | +| Bookmarks | Yes | Yes | +| Cached summaries | Yes | Yes | +| Provider-specific badges | Yes | Yes | +| Context, quota, token rate | Yes | Yes, when Codex writes `token_count` events | + +Deckard aims for equal day-to-day workflows across Claude Code and Codex. Some telemetry is necessarily provider-specific because the CLIs expose different local data. When Deckard cannot read a metric reliably, it hides that metric instead of showing stale data from another tab or provider. + ## Install **Homebrew:** @@ -77,8 +107,11 @@ brew install gi11es/tap/deckard ## Requirements - macOS 14.0 (Sonoma) or later -- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI installed -- Xcode 16+ (to build from source) +- Claude Code CLI installed to use Claude tabs +- Codex CLI installed to use Codex tabs +- Xcode 16+ to build from source + +Deckard can be used with only Claude Code, only Codex, or both installed. Terminal tabs work without either agent CLI. ## Building @@ -94,12 +127,26 @@ The built app will be in your Xcode DerivedData directory. ## How It Works -On launch, Deckard automatically installs two integrations into Claude Code (no manual setup needed): +Deckard integrates with each provider using the local state that provider already writes. + +**Claude Code** + +On launch, Deckard installs Claude Code integrations idempotently: + +1. **Lifecycle hooks**: a shell script and entries in `~/.claude/settings.json` notify Deckard when Claude starts thinking, finishes a response, needs tool approval, encounters an error, or emits status-line quota data. Communication happens over a Unix domain socket. +2. **`/deckard` skill**: a Claude Code slash command at `~/.claude/commands/deckard.md` for filing bug reports and feature requests directly from a session. + +Deckard reads Claude session JSONL files under `~/.claude/projects` for session discovery, timelines, action extraction, context usage, summaries, resume, and fork-at-turn. + +**Codex** + +Deckard reads Codex rollout files under `~/.codex/sessions` and the local Codex state database at `~/.codex/state_5.sqlite`. That provides project-scoped session discovery, resume, fork, fork-at-turn, timeline parsing, action extraction, badges, context usage, quota percentages, and token-rate calculation when Codex has written the corresponding events. + +Deckard does not install Codex hooks. It launches the Codex CLI directly with `codex`, `codex resume`, or `codex fork`. -1. **Lifecycle hooks** — a shell script and entries in `~/.claude/settings.json` that notify Deckard when Claude starts thinking, finishes a response, needs tool approval, or encounters an error. Communication happens over a Unix domain socket. -2. **`/deckard` skill** — a Claude Code slash command (`~/.claude/commands/deckard.md`) for filing bug reports and feature requests directly from a session. +**Terminal** -These are installed idempotently on every launch and don't modify Claude Code itself. +Classic terminal tabs are normal shells, optionally tmux-backed. They do not participate in agent session discovery and do not show agent context, quota, or token-rate telemetry. ## License diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index e213244..053680d 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -41,6 +41,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { nc.addObserver(self, selector: #selector(handleSurfaceClosed(_:)), name: .deckardSurfaceClosed, object: nil) nc.addObserver(self, selector: #selector(handleTitleChanged(_:)), name: .deckardSurfaceTitleChanged, object: nil) nc.addObserver(self, selector: #selector(handleNewTab), name: .deckardNewTab, object: nil) + nc.addObserver(self, selector: #selector(handleNewCodexTab), name: .deckardNewCodexTab, object: nil) nc.addObserver(self, selector: #selector(handleCloseTab), name: .deckardCloseTab, object: nil) // Start the control socket for hook communication. @@ -148,7 +149,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @objc private func handleNewTab() { - windowController?.addTabToCurrentProject(isClaude: true) + windowController?.addTabToCurrentProject(kind: .claude) + } + + @objc private func handleNewCodexTab() { + windowController?.addTabToCurrentProject(kind: .codex) } @objc private func handleCloseTab() { @@ -193,6 +198,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { claudeItem.setShortcut(for: .newClaudeTab) fileMenu.addItem(claudeItem) + let codexItem = NSMenuItem(title: "New Codex Tab", action: #selector(newCodexTab), keyEquivalent: "") + codexItem.setShortcut(for: .newCodexTab) + fileMenu.addItem(codexItem) + let termItem = NSMenuItem(title: "New Terminal Tab", action: #selector(newTerminalTab), keyEquivalent: "") termItem.setShortcut(for: .newTerminalTab) fileMenu.addItem(termItem) @@ -298,11 +307,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @objc private func newClaudeTab() { - windowController?.addTabToCurrentProject(isClaude: true) + windowController?.addTabToCurrentProject(kind: .claude) + } + + @objc private func newCodexTab() { + windowController?.addTabToCurrentProject(kind: .codex) } @objc private func newTerminalTab() { - windowController?.addTabToCurrentProject(isClaude: false) + windowController?.addTabToCurrentProject(kind: .terminal) } @objc private func closeCurrentTab() { diff --git a/Sources/App/ShortcutNames.swift b/Sources/App/ShortcutNames.swift index 9f834ad..9df930d 100644 --- a/Sources/App/ShortcutNames.swift +++ b/Sources/App/ShortcutNames.swift @@ -3,6 +3,7 @@ import KeyboardShortcuts extension KeyboardShortcuts.Name { static let openFolder = Self("openFolder", default: .init(.o, modifiers: .command)) static let newClaudeTab = Self("newClaudeTab", default: .init(.t, modifiers: .command)) + static let newCodexTab = Self("newCodexTab", default: .init(.t, modifiers: [.command, .option])) static let newTerminalTab = Self("newTerminalTab", default: .init(.t, modifiers: [.command, .shift])) static let closeTab = Self("closeTab", default: .init(.w, modifiers: .command)) static let closeFolder = Self("closeFolder", default: .init(.w, modifiers: [.command, .shift])) @@ -36,6 +37,7 @@ struct ShortcutEntry { let configurableShortcuts: [ShortcutEntry] = [ ShortcutEntry(name: .openFolder, label: "Open Folder"), ShortcutEntry(name: .newClaudeTab, label: "New Claude Tab"), + ShortcutEntry(name: .newCodexTab, label: "New Codex Tab"), ShortcutEntry(name: .newTerminalTab, label: "New Terminal Tab"), ShortcutEntry(name: .closeTab, label: "Close Tab"), ShortcutEntry(name: .closeFolder, label: "Close Folder"), diff --git a/Sources/Control/ControlSocket.swift b/Sources/Control/ControlSocket.swift index f893f95..c9c15c6 100644 --- a/Sources/Control/ControlSocket.swift +++ b/Sources/Control/ControlSocket.swift @@ -263,6 +263,7 @@ struct TabInfo: Codable { var id: String var name: String var isClaude: Bool + var kind: String? = nil var isMaster: Bool var sessionId: String? var badgeState: String diff --git a/Sources/Detection/ContextMonitor.swift b/Sources/Detection/ContextMonitor.swift index 33958d2..a3a4984 100644 --- a/Sources/Detection/ContextMonitor.swift +++ b/Sources/Detection/ContextMonitor.swift @@ -14,7 +14,7 @@ extension String { } } -/// Reads Claude Code session JSONL files to calculate context usage. +/// Reads Claude Code and Codex session files to calculate session metadata and usage. class ContextMonitor { static let shared = ContextMonitor() @@ -41,11 +41,40 @@ class ContextMonitor { } + struct CodexActivityInfo { + let isBusy: Bool + let isError: Bool + let timestamp: Date? + } + + struct CodexUsageInfo { + let context: ContextUsage? + let quotaSnapshot: QuotaMonitor.QuotaSnapshot? + let tokenRate: QuotaMonitor.TokenRate? + let sparklineData: [Double] + } + struct SessionInfo { let sessionId: String let modificationDate: Date let firstUserMessage: String let messageCount: Int + let kind: TabKind + let filePath: URL? + + init(sessionId: String, + modificationDate: Date, + firstUserMessage: String, + messageCount: Int, + kind: TabKind = .claude, + filePath: URL? = nil) { + self.sessionId = sessionId + self.modificationDate = modificationDate + self.firstUserMessage = firstUserMessage + self.messageCount = messageCount + self.kind = kind + self.filePath = filePath + } } /// Lists all Claude sessions for a project, sorted by most recent first. @@ -98,7 +127,9 @@ class ContextMonitor { sessionId: sessionId, modificationDate: modDate, firstUserMessage: firstMessage, - messageCount: 0 + messageCount: 0, + kind: .claude, + filePath: URL(fileURLWithPath: filePath) )) } @@ -106,9 +137,326 @@ class ContextMonitor { return results } + /// Lists Claude and Codex sessions for a project, sorted by most recent first. + func listAllSessions(forProjectPath projectPath: String) -> [SessionInfo] { + (listSessions(forProjectPath: projectPath) + listCodexSessions(forProjectPath: projectPath)) + .sorted { $0.modificationDate > $1.modificationDate } + } + + /// Lists Codex sessions for a project by scanning ~/.codex/sessions. + func listCodexSessions(forProjectPath projectPath: String) -> [SessionInfo] { + let root = codexSessionsRoot + let fm = FileManager.default + guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles]) else { + return [] + } + + let resolvedProjectPath = (projectPath as NSString).resolvingSymlinksInPath + var results: [SessionInfo] = [] + + for case let fileURL as URL in enumerator where fileURL.pathExtension == "jsonl" { + guard let info = parseCodexSessionInfo(fileURL: fileURL, projectPath: resolvedProjectPath) else { continue } + results.append(info) + } + + results.sort { $0.modificationDate > $1.modificationDate } + return results + } + + private var codexSessionsRoot: URL { + URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".codex/sessions") + } + + private var codexStateDatabaseURL: URL { + URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".codex/state_5.sqlite") + } + + func codexSessionFileURL(sessionId: String) -> URL? { + let fm = FileManager.default + guard let enumerator = fm.enumerator(at: codexSessionsRoot, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else { + return nil + } + for case let fileURL as URL in enumerator + where fileURL.pathExtension == "jsonl" && fileURL.lastPathComponent.contains(sessionId) { + return fileURL + } + return nil + } + + func latestCodexSession(forProjectPath projectPath: String, after date: Date, excluding excludedIds: Set = []) -> SessionInfo? { + listCodexSessions(forProjectPath: projectPath) + .first { $0.modificationDate >= date && !excludedIds.contains($0.sessionId) } + } + + func codexActivityInfo(sessionId: String) -> CodexActivityInfo? { + guard let content = codexTailContent(sessionId: sessionId, maxBytes: 256 * 1024) else { + return nil + } + + var latest: CodexActivityInfo? + for line in content.split(separator: "\n") { + guard let json = parseJSONObject(line), + json["type"] as? String == "event_msg", + let payload = json["payload"] as? [String: Any], + let payloadType = payload["type"] as? String else { continue } + + let isBusy: Bool + let isError: Bool + switch payloadType { + case "task_started": + isBusy = true + isError = false + case "task_complete", "task_cancelled": + isBusy = false + isError = false + case "task_failed": + isBusy = false + isError = true + default: + continue + } + + let timestamp = (json["timestamp"] as? String).flatMap { codexTimestampFormatter.date(from: $0) } + latest = CodexActivityInfo(isBusy: isBusy, isError: isError, timestamp: timestamp) + } + + return latest + } + + func getCodexUsage(sessionId: String) -> CodexUsageInfo? { + guard let content = codexTailContent(sessionId: sessionId, maxBytes: 1024 * 1024) else { + return nil + } + + let now = Date() + let cutoff = now.addingTimeInterval(-300) + var context: ContextUsage? + var quotaSnapshot: QuotaMonitor.QuotaSnapshot? + var generatedEvents: [(timestamp: Date, tokens: Int)] = [] + + for line in content.split(separator: "\n") { + guard let json = parseJSONObject(line), + json["type"] as? String == "event_msg", + let payload = json["payload"] as? [String: Any], + payload["type"] as? String == "token_count" else { continue } + + let timestamp = (json["timestamp"] as? String).flatMap { codexTimestampFormatter.date(from: $0) } + + if let info = payload["info"] as? [String: Any], + let lastUsage = info["last_token_usage"] as? [String: Any] { + let contextWindow = codexInt(info["model_context_window"]) ?? 0 + let totalTokens = codexInt(lastUsage["total_tokens"]) + ?? ((codexInt(lastUsage["input_tokens"]) ?? 0) + + (codexInt(lastUsage["output_tokens"]) ?? 0) + + (codexInt(lastUsage["reasoning_output_tokens"]) ?? 0)) + + if contextWindow > 0, totalTokens > 0 { + context = ContextUsage( + model: "codex", + inputTokens: totalTokens, + cacheReadTokens: 0, + contextLimit: contextWindow) + } + + if let timestamp, timestamp >= cutoff { + let generated = (codexInt(lastUsage["output_tokens"]) ?? 0) + + (codexInt(lastUsage["reasoning_output_tokens"]) ?? 0) + if generated > 0 { + generatedEvents.append((timestamp: timestamp, tokens: generated)) + } + } + } + + if let rateLimits = payload["rate_limits"] as? [String: Any], + let snapshot = codexQuotaSnapshot(from: rateLimits, timestamp: timestamp ?? now) { + quotaSnapshot = snapshot + } + } + + let tokenRate = codexTokenRate(from: generatedEvents, now: now) + let sparklineData = generatedEvents.suffix(30).map { Double($0.tokens) } + + guard context != nil || quotaSnapshot != nil || tokenRate != nil || !sparklineData.isEmpty else { + return nil + } + + return CodexUsageInfo( + context: context, + quotaSnapshot: quotaSnapshot, + tokenRate: tokenRate, + sparklineData: sparklineData) + } + + private func parseCodexSessionInfo(fileURL: URL, projectPath: String) -> SessionInfo? { + guard let data = try? Data(contentsOf: fileURL), + let content = String(data: data, encoding: .utf8) else { return nil } + + var sessionId: String? + var cwd: String? + var firstUserMessage = "" + var messageCount = 0 + let iso8601 = ISO8601DateFormatter() + iso8601.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var metaTimestamp: Date? + + for line in content.split(separator: "\n") { + guard let json = parseJSONObject(line) else { continue } + let type = json["type"] as? String ?? "" + + if type == "session_meta", let payload = json["payload"] as? [String: Any] { + sessionId = payload["id"] as? String + if let rawCwd = payload["cwd"] as? String { + cwd = (rawCwd as NSString).resolvingSymlinksInPath + } + if let ts = payload["timestamp"] as? String { + metaTimestamp = iso8601.date(from: ts) + } + continue + } + + guard type == "response_item", + let payload = json["payload"] as? [String: Any], + let role = payload["role"] as? String, + role == "user", + let text = codexMessageText(from: payload), + !isSyntheticCodexUserMessage(text) else { continue } + + messageCount += 1 + if firstUserMessage.isEmpty { + firstUserMessage = text.split(separator: "\n").first.map(String.init) ?? text + firstUserMessage = firstUserMessage.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + guard let sessionId, cwd == projectPath else { return nil } + + let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path) + let modDate = attrs?[.modificationDate] as? Date ?? metaTimestamp ?? Date.distantPast + + return SessionInfo( + sessionId: sessionId, + modificationDate: modDate, + firstUserMessage: firstUserMessage, + messageCount: messageCount, + kind: .codex, + filePath: fileURL + ) + } + + private func parseJSONObject(_ line: Substring) -> [String: Any]? { + guard let lineData = line.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else { + return nil + } + return json + } + + private func codexMessageText(from payload: [String: Any]) -> String? { + guard let content = payload["content"] as? [[String: Any]] else { return nil } + let parts = content.compactMap { block -> String? in + if let text = block["text"] as? String { + return text + } + return nil + } + let text = parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } + + private func codexTailContent(sessionId: String, maxBytes: UInt64) -> String? { + guard let fileURL = codexSessionFileURL(sessionId: sessionId), + let fh = FileHandle(forReadingAtPath: fileURL.path) else { return nil } + defer { try? fh.close() } + + let fileSize = fh.seekToEndOfFile() + guard fileSize > 0 else { return nil } + + let offset = fileSize > maxBytes ? fileSize - maxBytes : 0 + fh.seek(toFileOffset: offset) + let data = fh.readData(ofLength: Int(fileSize - offset)) + return String(data: data, encoding: .utf8) + } + + private func codexQuotaSnapshot(from rateLimits: [String: Any], timestamp: Date) -> QuotaMonitor.QuotaSnapshot? { + guard let primary = rateLimits["primary"] as? [String: Any], + let secondary = rateLimits["secondary"] as? [String: Any] else { return nil } + + let primaryUsed = codexDouble(primary["used_percent"]) ?? 0 + let secondaryUsed = codexDouble(secondary["used_percent"]) ?? 0 + let primaryReset = codexDouble(primary["resets_at"]).map { Date(timeIntervalSince1970: $0) } + let secondaryReset = codexDouble(secondary["resets_at"]).map { Date(timeIntervalSince1970: $0) } + + guard primaryUsed > 0 || secondaryUsed > 0 || primaryReset != nil || secondaryReset != nil else { + return nil + } + + return QuotaMonitor.QuotaSnapshot( + fiveHourUsed: primaryUsed, + fiveHourResetsAt: primaryReset, + sevenDayUsed: secondaryUsed, + sevenDayResetsAt: secondaryReset, + lastUpdated: timestamp) + } + + private func codexTokenRate(from events: [(timestamp: Date, tokens: Int)], now: Date) -> QuotaMonitor.TokenRate? { + guard let earliest = events.map(\.timestamp).min() else { return nil } + let totalTokens = events.reduce(0) { $0 + $1.tokens } + guard totalTokens > 0 else { return nil } + + let elapsedMinutes = max(now.timeIntervalSince(earliest) / 60.0, 1.0) + return QuotaMonitor.TokenRate( + tokensPerMinute: Double(totalTokens) / elapsedMinutes, + windowSeconds: Int(now.timeIntervalSince(earliest))) + } + + private func codexInt(_ value: Any?) -> Int? { + if let int = value as? Int { return int } + if let double = value as? Double { return Int(double) } + if let string = value as? String { return Int(string) } + return nil + } + + private func codexDouble(_ value: Any?) -> Double? { + if let double = value as? Double { return double } + if let int = value as? Int { return Double(int) } + if let string = value as? String { return Double(string) } + return nil + } + + private func isSyntheticCodexUserMessage(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.hasPrefix("") || + trimmed.hasPrefix("") || + trimmed.hasPrefix("") + } + + private func codexActionDescription(name: String, arguments: String?) -> String { + guard let arguments, + let data = arguments.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return name + } + + if let cmd = json["cmd"] as? String ?? json["command"] as? String { + let brief = cmd.split(separator: "\n").first.map(String.init) ?? cmd + return "\(name): \(String(brief.prefix(50)))" + } + if let path = json["path"] as? String ?? json["file_path"] as? String { + return "\(name) \((path as NSString).lastPathComponent)" + } + if let pattern = json["pattern"] as? String { + return "\(name) \(pattern)" + } + return name + } + /// Parses a session JSONL file and returns an ordered list of user turns. /// Deduplicates by promptId — only the first occurrence with non-empty content is kept. - func parseTimeline(sessionId: String, projectPath: String) -> [TimelineEntry] { + func parseTimeline(sessionId: String, projectPath: String, kind: TabKind = .claude) -> [TimelineEntry] { + if kind == .codex { + return parseCodexTimeline(sessionId: sessionId, projectPath: projectPath) + } + let encoded = projectPath.claudeProjectDirName let jsonlPath = NSHomeDirectory() + "/.claude/projects/\(encoded)/\(sessionId).jsonl" @@ -162,9 +510,45 @@ class ContextMonitor { return entries } + private func parseCodexTimeline(sessionId: String, projectPath: String) -> [TimelineEntry] { + guard let fileURL = codexSessionFileURL(sessionId: sessionId), + let data = try? Data(contentsOf: fileURL), + let content = String(data: data, encoding: .utf8) else { return [] } + + var entries: [TimelineEntry] = [] + let iso8601 = ISO8601DateFormatter() + iso8601.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + for line in content.split(separator: "\n") { + guard let json = parseJSONObject(line), + let text = codexRealUserMessageText(from: json) else { continue } + + let timestamp: Date? + if let ts = json["timestamp"] as? String { + timestamp = iso8601.date(from: ts) + } else { + timestamp = nil + } + + entries.append(TimelineEntry( + index: entries.count, + promptId: "\(sessionId)-\(entries.count)", + message: text, + timestamp: timestamp, + actionSummary: nil + )) + } + + return entries + } + /// Extracts a raw description of tool uses for each user turn in a session. /// Returns a dictionary mapping turn index to a list of action descriptions. - func parseActions(sessionId: String, projectPath: String) -> [Int: [String]] { + func parseActions(sessionId: String, projectPath: String, kind: TabKind = .claude) -> [Int: [String]] { + if kind == .codex { + return parseCodexActions(sessionId: sessionId, projectPath: projectPath) + } + let encoded = projectPath.claudeProjectDirName let jsonlPath = NSHomeDirectory() + "/.claude/projects/\(encoded)/\(sessionId).jsonl" @@ -222,9 +606,50 @@ class ContextMonitor { return result } + private func parseCodexActions(sessionId: String, projectPath: String) -> [Int: [String]] { + guard let fileURL = codexSessionFileURL(sessionId: sessionId), + let data = try? Data(contentsOf: fileURL), + let content = String(data: data, encoding: .utf8) else { return [:] } + + var result: [Int: [String]] = [:] + var currentTurnIndex = -1 + + for line in content.split(separator: "\n") { + guard let json = parseJSONObject(line), + json["type"] as? String == "response_item", + let payload = json["payload"] as? [String: Any], + let payloadType = payload["type"] as? String else { continue } + + if codexRealUserMessageText(from: json) != nil { + currentTurnIndex += 1 + continue + } + + if payloadType == "function_call", currentTurnIndex >= 0, + let name = payload["name"] as? String { + let desc = codexActionDescription(name: name, arguments: payload["arguments"] as? String) + result[currentTurnIndex, default: []].append(desc) + } + } + + return result + } + /// Creates a truncated copy of a session JSONL, keeping everything up to (and including /// the full response for) the Nth unique user turn. Returns the new session ID. - func truncateSession(sessionId: String, projectPath: String, afterTurnIndex: Int) -> String? { + func truncateSession(sessionId: String, projectPath: String, afterTurnIndex: Int, kind: TabKind = .claude) -> String? { + switch kind { + case .claude: + return truncateClaudeSession(sessionId: sessionId, projectPath: projectPath, afterTurnIndex: afterTurnIndex) + case .codex: + return truncateCodexSession(sessionId: sessionId, projectPath: projectPath, afterTurnIndex: afterTurnIndex) + case .terminal: + return nil + } + } + + private func truncateClaudeSession(sessionId: String, projectPath: String, afterTurnIndex: Int) -> String? { + let encoded = projectPath.claudeProjectDirName let dir = NSHomeDirectory() + "/.claude/projects/\(encoded)" let jsonlPath = dir + "/\(sessionId).jsonl" @@ -282,6 +707,207 @@ class ContextMonitor { } } + private func truncateCodexSession(sessionId: String, projectPath: String, afterTurnIndex: Int) -> String? { + guard let sourceURL = codexSessionFileURL(sessionId: sessionId), + let data = try? Data(contentsOf: sourceURL), + let content = String(data: data, encoding: .utf8) else { return nil } + + let lines = content.split(separator: "\n", omittingEmptySubsequences: false) + var turnIndex = -1 + var cutoffLineIndex = lines.count + var pendingTurnStartIndex: Int? + + for (i, line) in lines.enumerated() where !line.isEmpty { + guard let json = parseJSONObject(line) else { continue } + + if codexLineStartsTurn(json) { + pendingTurnStartIndex = i + } + + guard codexRealUserMessageText(from: json) != nil else { continue } + + turnIndex += 1 + if turnIndex > afterTurnIndex { + cutoffLineIndex = pendingTurnStartIndex ?? i + break + } + + pendingTurnStartIndex = nil + } + + let keptLines = lines.prefix(cutoffLineIndex).filter { !$0.isEmpty } + guard !keptLines.isEmpty else { return nil } + + let newSessionId = UUID().uuidString.lowercased() + let now = Date() + let destinationURL = codexRolloutURL(sessionId: newSessionId, date: now) + + var rewrittenLines: [String] = [] + rewrittenLines.reserveCapacity(keptLines.count) + + for (i, line) in keptLines.enumerated() { + if i == 0 { + guard let rewritten = rewriteCodexSessionMeta(line, newSessionId: newSessionId, date: now) else { + return nil + } + rewrittenLines.append(rewritten) + } else { + rewrittenLines.append(String(line)) + } + } + + let firstMessage = codexFirstUserMessage(from: rewrittenLines) + let truncatedContent = rewrittenLines.joined(separator: "\n") + "\n" + + do { + try FileManager.default.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try truncatedContent.write(to: destinationURL, atomically: true, encoding: .utf8) + } catch { + return nil + } + + guard registerCodexThreadFork( + originalSessionId: sessionId, + newSessionId: newSessionId, + rolloutPath: destinationURL.path, + firstUserMessage: firstMessage, + date: now + ) else { + try? FileManager.default.removeItem(at: destinationURL) + return nil + } + + return newSessionId + } + + private func codexLineStartsTurn(_ json: [String: Any]) -> Bool { + if json["type"] as? String == "turn_context" { + return true + } + guard json["type"] as? String == "event_msg", + let payload = json["payload"] as? [String: Any] else { return false } + return payload["type"] as? String == "task_started" + } + + private func codexRealUserMessageText(from json: [String: Any]) -> String? { + guard json["type"] as? String == "response_item", + let payload = json["payload"] as? [String: Any], + payload["type"] as? String == "message", + payload["role"] as? String == "user", + let text = codexMessageText(from: payload), + !isSyntheticCodexUserMessage(text) else { return nil } + return text + } + + private func rewriteCodexSessionMeta(_ line: Substring, newSessionId: String, date: Date) -> String? { + guard var json = parseJSONObject(line), + json["type"] as? String == "session_meta", + var payload = json["payload"] as? [String: Any] else { return nil } + + let timestamp = codexTimestampFormatter.string(from: date) + json["timestamp"] = timestamp + payload["id"] = newSessionId + payload["timestamp"] = timestamp + json["payload"] = payload + + guard JSONSerialization.isValidJSONObject(json), + let data = try? JSONSerialization.data(withJSONObject: json), + let rewritten = String(data: data, encoding: .utf8) else { return nil } + return rewritten + } + + private var codexTimestampFormatter: ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + } + + private func codexRolloutURL(sessionId: String, date: Date) -> URL { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + let components = calendar.dateComponents([.year, .month, .day], from: date) + + let stampFormatter = DateFormatter() + stampFormatter.calendar = calendar + stampFormatter.timeZone = calendar.timeZone + stampFormatter.locale = Locale(identifier: "en_US_POSIX") + stampFormatter.dateFormat = "yyyy-MM-dd'T'HH-mm-ss" + + return codexSessionsRoot + .appendingPathComponent(String(format: "%04d", components.year ?? 0)) + .appendingPathComponent(String(format: "%02d", components.month ?? 0)) + .appendingPathComponent(String(format: "%02d", components.day ?? 0)) + .appendingPathComponent("rollout-\(stampFormatter.string(from: date))-\(sessionId).jsonl") + } + + private func codexFirstUserMessage(from lines: [String]) -> String { + for line in lines { + guard let json = parseJSONObject(Substring(line)), + let text = codexRealUserMessageText(from: json) else { continue } + return text.split(separator: "\n").first.map(String.init)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + return "" + } + + private func registerCodexThreadFork(originalSessionId: String, newSessionId: String, rolloutPath: String, firstUserMessage: String, date: Date) -> Bool { + guard FileManager.default.fileExists(atPath: codexStateDatabaseURL.path) else { return false } + + let seconds = Int(date.timeIntervalSince1970) + let milliseconds = Int(date.timeIntervalSince1970 * 1000) + let title = firstUserMessage.isEmpty ? "Forked Codex session" : firstUserMessage + + let sql = """ + PRAGMA busy_timeout=2000; + BEGIN IMMEDIATE; + INSERT OR REPLACE INTO threads ( + id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, + sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at, + git_sha, git_branch, git_origin_url, cli_version, first_user_message, + agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path, + created_at_ms, updated_at_ms + ) + SELECT + \(sqlString(newSessionId)), \(sqlString(rolloutPath)), \(seconds), \(seconds), + source, model_provider, cwd, \(sqlString(title)), + sandbox_policy, approval_mode, 0, has_user_event, 0, NULL, + git_sha, git_branch, git_origin_url, cli_version, \(sqlString(firstUserMessage)), + agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path, + \(milliseconds), \(milliseconds) + FROM threads + WHERE id = \(sqlString(originalSessionId)); + SELECT changes(); + COMMIT; + """ + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") + process.arguments = [codexStateDatabaseURL.path, sql] + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + do { + try process.run() + process.waitUntilExit() + } catch { + return false + } + + guard process.terminationStatus == 0 else { return false } + + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: outputData, encoding: .utf8) ?? "" + return output.split(whereSeparator: \.isWhitespace).last == "1" + } + + private func sqlString(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "''"))'" + } + /// Per-session cache so we don't flicker the context bar to nil when a tail /// read misses the usage entry (e.g. large tool-result block at end of file). /// Access only via `cachedUsage(_:)` and `setCachedUsage(_:for:)`. diff --git a/Sources/Detection/ProcessMonitor.swift b/Sources/Detection/ProcessMonitor.swift index ab0ccfa..bed0723 100644 --- a/Sources/Detection/ProcessMonitor.swift +++ b/Sources/Detection/ProcessMonitor.swift @@ -14,9 +14,22 @@ class ProcessMonitor { struct TabInfo { let surfaceId: UUID - let isClaude: Bool + let kind: TabKind let name: String let projectPath: String + + var isClaude: Bool { kind == .claude } + + init(surfaceId: UUID, kind: TabKind, name: String, projectPath: String) { + self.surfaceId = surfaceId + self.kind = kind + self.name = name + self.projectPath = projectPath + } + + init(surfaceId: UUID, isClaude: Bool, name: String, projectPath: String) { + self.init(surfaceId: surfaceId, kind: isClaude ? .claude : .terminal, name: name, projectPath: projectPath) + } } struct ActivityInfo: Equatable { @@ -79,7 +92,7 @@ class ProcessMonitor { // MARK: - Core Poll (called on queue) private func _poll(tabs: [TabInfo]) -> [UUID: ActivityInfo] { - let terminalTabs = tabs.filter { !$0.isClaude } + let terminalTabs = tabs.filter { $0.kind == .terminal } guard !terminalTabs.isEmpty else { return [:] } // Resolve registered shell PIDs → (login, shell) pairs for uncached tabs. @@ -100,7 +113,7 @@ class ProcessMonitor { if !hasLoggedMapping && cachedPids.count == tabs.count { hasLoggedMapping = true let lines = tabs.map { tab -> String in - let prefix = tab.isClaude ? "C" : "T" + let prefix = tab.kind.rawValue.prefix(1).uppercased() let pid = cachedPids[tab.surfaceId].map { "login=\($0.login) shell=\($0.shell)" } ?? "?" return " \(prefix):\(tab.name)@\(tab.projectPath) → \(pid)" } diff --git a/Sources/Session/BookmarkManager.swift b/Sources/Session/BookmarkManager.swift index 3e886a3..35b3792 100644 --- a/Sources/Session/BookmarkManager.swift +++ b/Sources/Session/BookmarkManager.swift @@ -16,21 +16,34 @@ class BookmarkManager { /// Returns all bookmarked session IDs for a project. func bookmarkedSessionIds(forProjectPath projectPath: String) -> Set { + bookmarkedSessionIds(forProjectPath: projectPath, kind: .claude) + } + + func bookmarkedSessionIds(forProjectPath projectPath: String, kind: TabKind) -> Set { let all = loadAll() - let key = projectPath.claudeProjectDirName + let key = projectKey(projectPath: projectPath, kind: kind) return Set(all[key] ?? []) } /// Checks if a session is bookmarked. func isBookmarked(projectPath: String, sessionId: String) -> Bool { - bookmarkedSessionIds(forProjectPath: projectPath).contains(sessionId) + isBookmarked(projectPath: projectPath, sessionId: sessionId, kind: .claude) + } + + func isBookmarked(projectPath: String, sessionId: String, kind: TabKind) -> Bool { + bookmarkedSessionIds(forProjectPath: projectPath, kind: kind).contains(sessionId) } /// Toggles the bookmark state for a session. Returns the new state. @discardableResult func toggleBookmark(projectPath: String, sessionId: String) -> Bool { + toggleBookmark(projectPath: projectPath, sessionId: sessionId, kind: .claude) + } + + @discardableResult + func toggleBookmark(projectPath: String, sessionId: String, kind: TabKind) -> Bool { var all = loadAll() - let key = projectPath.claudeProjectDirName + let key = projectKey(projectPath: projectPath, kind: kind) var ids = all[key] ?? [] if let idx = ids.firstIndex(of: sessionId) { @@ -48,6 +61,11 @@ class BookmarkManager { // MARK: - Private + private func projectKey(projectPath: String, kind: TabKind) -> String { + let encoded = projectPath.claudeProjectDirName + return kind == .claude ? encoded : "\(kind.rawValue):\(encoded)" + } + private func loadAll() -> [String: [String]] { if let cached = cache { return cached } guard let data = try? Data(contentsOf: fileURL), diff --git a/Sources/Session/SessionExplorerModels.swift b/Sources/Session/SessionExplorerModels.swift index 3962462..89b3557 100644 --- a/Sources/Session/SessionExplorerModels.swift +++ b/Sources/Session/SessionExplorerModels.swift @@ -1,7 +1,8 @@ import Foundation -/// A Claude Code session on disk, enriched with parsed metadata. +/// An agent session on disk, enriched with parsed metadata. struct ExplorerSessionInfo { + let agentKind: TabKind let sessionId: String let filePath: URL let modificationDate: Date @@ -10,6 +11,30 @@ struct ExplorerSessionInfo { var savedName: String? var summary: String? var isBookmarked: Bool + + var cacheKey: String { + SessionManager.sessionCacheKey(sessionId: sessionId, kind: agentKind) + } + + init(agentKind: TabKind = .claude, + sessionId: String, + filePath: URL, + modificationDate: Date, + messageCount: Int, + firstUserMessage: String, + savedName: String?, + summary: String?, + isBookmarked: Bool) { + self.agentKind = agentKind + self.sessionId = sessionId + self.filePath = filePath + self.modificationDate = modificationDate + self.messageCount = messageCount + self.firstUserMessage = firstUserMessage + self.savedName = savedName + self.summary = summary + self.isBookmarked = isBookmarked + } } /// A single user turn within a session timeline. diff --git a/Sources/Session/SessionExplorerTimelineView.swift b/Sources/Session/SessionExplorerTimelineView.swift index 7d92ea5..dbef4af 100644 --- a/Sources/Session/SessionExplorerTimelineView.swift +++ b/Sources/Session/SessionExplorerTimelineView.swift @@ -10,6 +10,7 @@ class SessionExplorerTimelineController: NSObject, NSTableViewDataSource, NSTabl private var currentSession: ExplorerSessionInfo? private var entries: [TimelineEntry] = [] + private var forkAtPointEnabled = false private var summarizeSpinner: NSProgressIndicator? // Callbacks @@ -59,6 +60,7 @@ class SessionExplorerTimelineController: NSObject, NSTableViewDataSource, NSTabl let cachedActionSummaries: [Int: String] let summarizeEnabled: Bool let resumeEnabled: Bool + let forkAtPointEnabled: Bool let scrollToIndex: Int? } @@ -67,6 +69,7 @@ class SessionExplorerTimelineController: NSObject, NSTableViewDataSource, NSTabl func showTimeline(session: ExplorerSessionInfo, entries: [TimelineEntry], options: TimelineOptions) { self.currentSession = session self.entries = entries + self.forkAtPointEnabled = options.forkAtPointEnabled // Apply cached action summaries for i in 0.. Void)? + /// Parameters: kind, sessionId, forkSession flag, tab name. + var onSessionAction: ((TabKind, String, Bool, String?) -> Void)? /// Session IDs currently open in the project's tabs. var openSessionIds = Set() @@ -169,20 +169,22 @@ class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, // MARK: - Data Loading private func loadData() { - let rawSessions = ContextMonitor.shared.listSessions(forProjectPath: projectPath) + let rawSessions = ContextMonitor.shared.listAllSessions(forProjectPath: projectPath) let savedNames = SessionManager.shared.loadSessionNames() - let bookmarkedIds = BookmarkManager.shared.bookmarkedSessionIds(forProjectPath: projectPath) allSessions = rawSessions.map { session in - let name = savedNames[session.sessionId] + let cacheKey = SessionManager.sessionCacheKey(sessionId: session.sessionId, kind: session.kind) + let name = savedNames[cacheKey] + let bookmarkedIds = BookmarkManager.shared.bookmarkedSessionIds(forProjectPath: projectPath, kind: session.kind) return ExplorerSessionInfo( + agentKind: session.kind, sessionId: session.sessionId, - filePath: URL(fileURLWithPath: NSHomeDirectory() + "/.claude/projects/\(projectPath.claudeProjectDirName)/\(session.sessionId).jsonl"), + filePath: session.filePath ?? URL(fileURLWithPath: NSHomeDirectory() + "/.claude/projects/\(projectPath.claudeProjectDirName)/\(session.sessionId).jsonl"), modificationDate: session.modificationDate, messageCount: session.messageCount, firstUserMessage: session.firstUserMessage, savedName: (name?.isEmpty == false) ? name : nil, - summary: SummaryManager.shared.cachedSummary(forSessionId: session.sessionId), + summary: SummaryManager.shared.cachedSummary(forSessionId: session.sessionId, kind: session.kind), isBookmarked: bookmarkedIds.contains(session.sessionId) ) } @@ -228,7 +230,7 @@ class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, } private func restoreListSelection(sessionId: String) { - if let idx = filteredSessions.firstIndex(where: { $0.sessionId == sessionId }) { + if let idx = filteredSessions.firstIndex(where: { $0.cacheKey == sessionId }) { listTableView.selectRowIndexes(IndexSet(integer: idx), byExtendingSelection: false) } } @@ -236,39 +238,43 @@ class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, // MARK: - Actions private func sessionDisplayName(for sessionId: String) -> String? { + guard let session = allSessions.first(where: { $0.cacheKey == sessionId || $0.sessionId == sessionId }) else { return nil } let savedNames = SessionManager.shared.loadSessionNames() - if let name = savedNames[sessionId], !name.isEmpty { return name } - guard let session = allSessions.first(where: { $0.sessionId == sessionId }) else { return nil } + if let name = savedNames[session.cacheKey], !name.isEmpty { return name } let msg = session.firstUserMessage return msg.isEmpty ? nil : String(msg.prefix(60)) } private func performAction(sessionId: String, fork: Bool) { - onSessionAction?(sessionId, fork, sessionDisplayName(for: sessionId)) + guard let session = allSessions.first(where: { $0.cacheKey == selectedSessionId || $0.sessionId == sessionId }) else { return } + onSessionAction?(session.agentKind, session.sessionId, fork, sessionDisplayName(for: session.cacheKey)) close() } private func performForkAtPoint(sessionId: String, turnIndex: Int) { - let name = sessionDisplayName(for: sessionId) - guard let newSessionId = ContextMonitor.shared.truncateSession( + guard let session = allSessions.first(where: { $0.cacheKey == selectedSessionId || $0.sessionId == sessionId }), + let newSessionId = ContextMonitor.shared.truncateSession( sessionId: sessionId, projectPath: projectPath, - afterTurnIndex: turnIndex + afterTurnIndex: turnIndex, + kind: session.agentKind ) else { return } - onSessionAction?(newSessionId, true, name) + let name = sessionDisplayName(for: session.cacheKey) + onSessionAction?(session.agentKind, newSessionId, true, name) close() } @objc private func starClicked(_ sender: NSButton) { let row = sender.tag guard row < filteredSessions.count else { return } - let sessionId = filteredSessions[row].sessionId - let newState = BookmarkManager.shared.toggleBookmark(projectPath: projectPath, sessionId: sessionId) - if let idx = allSessions.firstIndex(where: { $0.sessionId == sessionId }) { + let session = filteredSessions[row] + let sessionId = session.sessionId + let newState = BookmarkManager.shared.toggleBookmark(projectPath: projectPath, sessionId: sessionId, kind: session.agentKind) + if let idx = allSessions.firstIndex(where: { $0.cacheKey == session.cacheKey }) { allSessions[idx].isBookmarked = newState } - if let fIdx = filteredSessions.firstIndex(where: { $0.sessionId == sessionId }) { + if let fIdx = filteredSessions.firstIndex(where: { $0.cacheKey == session.cacheKey }) { filteredSessions[fIdx].isBookmarked = newState } // Update only the button itself — no row reload @@ -292,33 +298,34 @@ class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, let row = listTableView.selectedRow guard row >= 0, row < filteredSessions.count else { return } let session = filteredSessions[row] - selectSession(sessionId: session.sessionId, scrollToMessageIndex: nil) + selectSession(cacheKey: session.cacheKey, scrollToMessageIndex: nil) } - private func selectSession(sessionId: String, scrollToMessageIndex: Int?) { - selectedSessionId = sessionId - guard let session = allSessions.first(where: { $0.sessionId == sessionId }) else { return } + private func selectSession(cacheKey: String, scrollToMessageIndex: Int?) { + selectedSessionId = cacheKey + guard let session = allSessions.first(where: { $0.cacheKey == cacheKey }) else { return } + let sessionId = session.sessionId - let entries = ContextMonitor.shared.parseTimeline(sessionId: sessionId, projectPath: projectPath) + let entries = ContextMonitor.shared.parseTimeline(sessionId: sessionId, projectPath: projectPath, kind: session.agentKind) - if let idx = allSessions.firstIndex(where: { $0.sessionId == sessionId }) { + if let idx = allSessions.firstIndex(where: { $0.cacheKey == cacheKey }) { allSessions[idx].messageCount = entries.count } - let updatedSession = allSessions.first(where: { $0.sessionId == sessionId }) ?? session + let updatedSession = allSessions.first(where: { $0.cacheKey == cacheKey }) ?? session - let cachedActionSummaries = SummaryManager.shared.cachedTurnSummaries(forSessionId: sessionId) + let cachedActionSummaries = SummaryManager.shared.cachedTurnSummaries(forSessionId: sessionId, kind: session.agentKind) - let actions = ContextMonitor.shared.parseActions(sessionId: sessionId, projectPath: projectPath) + let actions = ContextMonitor.shared.parseActions(sessionId: sessionId, projectPath: projectPath, kind: session.agentKind) let hasUncachedActions = entries.contains { entry in let turnActions = actions[entry.index] ?? [] return !turnActions.isEmpty && cachedActionSummaries[entry.index] == nil } - let cachedTurnCount = SummaryManager.shared.cachedSummaryTurnCount(forSessionId: sessionId) + let cachedTurnCount = SummaryManager.shared.cachedSummaryTurnCount(forSessionId: sessionId, kind: session.agentKind) let needsSessionSummary = updatedSession.summary == nil || cachedTurnCount < entries.count let summarizeEnabled = needsSessionSummary || hasUncachedActions - let isOpen = openSessionIds.contains(sessionId) + let isOpen = openSessionIds.contains(updatedSession.cacheKey) timelineController?.showTimeline( session: updatedSession, @@ -327,36 +334,38 @@ class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, cachedActionSummaries: cachedActionSummaries, summarizeEnabled: summarizeEnabled, resumeEnabled: !isOpen, + forkAtPointEnabled: updatedSession.agentKind.isAgent, scrollToIndex: scrollToMessageIndex ) ) timelineController?.onSummarize = { [weak self] in - self?.summarizeAll(sessionId: sessionId, entries: entries, actions: actions) + self?.summarizeAll(session: updatedSession, entries: entries, actions: actions) } } - private func summarizeAll(sessionId: String, entries: [TimelineEntry], actions: [Int: [String]]) { + private func summarizeAll(session: ExplorerSessionInfo, entries: [TimelineEntry], actions: [Int: [String]]) { timelineController?.setSummarizing(true) SummaryManager.shared.generateCombinedSummaries( - sessionId: sessionId, + sessionId: session.sessionId, projectPath: projectPath, + kind: session.agentKind, currentTurnCount: entries.count, actions: actions ) { [weak self] sessionSummary, actionSummaries in - guard let self, self.selectedSessionId == sessionId else { return } + guard let self, self.selectedSessionId == session.cacheKey else { return } if let summary = sessionSummary, - let idx = self.allSessions.firstIndex(where: { $0.sessionId == sessionId }) { + let idx = self.allSessions.firstIndex(where: { $0.cacheKey == session.cacheKey }) { self.allSessions[idx].summary = summary - if let fIdx = self.filteredSessions.firstIndex(where: { $0.sessionId == sessionId }) { + if let fIdx = self.filteredSessions.firstIndex(where: { $0.cacheKey == session.cacheKey }) { self.filteredSessions[fIdx].summary = summary } } // Rebuild right pane with updated data - self.selectSession(sessionId: sessionId, scrollToMessageIndex: nil) + self.selectSession(cacheKey: session.cacheKey, scrollToMessageIndex: nil) } } @@ -401,13 +410,13 @@ extension SessionExplorerWindowController: NSTableViewDataSource, NSTableViewDel // Title — single line, truncates let title = NSTextField(labelWithString: session.savedName ?? session.firstUserMessage) - title.font = .systemFont(ofSize: 13, weight: session.sessionId == selectedSessionId ? .semibold : .regular) + title.font = .systemFont(ofSize: 13, weight: session.cacheKey == selectedSessionId ? .semibold : .regular) title.textColor = .labelColor title.lineBreakMode = .byTruncatingTail // Timestamp let timeStr = relativeFormatter.localizedString(for: session.modificationDate, relativeTo: Date()) - let metaField = NSTextField(labelWithString: timeStr) + let metaField = NSTextField(labelWithString: "\(session.agentKind.displayName) · \(timeStr)") metaField.font = .systemFont(ofSize: 10) metaField.textColor = .tertiaryLabelColor diff --git a/Sources/Session/SessionState.swift b/Sources/Session/SessionState.swift index c0ff2ff..2180f70 100644 --- a/Sources/Session/SessionState.swift +++ b/Sources/Session/SessionState.swift @@ -1,5 +1,23 @@ import Foundation +enum TabKind: String, Codable, CaseIterable { + case claude + case codex + case terminal + + var displayName: String { + switch self { + case .claude: return "Claude" + case .codex: return "Codex" + case .terminal: return "Terminal" + } + } + + var isAgent: Bool { + self == .claude || self == .codex + } +} + /// Persisted state for Deckard — saved to ~/Library/Application Support/Deckard/state.json struct DeckardState: Codable { var version: Int = 2 @@ -42,9 +60,54 @@ struct ProjectState: Codable { struct ProjectTabState: Codable { var id: String var name: String - var isClaude: Bool + var kind: TabKind var sessionId: String? var tmuxSessionName: String? + + var isClaude: Bool { + get { kind == .claude } + set { kind = newValue ? .claude : .terminal } + } + + init(id: String, name: String, kind: TabKind, sessionId: String? = nil, tmuxSessionName: String? = nil) { + self.id = id + self.name = name + self.kind = kind + self.sessionId = sessionId + self.tmuxSessionName = tmuxSessionName + } + + init(id: String, name: String, isClaude: Bool, sessionId: String? = nil, tmuxSessionName: String? = nil) { + self.init(id: id, name: name, kind: isClaude ? .claude : .terminal, sessionId: sessionId, tmuxSessionName: tmuxSessionName) + } + + private enum CodingKeys: String, CodingKey { + case id, name, kind, isClaude, sessionId, tmuxSessionName + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(kind, forKey: .kind) + try container.encode(kind == .claude, forKey: .isClaude) + try container.encodeIfPresent(sessionId, forKey: .sessionId) + try container.encodeIfPresent(tmuxSessionName, forKey: .tmuxSessionName) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + sessionId = try container.decodeIfPresent(String.self, forKey: .sessionId) + tmuxSessionName = try container.decodeIfPresent(String.self, forKey: .tmuxSessionName) + if let decodedKind = try container.decodeIfPresent(TabKind.self, forKey: .kind) { + kind = decodedKind + } else { + let legacyIsClaude = try container.decodeIfPresent(Bool.self, forKey: .isClaude) ?? false + kind = legacyIsClaude ? .claude : .terminal + } + } } struct SidebarFolderState: Codable { @@ -151,6 +214,10 @@ class SessionManager { private var cachedSessionNames: [String: String]? + static func sessionCacheKey(sessionId: String, kind: TabKind) -> String { + kind == .claude ? sessionId : "\(kind.rawValue):\(sessionId)" + } + func loadSessionNames() -> [String: String] { if let cached = cachedSessionNames { return cached } guard let data = try? Data(contentsOf: sessionNamesURL), @@ -163,8 +230,12 @@ class SessionManager { } func saveSessionName(sessionId: String, name: String) { + saveSessionName(sessionId: sessionId, kind: .claude, name: name) + } + + func saveSessionName(sessionId: String, kind: TabKind, name: String) { var names = loadSessionNames() - names[sessionId] = name + names[Self.sessionCacheKey(sessionId: sessionId, kind: kind)] = name cachedSessionNames = names let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] diff --git a/Sources/Session/SummaryManager.swift b/Sources/Session/SummaryManager.swift index 76c4f51..c80bdf0 100644 --- a/Sources/Session/SummaryManager.swift +++ b/Sources/Session/SummaryManager.swift @@ -22,14 +22,22 @@ class SummaryManager { /// Returns a cached summary for the session, or nil if not yet generated. func cachedSummary(forSessionId sessionId: String) -> String? { + cachedSummary(forSessionId: sessionId, kind: .claude) + } + + func cachedSummary(forSessionId sessionId: String, kind: TabKind) -> String? { let all = loadAll() - return all[sessionId]?.summary + return all[cacheKey(sessionId: sessionId, kind: kind)]?.summary } /// Returns the cached turn count for a session summary, or 0 if unknown. func cachedSummaryTurnCount(forSessionId sessionId: String) -> Int { + cachedSummaryTurnCount(forSessionId: sessionId, kind: .claude) + } + + func cachedSummaryTurnCount(forSessionId sessionId: String, kind: TabKind) -> Int { let all = loadAll() - return all[sessionId]?.turnCount ?? 0 + return all[cacheKey(sessionId: sessionId, kind: kind)]?.turnCount ?? 0 } /// Returns true if a summary generation is currently in progress for this session. @@ -41,8 +49,13 @@ class SummaryManager { /// turns than when it was generated, the summary is regenerated. /// Calls `completion` on the main thread with the result. func generateSummary(sessionId: String, projectPath: String, currentTurnCount: Int, completion: @escaping (String?) -> Void) { + generateSummary(sessionId: sessionId, projectPath: projectPath, kind: .claude, currentTurnCount: currentTurnCount, completion: completion) + } + + func generateSummary(sessionId: String, projectPath: String, kind: TabKind, currentTurnCount: Int, completion: @escaping (String?) -> Void) { + let cacheKey = cacheKey(sessionId: sessionId, kind: kind) let all = loadAll() - let cached = all[sessionId] + let cached = all[cacheKey] // Return cached if it covers all current turns if let cached, (cached.turnCount ?? 0) >= currentTurnCount { @@ -51,28 +64,28 @@ class SummaryManager { } // Already in flight? - guard !inFlightSessionIds.contains(sessionId) else { return } - inFlightSessionIds.insert(sessionId) + guard !inFlightSessionIds.contains(cacheKey) else { return } + inFlightSessionIds.insert(cacheKey) // Parse user messages for the prompt - let entries = ContextMonitor.shared.parseTimeline(sessionId: sessionId, projectPath: projectPath) + let entries = ContextMonitor.shared.parseTimeline(sessionId: sessionId, projectPath: projectPath, kind: kind) guard !entries.isEmpty else { - inFlightSessionIds.remove(sessionId) + inFlightSessionIds.remove(cacheKey) completion(cached?.summary) return } let userMessages = entries.map { $0.message }.joined(separator: "\n---\n") - let prompt = "Summarize this Claude Code session in 1-2 concise sentences, focusing on what was accomplished. Only output the summary, nothing else.\n\nUser messages:\n\(userMessages)" + let prompt = "Summarize this \(kind.displayName) session in 1-2 concise sentences, focusing on what was accomplished. Only output the summary, nothing else.\n\nUser messages:\n\(userMessages)" DispatchQueue.global(qos: .utility).async { [weak self] in let result = self?.runClaudePrint(prompt: prompt) DispatchQueue.main.async { - self?.inFlightSessionIds.remove(sessionId) + self?.inFlightSessionIds.remove(cacheKey) if let summary = result, !summary.isEmpty { - self?.saveSummary(sessionId: sessionId, summary: summary, turnCount: entries.count) + self?.saveSummary(sessionId: cacheKey, summary: summary, turnCount: entries.count) completion(summary) } else { completion(cached?.summary) @@ -92,15 +105,27 @@ class SummaryManager { actions: [Int: [String]], completion: @escaping (String?, [Int: String]) -> Void ) { - let key = "combined-\(sessionId)" + generateCombinedSummaries(sessionId: sessionId, projectPath: projectPath, kind: .claude, currentTurnCount: currentTurnCount, actions: actions, completion: completion) + } + + func generateCombinedSummaries( + sessionId: String, + projectPath: String, + kind: TabKind, + currentTurnCount: Int, + actions: [Int: [String]], + completion: @escaping (String?, [Int: String]) -> Void + ) { + let sessionCacheKey = cacheKey(sessionId: sessionId, kind: kind) + let key = "combined-\(sessionCacheKey)" guard !inFlightSessionIds.contains(key) else { return } inFlightSessionIds.insert(key) // Determine what needs generation - let cachedSessionSummary = loadAll()[sessionId] + let cachedSessionSummary = loadAll()[sessionCacheKey] let needsSessionSummary = cachedSessionSummary == nil || (cachedSessionSummary?.turnCount ?? 0) < currentTurnCount - let existingTurnSummaries = cachedTurnSummaries(forSessionId: sessionId) + let existingTurnSummaries = cachedTurnSummaries(forSessionId: sessionId, kind: kind) let nonEmpty = actions.filter { !$0.value.isEmpty } let needsTurnSummaries = nonEmpty.filter { existingTurnSummaries[$0.key] == nil } @@ -112,13 +137,13 @@ class SummaryManager { } // Build combined prompt - let entries = ContextMonitor.shared.parseTimeline(sessionId: sessionId, projectPath: projectPath) + let entries = ContextMonitor.shared.parseTimeline(sessionId: sessionId, projectPath: projectPath, kind: kind) let userMessages = entries.map { $0.message }.joined(separator: "\n---\n") var promptParts: [String] = [] if needsSessionSummary { - promptParts.append("PART 1: Summarize this Claude Code session in 1-2 concise sentences, focusing on what was accomplished. Output on a line starting with \"SESSION:\".") + promptParts.append("PART 1: Summarize this \(kind.displayName) session in 1-2 concise sentences, focusing on what was accomplished. Output on a line starting with \"SESSION:\".") promptParts.append("\nUser messages:\n\(userMessages)\n") } @@ -161,7 +186,7 @@ class SummaryManager { // Persist session summary if let summary = sessionSummary, needsSessionSummary { - self?.saveSummary(sessionId: sessionId, summary: summary, turnCount: currentTurnCount) + self?.saveSummary(sessionId: sessionCacheKey, summary: summary, turnCount: currentTurnCount) } // Mark all requested turns as cached (empty string for ones haiku skipped) @@ -172,7 +197,7 @@ class SummaryManager { } let mergedTurns = existingTurnSummaries.merging(allRequestedTurns) { _, new in new } if !needsTurnSummaries.isEmpty { - self?.saveTurnSummaries(sessionId: sessionId, summaries: mergedTurns) + self?.saveTurnSummaries(sessionId: sessionCacheKey, summaries: mergedTurns) } completion( @@ -200,8 +225,12 @@ class SummaryManager { /// Returns all cached turn summaries for a session. func cachedTurnSummaries(forSessionId sessionId: String) -> [Int: String] { + cachedTurnSummaries(forSessionId: sessionId, kind: .claude) + } + + func cachedTurnSummaries(forSessionId sessionId: String, kind: TabKind) -> [Int: String] { let all = loadAllTurnSummaries() - guard let cached = all[sessionId] else { return [:] } + guard let cached = all[cacheKey(sessionId: sessionId, kind: kind)] else { return [:] } var result: [Int: String] = [:] for (key, value) in cached.summaries { if let idx = Int(key) { result[idx] = value } @@ -215,10 +244,15 @@ class SummaryManager { /// `actions` maps turn index to raw action descriptions. `totalTurnCount` is the current /// number of turns in the session (used to detect continued sessions). func generateTurnSummaries(sessionId: String, actions: [Int: [String]], completion: @escaping ([Int: String]) -> Void) { - let key = "turns-\(sessionId)" + generateTurnSummaries(sessionId: sessionId, kind: .claude, actions: actions, completion: completion) + } + + func generateTurnSummaries(sessionId: String, kind: TabKind, actions: [Int: [String]], completion: @escaping ([Int: String]) -> Void) { + let sessionCacheKey = cacheKey(sessionId: sessionId, kind: kind) + let key = "turns-\(sessionCacheKey)" // Load existing cached summaries - let existing = cachedTurnSummaries(forSessionId: sessionId) + let existing = cachedTurnSummaries(forSessionId: sessionId, kind: kind) // Figure out which turns need summarization (have actions but no cached summary) let nonEmpty = actions.filter { !$0.value.isEmpty } @@ -269,7 +303,7 @@ class SummaryManager { // Merge with existing and persist let merged = existing.merging(newSummaries) { _, new in new } - self?.saveTurnSummaries(sessionId: sessionId, summaries: merged) + self?.saveTurnSummaries(sessionId: sessionCacheKey, summaries: merged) completion(merged) } } @@ -300,6 +334,10 @@ class SummaryManager { // MARK: - Private + private func cacheKey(sessionId: String, kind: TabKind) -> String { + kind == .claude ? sessionId : "\(kind.rawValue):\(sessionId)" + } + private func runClaudePrint(prompt: String) -> String? { let process = Process() // Search common locations for the claude binary diff --git a/Sources/Terminal/TerminalSurface.swift b/Sources/Terminal/TerminalSurface.swift index e4b6cb1..9b95f41 100644 --- a/Sources/Terminal/TerminalSurface.swift +++ b/Sources/Terminal/TerminalSurface.swift @@ -469,6 +469,7 @@ extension Notification.Name { static let deckardSurfaceTitleChanged = Notification.Name("deckardSurfaceTitleChanged") static let deckardSurfaceClosed = Notification.Name("deckardSurfaceClosed") static let deckardNewTab = Notification.Name("deckardNewTab") + static let deckardNewCodexTab = Notification.Name("deckardNewCodexTab") static let deckardCloseTab = Notification.Name("deckardCloseTab") static let deckardFontChanged = Notification.Name("deckardFontChanged") static let deckardScrollbackChanged = Notification.Name("deckardScrollbackChanged") diff --git a/Sources/Window/DeckardWindowController.swift b/Sources/Window/DeckardWindowController.swift index a71d803..d6389fb 100644 --- a/Sources/Window/DeckardWindowController.swift +++ b/Sources/Window/DeckardWindowController.swift @@ -12,17 +12,27 @@ func shortcutTooltip(_ label: String, for name: KeyboardShortcuts.Name) -> Strin // MARK: - Data Models -/// A horizontal tab within a project (Claude session or terminal). +/// A horizontal tab within a project (agent session or terminal). class TabItem { let id: UUID var surface: TerminalSurface var name: String - var isClaude: Bool + var kind: TabKind var sessionId: String? var badgeState: BadgeState = .none /// Set during restore — suppresses completedUnseen until hook.session-start fires. var suppressUnseen: Bool = false + var isClaude: Bool { kind == .claude } + var isCodex: Bool { kind == .codex } + var isTerminal: Bool { kind == .terminal } + var isAgent: Bool { kind.isAgent } + + var sessionCacheKey: String? { + guard let sessionId, !sessionId.isEmpty else { return nil } + return SessionManager.sessionCacheKey(sessionId: sessionId, kind: kind) + } + enum BadgeState: String { case none case idle // grey - connected but no activity yet @@ -30,6 +40,10 @@ class TabItem { case waitingForInput case needsPermission case error + case codexIdle + case codexThinking + case codexError + case codexCompletedUnseen case terminalIdle // muted teal - terminal at prompt case terminalActive // teal pulsing - terminal foreground process has activity case terminalError // red - terminal process exited with error @@ -54,11 +68,15 @@ class TabItem { } } - init(surface: TerminalSurface, name: String, isClaude: Bool) { + init(surface: TerminalSurface, name: String, kind: TabKind) { self.id = surface.surfaceId self.surface = surface self.name = name - self.isClaude = isClaude + self.kind = kind + } + + convenience init(surface: TerminalSurface, name: String, isClaude: Bool) { + self.init(surface: surface, name: name, kind: isClaude ? .claude : .terminal) } } @@ -111,19 +129,20 @@ enum SidebarItem { // MARK: - Default Tab Configuration struct DefaultTabConfig { - var entries: [(isClaude: Bool, name: String)] + var entries: [(kind: TabKind, name: String)] static var current: DefaultTabConfig { let raw = UserDefaults.standard.string(forKey: "defaultTabConfig") ?? "claude, terminal" - let entries = raw.split(separator: ",").compactMap { item -> (isClaude: Bool, name: String)? in + let entries = raw.split(separator: ",").compactMap { item -> (kind: TabKind, name: String)? in let trimmed = item.trimmingCharacters(in: .whitespaces).lowercased() switch trimmed { - case "claude": return (isClaude: true, name: "Claude") - case "terminal": return (isClaude: false, name: "Terminal") + case "claude": return (kind: .claude, name: "Claude") + case "codex": return (kind: .codex, name: "Codex") + case "terminal": return (kind: .terminal, name: "Terminal") default: return nil } } - return DefaultTabConfig(entries: entries.isEmpty ? [(true, "Claude"), (false, "Terminal")] : entries) + return DefaultTabConfig(entries: entries.isEmpty ? [(.claude, "Claude"), (.terminal, "Terminal")] : entries) } } @@ -556,8 +575,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { recentlyClosedProjects.removeAll { $0.path == project.path } project.name = snapshot.name for ts in snapshot.tabs { - createTabInProject(project, isClaude: ts.isClaude, name: ts.name, - sessionIdToResume: ts.isClaude ? ts.sessionId : nil, + createTabInProject(project, kind: ts.kind, name: ts.name, + sessionIdToResume: ts.kind.isAgent ? ts.sessionId : nil, tmuxSessionToResume: ts.tmuxSessionName) } project.selectedTabIndex = min(snapshot.selectedTabIndex, project.tabs.count - 1) @@ -567,7 +586,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { if project.tabs.isEmpty { let config = DefaultTabConfig.current for entry in config.entries { - createTabInProject(project, isClaude: entry.isClaude) + createTabInProject(project, kind: entry.kind) } } @@ -609,26 +628,26 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { selectedTabIndex: project.selectedTabIndex, tabs: project.tabs.map { tab in ProjectTabState(id: tab.id.uuidString, name: tab.name, - isClaude: tab.isClaude, sessionId: tab.sessionId, + kind: tab.kind, sessionId: tab.sessionId, tmuxSessionName: tab.surface.tmuxSessionName) } ) recentlyClosedProjects.removeAll { $0.path == project.path } recentlyClosedProjects.append(snapshot) - // Persist session names for claude tabs so they survive app restarts - for tab in project.tabs where tab.isClaude { + // Persist session names for agent tabs so they survive app restarts + for tab in project.tabs where tab.isAgent { if let sid = tab.sessionId, !sid.isEmpty { - SessionManager.shared.saveSessionName(sessionId: sid, name: tab.name) + SessionManager.shared.saveSessionName(sessionId: sid, kind: tab.kind, name: tab.name) } } // Detach terminal tabs so their tmux sessions survive for re-open; - // terminate Claude tabs (they use their own resume mechanism). + // terminate agent tabs (they use their own resume mechanism). let closedIds = Set(project.tabs.map { $0.id }) tabCreationOrder.removeAll { closedIds.contains($0) } for tab in project.tabs { - if !tab.isClaude && tab.surface.tmuxSessionName != nil { + if tab.isTerminal && tab.surface.tmuxSessionName != nil { tab.surface.detach() } else { tab.surface.terminate() @@ -715,17 +734,33 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // MARK: - Tab Management (within a project) + private func initialBadgeState(for kind: TabKind) -> TabItem.BadgeState { + switch kind { + case .claude: + return .idle + case .codex: + return .codexIdle + case .terminal: + return .terminalIdle + } + } + func createTabInProject(_ project: ProjectItem, isClaude: Bool, name: String? = nil, sessionIdToResume: String? = nil, forkSession: Bool = false, tmuxSessionToResume: String? = nil, extraArgs: String? = nil) { + createTabInProject(project, kind: isClaude ? .claude : .terminal, name: name, sessionIdToResume: sessionIdToResume, forkSession: forkSession, tmuxSessionToResume: tmuxSessionToResume, extraArgs: extraArgs) + } + + func createTabInProject(_ project: ProjectItem, kind: TabKind, name: String? = nil, sessionIdToResume: String? = nil, forkSession: Bool = false, tmuxSessionToResume: String? = nil, extraArgs: String? = nil) { let surface = TerminalSurface() + let discoveryStart = Date().addingTimeInterval(-2) let tabName: String if let name = name { tabName = name } else { - let base = isClaude ? "Claude" : "Terminal" + let base = kind.displayName // Find the highest existing number for this tab type to avoid duplicates let prefix = "\(base) #" let maxNum = project.tabs - .filter { $0.isClaude == isClaude } + .filter { $0.kind == kind } .compactMap { tab -> Int? in guard tab.name.hasPrefix(prefix) else { return nil } return Int(tab.name.dropFirst(prefix.count)) @@ -733,20 +768,20 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { .max() ?? 0 tabName = "\(base) #\(maxNum + 1)" } - let tab = TabItem(surface: surface, name: tabName, isClaude: isClaude) + let tab = TabItem(surface: surface, name: tabName, kind: kind) surface.tabId = tab.id - tab.badgeState = isClaude ? .idle : .terminalIdle - if isClaude && isRestoring { + tab.badgeState = initialBadgeState(for: kind) + if kind == .claude && isRestoring { tab.suppressUnseen = true } var envVars: [String: String] = [:] - if isClaude { - tab.sessionId = sessionIdToResume - envVars["DECKARD_SESSION_TYPE"] = "claude" + if kind.isAgent { + tab.sessionId = forkSession ? nil : sessionIdToResume + envVars["DECKARD_SESSION_TYPE"] = kind.rawValue } let initialInput: String? - if isClaude { + if kind == .claude { let resolvedArgs = extraArgs ?? project.defaultArgs ?? UserDefaults.standard.string(forKey: "claudeExtraArgs") ?? "" let extraArgsSuffix = resolvedArgs.isEmpty ? "" : " \(resolvedArgs)" var claudeArgs = extraArgsSuffix @@ -764,11 +799,23 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // DeckardHooksInstaller — no wrapper needed, just call claude directly. // clear hides the echoed command; exec replaces the shell. initialInput = "clear && exec claude\(claudeArgs)\n" + } else if kind == .codex { + var codexArgs = "" + if let sessionIdToResume { + if forkSession { + codexArgs = " fork \(sessionIdToResume)" + } else if ContextMonitor.shared.codexSessionFileURL(sessionId: sessionIdToResume) != nil { + codexArgs = " resume \(sessionIdToResume)" + } else { + tab.sessionId = nil + } + } + initialInput = "clear && exec codex\(codexArgs)\n" } else { initialInput = nil } - DiagnosticLog.shared.log("surface", "createTab: \(isClaude ? "claude" : "terminal") surfaceId=\(surface.surfaceId)") + DiagnosticLog.shared.log("surface", "createTab: \(kind.rawValue) surfaceId=\(surface.surfaceId)") surface.startShell( workingDirectory: project.path, @@ -785,12 +832,48 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { project.tabs.append(tab) tabCreationOrder.append(tab.id) + + if kind == .codex && (tab.sessionId == nil || forkSession) { + var excluded = Set(project.tabs.compactMap { $0.kind == .codex ? $0.sessionId : nil }) + if let sessionIdToResume { excluded.insert(sessionIdToResume) } + scheduleCodexSessionDiscovery(forSurfaceId: tab.id, projectPath: project.path, after: discoveryStart, excluding: excluded) + } + } + + private func scheduleCodexSessionDiscovery(forSurfaceId surfaceId: UUID, projectPath: String, after date: Date, excluding excludedIds: Set) { + for delay in [1.0, 3.0, 8.0] { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self, + let tab = self.tabForSurfaceId(surfaceId.uuidString), + tab.kind == .codex, + tab.sessionId == nil else { return } + + let openCodexIds = Set(self.projects.flatMap { project in + project.tabs.compactMap { other -> String? in + guard other.id != surfaceId, other.kind == .codex else { return nil } + return other.sessionId + } + }) + let excluded = excludedIds.union(openCodexIds) + guard let session = ContextMonitor.shared.latestCodexSession( + forProjectPath: projectPath, + after: date, + excluding: excluded + ) else { return } + + self.updateSessionId(forSurfaceId: surfaceId.uuidString, sessionId: session.sessionId) + } + } } /// Guards against rapid duplicate tab creation from key repeat. var isCreatingTab = false func addTabToCurrentProject(isClaude: Bool) { + addTabToCurrentProject(kind: isClaude ? .claude : .terminal) + } + + func addTabToCurrentProject(kind: TabKind) { guard !isCreatingTab else { return } isCreatingTab = true @@ -800,7 +883,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } let project = projects[selectedProjectIndex] - if isClaude && UserDefaults.standard.bool(forKey: "promptForSessionArgs") { + if kind == .claude && UserDefaults.standard.bool(forKey: "promptForSessionArgs") { promptForClaudeArgs(for: project) { [weak self] args in guard let self else { return } guard let args else { @@ -812,11 +895,11 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { self.isCreatingTab = false return } - self.createTabInProject(project, isClaude: true, extraArgs: args) + self.createTabInProject(project, kind: .claude, extraArgs: args) self.finalizeTabCreation(in: project) } } else { - createTabInProject(project, isClaude: isClaude) + createTabInProject(project, kind: kind) finalizeTabCreation(in: project) } } @@ -892,6 +975,10 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { tab.badgeState = .waitingForInput rebuildSidebar() rebuildTabBar() + case .codexCompletedUnseen: + tab.badgeState = .codexIdle + rebuildSidebar() + rebuildTabBar() case .terminalCompletedUnseen: tab.badgeState = .terminalIdle rebuildSidebar() @@ -976,6 +1063,9 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { func showEmptyState() { currentTerminalView?.removeFromSuperview() emptyStateView?.isHidden = false + contextTimer?.invalidate() + contextTimer = nil + quotaView.clear() } /// Hide the empty-state overlay (active tab is being shown). @@ -989,18 +1079,19 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { contextTimer?.invalidate() contextTimer = nil - if tab.isClaude { + switch tab.kind { + case .claude: updateContextUsage(for: tab) contextTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in self?.updateContextUsage(for: tab) } - } else { - quotaView.updateContext(usage: nil, tabName: nil) - // Still show quota/sparkline with last known values on non-Claude tabs - quotaView.update( - snapshot: QuotaMonitor.shared.latest, - tokenRate: QuotaMonitor.shared.tokenRate, - sparklineData: QuotaMonitor.shared.sparklineData) + case .codex: + updateCodexUsage(for: tab) + contextTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in + self?.updateCodexUsage(for: tab) + } + case .terminal: + quotaView.clear() } } @@ -1040,6 +1131,40 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } + private func updateCodexUsage(for tab: TabItem) { + guard let sessionId = tab.sessionId else { + quotaView.clear() + return + } + + let tabName = tab.name + let tabId = tab.id + DispatchQueue.global(qos: .utility).async { + let usage = ContextMonitor.shared.getCodexUsage(sessionId: sessionId) + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + guard let project = self.currentProject, + let activeTab = project.tabs[safe: project.selectedTabIndex], + activeTab.id == tabId else { + DiagnosticLog.shared.log("context", + "updateCodexUsage: stale callback for \(tabName), ignoring") + return + } + + guard let usage else { + self.quotaView.clear() + return + } + + self.quotaView.updateContext(usage: usage.context, tabName: tabName) + self.quotaView.update( + snapshot: usage.quotaSnapshot, + tokenRate: usage.tokenRate, + sparklineData: usage.sparklineData) + } + } + } + // MARK: - Process Monitor private func startProcessMonitor() { @@ -1051,23 +1176,71 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { for project in self.projects { for tab in project.tabs { tabInfos.append(ProcessMonitor.TabInfo( - surfaceId: tab.id, isClaude: tab.isClaude, + surfaceId: tab.id, kind: tab.kind, name: tab.name, projectPath: project.path)) } } DispatchQueue.global(qos: .utility).async { let states = ProcessMonitor.shared.poll(tabs: tabInfos) + let codexStates = self.pollCodexBadgeStates() DispatchQueue.main.async { self.applyTerminalBadgeStates(states) + self.applyCodexBadgeStates(codexStates) + } + } + } + } + + private func pollCodexBadgeStates() -> [UUID: ContextMonitor.CodexActivityInfo] { + var states: [UUID: ContextMonitor.CodexActivityInfo] = [:] + for project in projects { + for tab in project.tabs where tab.kind == .codex { + guard let sessionId = tab.sessionId, + let state = ContextMonitor.shared.codexActivityInfo(sessionId: sessionId) else { continue } + states[tab.id] = state + } + } + return states + } + + private func applyCodexBadgeStates(_ states: [UUID: ContextMonitor.CodexActivityInfo]) { + var changed = false + for project in projects { + for tab in project.tabs where tab.kind == .codex { + guard let state = states[tab.id] else { continue } + + let newBadge: TabItem.BadgeState + if state.isBusy { + newBadge = .codexThinking + } else if state.isError { + newBadge = .codexError + } else if tab.badgeState == .codexThinking { + let visible = isTabVisible(tab.id.uuidString) + newBadge = visible ? .codexIdle : .codexCompletedUnseen + } else if tab.badgeState == .codexCompletedUnseen { + newBadge = .codexCompletedUnseen + } else { + newBadge = .codexIdle + } + + if tab.badgeState != newBadge { + DiagnosticLog.shared.log("badge", + "codex badge: project=\(project.path) tab=\"\(tab.name)\" busy=\(state.isBusy) error=\(state.isError) -> \(newBadge)") + tab.badgeState = newBadge + changed = true } } } + if changed { + rebuildSidebar() + rebuildTabBar() + } } private func applyTerminalBadgeStates(_ states: [UUID: ProcessMonitor.ActivityInfo]) { var changed = false for project in projects { - for tab in project.tabs where !tab.isClaude { + for tab in project.tabs where tab.isTerminal { let activity = states[tab.id] ?? ProcessMonitor.ActivityInfo() // Require 2 consecutive active polls to transition to terminalActive. @@ -1126,7 +1299,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // Terminal tabs: restart shell instead of removing the tab. // Reconnects to the tmux session if it still exists, otherwise // starts a fresh shell. Rate-limited to prevent crash loops. - if !tab.isClaude && tab.surface.canRestart { + if tab.isTerminal && tab.surface.canRestart { DiagnosticLog.shared.log("surface", "restarting shell for surfaceId=\(surfaceId)") tab.surface.restartShell(workingDirectory: project.path) @@ -1211,7 +1384,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { guard let tab = tabForSurfaceId(surfaceIdStr) else { return } guard tab.sessionId != sessionId else { return } tab.sessionId = sessionId - SessionManager.shared.saveSessionName(sessionId: sessionId, name: tab.name) + SessionManager.shared.saveSessionName(sessionId: sessionId, kind: tab.kind, name: tab.name) saveState() // Start watching if this is the currently displayed tab if let project = currentProject, @@ -1256,6 +1429,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { id: tab.id.uuidString, name: "\(project.name)/\(tab.name)", isClaude: tab.isClaude, + kind: tab.kind.rawValue, isMaster: false, sessionId: tab.sessionId, badgeState: tab.badgeState.rawValue, @@ -1272,7 +1446,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { guard let tab = tabForSurfaceId(tabIdStr) else { return } tab.name = name if let sid = tab.sessionId, !sid.isEmpty { - SessionManager.shared.saveSessionName(sessionId: sid, name: name) + SessionManager.shared.saveSessionName(sessionId: sid, kind: tab.kind, name: name) } rebuildTabBar() saveState() @@ -1311,7 +1485,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { ProjectTabState( id: tab.id.uuidString, name: tab.name, - isClaude: tab.isClaude, + kind: tab.kind, sessionId: tab.sessionId, tmuxSessionName: tab.surface.tmuxSessionName ) @@ -1384,8 +1558,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { for (t, ts) in ps.tabs.enumerated() { if i == selectedIdx && t == selTab { // Create the active tab's surface synchronously - createTabInProject(project, isClaude: ts.isClaude, name: ts.name, - sessionIdToResume: ts.isClaude ? ts.sessionId : nil, + createTabInProject(project, kind: ts.kind, name: ts.name, + sessionIdToResume: ts.kind.isAgent ? ts.sessionId : nil, tmuxSessionToResume: ts.tmuxSessionName) } else { pending.append((project: project, tab: ts, originalIndex: t)) @@ -1477,7 +1651,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { var label = "?" for project in projects { if let tab = project.tabs.first(where: { $0.id == id }) { - label = "\(tab.isClaude ? "C" : "T"):\(tab.name)@\(project.name)" + label = "\(tab.kind.rawValue.prefix(1).uppercased()):\(tab.name)@\(project.name)" break } } @@ -1493,8 +1667,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { let insertAt = first.originalIndex // Create the tab (appends to project.tabs) - createTabInProject(project, isClaude: ts.isClaude, name: ts.name, - sessionIdToResume: ts.isClaude ? ts.sessionId : nil, + createTabInProject(project, kind: ts.kind, name: ts.name, + sessionIdToResume: ts.kind.isAgent ? ts.sessionId : nil, tmuxSessionToResume: ts.tmuxSessionName) // Move it from the end to its original position @@ -1528,6 +1702,9 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { @objc private func quotaDidChange() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } + guard let project = self.currentProject, + let activeTab = project.tabs[safe: project.selectedTabIndex], + activeTab.kind == .claude else { return } self.quotaView.update( snapshot: QuotaMonitor.shared.latest, tokenRate: QuotaMonitor.shared.tokenRate, diff --git a/Sources/Window/QuotaView.swift b/Sources/Window/QuotaView.swift index 1e6ab7b..500f19f 100644 --- a/Sources/Window/QuotaView.swift +++ b/Sources/Window/QuotaView.swift @@ -57,7 +57,7 @@ class SparklineView: NSView { // MARK: - QuotaView -/// Displays Claude Code context usage, rate limit usage, and token rate in the sidebar. +/// Displays agent context usage, rate limit usage, and token rate in the sidebar. class QuotaView: NSView { // --- Context row (top) --- private let contextLabel = NSTextField(labelWithString: "context") @@ -69,7 +69,7 @@ class QuotaView: NSView { private var contextViews: [NSView] { [contextLabel, contextPercent, contextBar, contextModel] } // --- 5h row --- - private let fiveHourLabel = NSTextField(labelWithString: "5h session") + private let fiveHourLabel = NSTextField(labelWithString: "5h limit") private let fiveHourPercent = NSTextField(labelWithString: "") private let fiveHourBar = NSView() private let fiveHourFill = NSView() @@ -77,7 +77,7 @@ class QuotaView: NSView { private var fiveHourFillWidth: NSLayoutConstraint? // --- 7d row --- - private let sevenDayLabel = NSTextField(labelWithString: "7d weekly") + private let sevenDayLabel = NSTextField(labelWithString: "7d limit") private let sevenDayPercent = NSTextField(labelWithString: "") private let sevenDayBar = NSView() private let sevenDayFill = NSView() @@ -256,6 +256,11 @@ class QuotaView: NSView { updateVisibility() } + func clear() { + updateContext(usage: nil, tabName: nil) + update(snapshot: nil, tokenRate: nil, sparklineData: []) + } + func update(snapshot: QuotaMonitor.QuotaSnapshot?, tokenRate: QuotaMonitor.TokenRate?, sparklineData: [Double], alwaysShowRate: Bool = false) { let hasQuota = snapshot != nil diff --git a/Sources/Window/SettingsWindow.swift b/Sources/Window/SettingsWindow.swift index a965dfc..9edf866 100644 --- a/Sources/Window/SettingsWindow.swift +++ b/Sources/Window/SettingsWindow.swift @@ -170,7 +170,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie let tabConfigField = NSTextField() tabConfigField.stringValue = UserDefaults.standard.string(forKey: "defaultTabConfig") ?? "claude, terminal" - tabConfigField.placeholderString = "claude, terminal" + tabConfigField.placeholderString = "claude, codex, terminal" tabConfigField.font = .monospacedSystemFont(ofSize: 12, weight: .regular) objc_setAssociatedObject(tabConfigField, &settingsKeyAssoc, "defaultTabConfig", .OBJC_ASSOCIATION_RETAIN) tabConfigField.delegate = self @@ -183,7 +183,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie grid.addRow(with: [tabConfigLabel, tabConfigField]) - let tabConfigHelp = NSTextField(labelWithString: "Comma-separated list: claude, terminal") + let tabConfigHelp = NSTextField(labelWithString: "Comma-separated list: claude, codex, terminal") tabConfigHelp.font = .systemFont(ofSize: 11) tabConfigHelp.textColor = .secondaryLabelColor grid.addRow(with: [NSGridCell.emptyContentView, tabConfigHelp]) @@ -693,20 +693,27 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie (.idle, "Idle"), (.thinking, "Thinking"), (.waitingForInput, "Ready"), - (.needsPermission, "Needs Permission"), + (.needsPermission, "Permission"), (.error, "Error"), - (.completedUnseen, "Done (Unvisited)"), + (.completedUnseen, "Done"), + ] + + private static let codexBadgeEntries: [(state: TabItem.BadgeState, label: String)] = [ + (.codexIdle, "Idle"), + (.codexThinking, "Working"), + (.codexError, "Error"), + (.codexCompletedUnseen, "Done"), ] private static let terminalBadgeEntries: [(state: TabItem.BadgeState, label: String)] = [ (.terminalIdle, "Idle"), (.terminalActive, "Busy"), (.terminalError, "Error"), - (.terminalCompletedUnseen, "Done (Unvisited)"), + (.terminalCompletedUnseen, "Done"), ] /// Default animation settings per state. - static let defaultBadgeAnimated: Set = [.thinking, .terminalActive] + static let defaultBadgeAnimated: Set = [.thinking, .codexThinking, .terminalActive] static func isBadgeAnimated(_ state: TabItem.BadgeState) -> Bool { if let saved = UserDefaults.standard.object(forKey: "badgeAnimate.\(state.rawValue)") as? Bool { @@ -721,7 +728,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie _ entries: [(state: TabItem.BadgeState, label: String)]) -> NSView { let borderColor = NSColor.separatorColor.cgColor let rowHeight: CGFloat = 28 - let colWidths: [CGFloat] = [120, 60, 50, 50] // state, shape, color, blink + let colWidths: [CGFloat] = [84, 54, 38, 38] // state, shape, color, blink let tableWidth = colWidths.reduce(0, +) let totalRows = 1 + entries.count @@ -843,12 +850,13 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie } let claudeTable = makeSectionTable("Claude", Self.claudeBadgeEntries) + let codexTable = makeSectionTable("Codex", Self.codexBadgeEntries) let terminalTable = makeSectionTable("Terminal", Self.terminalBadgeEntries) - let hStack = NSStackView(views: [claudeTable, terminalTable]) + let hStack = NSStackView(views: [claudeTable, codexTable, terminalTable]) hStack.orientation = .horizontal hStack.alignment = .top - hStack.spacing = 16 + hStack.spacing = 12 // Wrap in a vertical stack with the reset button let wrapper = NSStackView() @@ -935,7 +943,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie } @objc private func resetBadgeColors() { - for entry in Self.claudeBadgeEntries + Self.terminalBadgeEntries { + for entry in Self.claudeBadgeEntries + Self.codexBadgeEntries + Self.terminalBadgeEntries { UserDefaults.standard.removeObject(forKey: "badgeColor.\(entry.state.rawValue)") UserDefaults.standard.removeObject(forKey: "badgeAnimate.\(entry.state.rawValue)") UserDefaults.standard.removeObject(forKey: "badgeShape.\(entry.state.rawValue)") @@ -1067,7 +1075,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie versionLabel.textColor = .secondaryLabelColor stack.addArrangedSubview(versionLabel) - let descLabel = NSTextField(labelWithString: "Multi-session Claude Code terminal manager") + let descLabel = NSTextField(labelWithString: "Multi-session Claude Code and Codex terminal manager") descLabel.font = .systemFont(ofSize: 12) descLabel.textColor = .tertiaryLabelColor stack.addArrangedSubview(descLabel) diff --git a/Sources/Window/SidebarController.swift b/Sources/Window/SidebarController.swift index 1f95b1d..5eaafa1 100644 --- a/Sources/Window/SidebarController.swift +++ b/Sources/Window/SidebarController.swift @@ -678,10 +678,10 @@ extension DeckardWindowController { projectPath: project.path, projectName: project.name ) - explorer.openSessionIds = Set(project.tabs.compactMap { $0.sessionId }) - explorer.onSessionAction = { [weak self] sessionId, fork, tabName in + explorer.openSessionIds = Set(project.tabs.compactMap { $0.sessionCacheKey }) + explorer.onSessionAction = { [weak self] kind, sessionId, fork, tabName in guard let self else { return } - self.createTabInProject(project, isClaude: true, name: tabName, sessionIdToResume: sessionId, forkSession: fork) + self.createTabInProject(project, kind: kind, name: tabName, sessionIdToResume: sessionId, forkSession: fork) project.selectedTabIndex = project.tabs.count - 1 if let idx = self.projects.firstIndex(where: { $0 === project }) { self.selectProject(at: idx) diff --git a/Sources/Window/SidebarViews.swift b/Sources/Window/SidebarViews.swift index 5def7d3..d651a78 100644 --- a/Sources/Window/SidebarViews.swift +++ b/Sources/Window/SidebarViews.swift @@ -146,6 +146,10 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { case .waitingForInput: return "Waiting for input" case .needsPermission: return "Needs permission" case .error: return "Error" + case .codexIdle: return "Codex idle" + case .codexThinking: return "Codex working..." + case .codexError: return "Codex error" + case .codexCompletedUnseen: return "Codex done (unvisited)" case .terminalIdle: return "Idle" case .terminalActive: return activity?.description ?? "Running" case .terminalError: return "Error" @@ -160,6 +164,10 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { .waitingForInput: NSColor(red: 0.65, green: 0.4, blue: 0.9, alpha: 1.0), .needsPermission: .systemOrange, .error: .systemRed, + .codexIdle: NSColor(red: 0.26, green: 0.58, blue: 0.42, alpha: 1.0), + .codexThinking: NSColor(red: 0.18, green: 0.76, blue: 0.48, alpha: 1.0), + .codexError: .systemRed, + .codexCompletedUnseen: NSColor(red: 0.10, green: 0.84, blue: 0.66, alpha: 1.0), .terminalIdle: NSColor(red: 0.35, green: 0.55, blue: 0.54, alpha: 1.0), .terminalActive: NSColor(red: 0.45, green: 0.72, blue: 0.71, alpha: 1.0), .terminalError: NSColor(red: 0.85, green: 0.3, blue: 0.3, alpha: 1.0), @@ -809,23 +817,26 @@ class ReorderableStackView: NSStackView { // MARK: - AddTabButton -/// + button: left-click adds Claude tab, right-click adds terminal tab. +/// + button: left-click adds Claude tab; modifiers/context menu expose other tab types. class AddTabButton: NSView { override var mouseDownCanMoveWindow: Bool { false } - private let leftClickAction: () -> Void - private let rightClickAction: () -> Void + private let claudeAction: () -> Void + private let codexAction: () -> Void + private let terminalAction: () -> Void private let label: NSTextField - init(leftClickAction: @escaping () -> Void, rightClickAction: @escaping () -> Void) { - self.leftClickAction = leftClickAction - self.rightClickAction = rightClickAction + init(claudeAction: @escaping () -> Void, codexAction: @escaping () -> Void, terminalAction: @escaping () -> Void) { + self.claudeAction = claudeAction + self.codexAction = codexAction + self.terminalAction = terminalAction label = NSTextField(labelWithString: " +") label.font = .systemFont(ofSize: 12, weight: .medium) label.textColor = ThemeManager.shared.currentColors.secondaryText super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false toolTip = shortcutTooltip("New Claude tab", for: .newClaudeTab) - + "\nShift-click or right-click: " + shortcutTooltip("new Terminal", for: .newTerminalTab) + + "\nOption-click: " + shortcutTooltip("new Codex", for: .newCodexTab) + + "\nShift-click: " + shortcutTooltip("new Terminal", for: .newTerminalTab) label.translatesAutoresizingMaskIntoConstraints = false addSubview(label) NSLayoutConstraint.activate([ @@ -840,16 +851,38 @@ class AddTabButton: NSView { required init?(coder: NSCoder) { fatalError() } override func mouseDown(with event: NSEvent) { - if event.modifierFlags.contains(.shift) { - rightClickAction() // Shift+click opens terminal tab + if event.modifierFlags.contains(.option) { + codexAction() + } else if event.modifierFlags.contains(.shift) { + terminalAction() } else { - leftClickAction() + claudeAction() } } override func rightMouseDown(with event: NSEvent) { - rightClickAction() + let menu = NSMenu() + let claudeItem = NSMenuItem(title: "New Claude Tab", action: #selector(newClaudeAction), keyEquivalent: "") + claudeItem.setShortcut(for: .newClaudeTab) + claudeItem.target = self + menu.addItem(claudeItem) + + let codexItem = NSMenuItem(title: "New Codex Tab", action: #selector(newCodexAction), keyEquivalent: "") + codexItem.setShortcut(for: .newCodexTab) + codexItem.target = self + menu.addItem(codexItem) + + let terminalItem = NSMenuItem(title: "New Terminal Tab", action: #selector(newTerminalAction), keyEquivalent: "") + terminalItem.setShortcut(for: .newTerminalTab) + terminalItem.target = self + menu.addItem(terminalItem) + + NSMenu.popUpContextMenu(menu, with: event, for: self) } + + @objc private func newClaudeAction() { claudeAction() } + @objc private func newCodexAction() { codexAction() } + @objc private func newTerminalAction() { terminalAction() } } // MARK: - BadgeShapeView diff --git a/Sources/Window/TabBarController.swift b/Sources/Window/TabBarController.swift index b5d3a13..bd32727 100644 --- a/Sources/Window/TabBarController.swift +++ b/Sources/Window/TabBarController.swift @@ -40,7 +40,7 @@ extension DeckardWindowController { let tabView = HorizontalTabView( displayTitle: title, editableName: tab.name, - isClaude: tab.isClaude, + kind: tab.kind, badgeState: tab.badgeState, activity: terminalActivity[tab.id], isSelected: isSelected, @@ -54,7 +54,7 @@ extension DeckardWindowController { let tab = project.tabs[i] tab.name = newName if let sid = tab.sessionId, !sid.isEmpty { - SessionManager.shared.saveSessionName(sessionId: sid, name: newName) + SessionManager.shared.saveSessionName(sessionId: sid, kind: tab.kind, name: newName) } self.rebuildTabBar() self.saveState() @@ -63,8 +63,8 @@ extension DeckardWindowController { guard let self = self, let project = self.currentProject, i < project.tabs.count else { return } let tab = project.tabs[i] - let base = tab.isClaude ? "Claude" : "Terminal" - let sameType = project.tabs.filter { $0.isClaude == tab.isClaude } + let base = tab.kind.displayName + let sameType = project.tabs.filter { $0.kind == tab.kind } tab.name = sameType.count <= 1 ? base : "\(base) #\(i + 1)" self.rebuildTabBar() self.saveState() @@ -76,10 +76,13 @@ extension DeckardWindowController { self.tabBarCloseClicked(btn) } tabView.onNewClaude = { [weak self] in - self?.addTabToCurrentProject(isClaude: true) + self?.addTabToCurrentProject(kind: .claude) + } + tabView.onNewCodex = { [weak self] in + self?.addTabToCurrentProject(kind: .codex) } tabView.onNewTerminal = { [weak self] in - self?.addTabToCurrentProject(isClaude: false) + self?.addTabToCurrentProject(kind: .terminal) } tabView.onEditingFinished = { [weak self] in guard let self = self, self.needsTabBarRebuild else { return } @@ -98,8 +101,9 @@ extension DeckardWindowController { // Add "+" button let addButton = AddTabButton( - leftClickAction: { [weak self] in self?.addTabToCurrentProject(isClaude: true) }, - rightClickAction: { [weak self] in self?.addTabToCurrentProject(isClaude: false) } + claudeAction: { [weak self] in self?.addTabToCurrentProject(kind: .claude) }, + codexAction: { [weak self] in self?.addTabToCurrentProject(kind: .codex) }, + terminalAction: { [weak self] in self?.addTabToCurrentProject(kind: .terminal) } ) tabBar.addArrangedSubview(addButton) diff --git a/Sources/Window/TabBarViews.swift b/Sources/Window/TabBarViews.swift index c9e4b01..c1e894b 100644 --- a/Sources/Window/TabBarViews.swift +++ b/Sources/Window/TabBarViews.swift @@ -17,6 +17,7 @@ class HorizontalTabView: NSView, NSTextFieldDelegate, NSDraggingSource { var onEditingFinished: (() -> Void)? var onClose: (() -> Void)? var onNewClaude: (() -> Void)? + var onNewCodex: (() -> Void)? var onNewTerminal: (() -> Void)? private var rawName: String @@ -25,7 +26,7 @@ class HorizontalTabView: NSView, NSTextFieldDelegate, NSDraggingSource { private var badgeDot: NSView? - init(displayTitle: String, editableName: String, isClaude: Bool = false, + init(displayTitle: String, editableName: String, kind: TabKind = .terminal, badgeState: TabItem.BadgeState = .none, activity: ProcessMonitor.ActivityInfo? = nil, isSelected: Bool, index: Int, @@ -185,6 +186,10 @@ class HorizontalTabView: NSView, NSTextFieldDelegate, NSDraggingSource { claudeItem.setShortcut(for: .newClaudeTab) menu.addItem(claudeItem) + let codexItem = NSMenuItem(title: "New Codex Tab", action: #selector(newCodexAction), keyEquivalent: "") + codexItem.setShortcut(for: .newCodexTab) + menu.addItem(codexItem) + let termItem = NSMenuItem(title: "New Terminal Tab", action: #selector(newTerminalAction), keyEquivalent: "") termItem.setShortcut(for: .newTerminalTab) menu.addItem(termItem) @@ -199,6 +204,7 @@ class HorizontalTabView: NSView, NSTextFieldDelegate, NSDraggingSource { } @objc private func newClaudeAction() { onNewClaude?() } + @objc private func newCodexAction() { onNewCodex?() } @objc private func newTerminalAction() { onNewTerminal?() } @objc private func closeTabAction() { onClose?() } From ec16be425ca08f986154b655575a75393633869c Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Tue, 28 Apr 2026 20:15:36 +0200 Subject: [PATCH 02/15] Fix Codex support lint issues --- README.md | 6 ++-- Sources/Control/ControlSocket.swift | 2 +- .../SessionExplorerWindowController.swift | 11 ++++---- Sources/Session/SummaryManager.swift | 28 +++++++++++++++---- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e2ffe0d..0166f0d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Deckard -A native macOS workspace for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), OpenAI Codex, and classic terminal tabs. Deckard treats agent sessions as first-class tabs: Claude Code and Codex can both be created, resumed, forked, explored, bookmarked, summarized, and restored across app launches. +A native macOS workspace for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenAI Codex](https://openai.com/codex), and classic terminal tabs. Deckard treats agent sessions as first-class tabs: Claude Code and Codex can both be created, resumed, forked, explored, bookmarked, summarized, and restored across app launches. Run multiple agents side by side in a single window with project-aware tabs, session persistence, status badges, and usage telemetry when the underlying CLI exposes it. Built with Swift and AppKit. Terminal rendering is powered by [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). @@ -107,8 +107,8 @@ brew install gi11es/tap/deckard ## Requirements - macOS 14.0 (Sonoma) or later -- Claude Code CLI installed to use Claude tabs -- Codex CLI installed to use Codex tabs +- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI installed to use Claude tabs +- [Codex CLI](https://openai.com/codex/get-started/) installed to use Codex tabs - Xcode 16+ to build from source Deckard can be used with only Claude Code, only Codex, or both installed. Terminal tabs work without either agent CLI. diff --git a/Sources/Control/ControlSocket.swift b/Sources/Control/ControlSocket.swift index c9c15c6..b45b6f2 100644 --- a/Sources/Control/ControlSocket.swift +++ b/Sources/Control/ControlSocket.swift @@ -263,7 +263,7 @@ struct TabInfo: Codable { var id: String var name: String var isClaude: Bool - var kind: String? = nil + var kind: String? var isMaster: Bool var sessionId: String? var badgeState: String diff --git a/Sources/Session/SessionExplorerWindowController.swift b/Sources/Session/SessionExplorerWindowController.swift index 6a09b9a..ed7a242 100644 --- a/Sources/Session/SessionExplorerWindowController.swift +++ b/Sources/Session/SessionExplorerWindowController.swift @@ -348,11 +348,12 @@ class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, timelineController?.setSummarizing(true) SummaryManager.shared.generateCombinedSummaries( - sessionId: session.sessionId, - projectPath: projectPath, - kind: session.agentKind, - currentTurnCount: entries.count, - actions: actions + SummaryManager.CombinedSummaryRequest( + sessionId: session.sessionId, + projectPath: projectPath, + kind: session.agentKind, + currentTurnCount: entries.count, + actions: actions) ) { [weak self] sessionSummary, actionSummaries in guard let self, self.selectedSessionId == session.cacheKey else { return } diff --git a/Sources/Session/SummaryManager.swift b/Sources/Session/SummaryManager.swift index c80bdf0..8918416 100644 --- a/Sources/Session/SummaryManager.swift +++ b/Sources/Session/SummaryManager.swift @@ -4,6 +4,14 @@ import Foundation class SummaryManager { static let shared = SummaryManager() + struct CombinedSummaryRequest { + let sessionId: String + let projectPath: String + let kind: TabKind + let currentTurnCount: Int + let actions: [Int: [String]] + } + private let fileURL: URL = { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let deckardDir = appSupport.appendingPathComponent("Deckard") @@ -105,17 +113,25 @@ class SummaryManager { actions: [Int: [String]], completion: @escaping (String?, [Int: String]) -> Void ) { - generateCombinedSummaries(sessionId: sessionId, projectPath: projectPath, kind: .claude, currentTurnCount: currentTurnCount, actions: actions, completion: completion) + generateCombinedSummaries( + CombinedSummaryRequest( + sessionId: sessionId, + projectPath: projectPath, + kind: .claude, + currentTurnCount: currentTurnCount, + actions: actions), + completion: completion) } func generateCombinedSummaries( - sessionId: String, - projectPath: String, - kind: TabKind, - currentTurnCount: Int, - actions: [Int: [String]], + _ request: CombinedSummaryRequest, completion: @escaping (String?, [Int: String]) -> Void ) { + let sessionId = request.sessionId + let projectPath = request.projectPath + let kind = request.kind + let currentTurnCount = request.currentTurnCount + let actions = request.actions let sessionCacheKey = cacheKey(sessionId: sessionId, kind: kind) let key = "combined-\(sessionCacheKey)" guard !inFlightSessionIds.contains(key) else { return } From dea05de5cd1d99147832953b501edeb77ca068f5 Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Tue, 28 Apr 2026 20:40:43 +0200 Subject: [PATCH 03/15] Remove session summarization --- Deckard.xcodeproj/project.pbxproj | 4 - README.md | 11 +- Sources/Detection/ContextMonitor.swift | 119 +---- Sources/Session/SessionExplorerModels.swift | 4 - .../Session/SessionExplorerTimelineView.swift | 120 +---- .../SessionExplorerWindowController.swift | 45 -- Sources/Session/SummaryManager.swift | 424 ------------------ 7 files changed, 11 insertions(+), 716 deletions(-) delete mode 100644 Sources/Session/SummaryManager.swift diff --git a/Deckard.xcodeproj/project.pbxproj b/Deckard.xcodeproj/project.pbxproj index 4803364..bab46c3 100644 --- a/Deckard.xcodeproj/project.pbxproj +++ b/Deckard.xcodeproj/project.pbxproj @@ -61,7 +61,6 @@ QA200006QA200006QA200006 /* QuotaMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = QA200005QA200005QA200005 /* QuotaMonitorTests.swift */; }; SE000001SE000001SE000001 /* SessionExplorerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = SE000002SE000002SE000002 /* SessionExplorerModels.swift */; }; SE000003SE000003SE000003 /* BookmarkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = SE000004SE000004SE000004 /* BookmarkManager.swift */; }; - SE000005SE000005SE000005 /* SummaryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = SE000006SE000006SE000006 /* SummaryManager.swift */; }; SE000007SE000007SE000007 /* SessionExplorerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = SE000008SE000008SE000008 /* SessionExplorerWindowController.swift */; }; SE000009SE000009SE000009 /* SessionExplorerTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = SE00000ASE00000ASE00000A /* SessionExplorerTimelineView.swift */; }; CF000001CF000001CF000001 /* ClaudeCLIFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF000002CF000002CF000002 /* ClaudeCLIFlags.swift */; }; @@ -124,7 +123,6 @@ QA200005QA200005QA200005 /* QuotaMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotaMonitorTests.swift; sourceTree = ""; }; SE000002SE000002SE000002 /* SessionExplorerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionExplorerModels.swift; sourceTree = ""; }; SE000004SE000004SE000004 /* BookmarkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkManager.swift; sourceTree = ""; }; - SE000006SE000006SE000006 /* SummaryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SummaryManager.swift; sourceTree = ""; }; SE000008SE000008SE000008 /* SessionExplorerWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionExplorerWindowController.swift; sourceTree = ""; }; SE00000ASE00000ASE00000A /* SessionExplorerTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionExplorerTimelineView.swift; sourceTree = ""; }; CF000002CF000002CF000002 /* ClaudeCLIFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCLIFlags.swift; sourceTree = ""; }; @@ -230,7 +228,6 @@ 7BE2AA0719D32ACCD1549184 /* SessionState.swift */, SE000002SE000002SE000002 /* SessionExplorerModels.swift */, SE000004SE000004SE000004 /* BookmarkManager.swift */, - SE000006SE000006SE000006 /* SummaryManager.swift */, SE000008SE000008SE000008 /* SessionExplorerWindowController.swift */, SE00000ASE00000ASE00000A /* SessionExplorerTimelineView.swift */, ); @@ -447,7 +444,6 @@ QA200004QA200004QA200004 /* QuotaView.swift in Sources */, SE000001SE000001SE000001 /* SessionExplorerModels.swift in Sources */, SE000003SE000003SE000003 /* BookmarkManager.swift in Sources */, - SE000005SE000005SE000005 /* SummaryManager.swift in Sources */, SE000007SE000007SE000007 /* SessionExplorerWindowController.swift in Sources */, SE000009SE000009SE000009 /* SessionExplorerTimelineView.swift in Sources */, CF000001CF000001CF000001 /* ClaudeCLIFlags.swift in Sources */, diff --git a/README.md b/README.md index 0166f0d..2ca7f95 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Deckard -A native macOS workspace for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenAI Codex](https://openai.com/codex), and classic terminal tabs. Deckard treats agent sessions as first-class tabs: Claude Code and Codex can both be created, resumed, forked, explored, bookmarked, summarized, and restored across app launches. +A native macOS workspace for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenAI Codex](https://openai.com/codex), and classic terminal tabs. Deckard treats agent sessions as first-class tabs: Claude Code and Codex can both be created, resumed, forked, explored, bookmarked, and restored across app launches. Run multiple agents side by side in a single window with project-aware tabs, session persistence, status badges, and usage telemetry when the underlying CLI exposes it. Built with Swift and AppKit. Terminal rendering is powered by [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). @@ -39,7 +39,7 @@ Claude Code and Codex have separate badge states and customizable colors. Claude ### Session Explorer -Browse past Claude Code and Codex sessions with Cmd+Shift+E. The explorer lists both providers for the current project, lets you resume or fork any session, and supports bookmark stars, timeline views, action extraction, and cached summaries. +Browse past Claude Code and Codex sessions with Cmd+Shift+E. The explorer lists both providers for the current project, lets you resume or fork any session, and supports bookmark stars and timeline views. Fork-at-turn works for both agent providers. For Claude Code, Deckard truncates the Claude session JSONL and resumes with Claude's fork support. For Codex, Deckard creates a truncated Codex rollout file, registers it with Codex's local state database, and launches `codex fork` or `codex resume` as appropriate. @@ -70,7 +70,7 @@ Ships with 486 built-in themes in Ghostty format and loads custom themes from `~ - **Session persistence**: Claude Code sessions resume with `claude --resume`; Codex sessions resume with `codex resume`. Tab structure and working directories are preserved across restarts. - **Forking workflows**: Claude Code and Codex sessions can be forked from the explorer, including from a specific user turn. -- **Bookmarks and summaries**: Claude Code and Codex sessions use separate bookmark and summary caches so provider sessions with similar IDs do not collide. +- **Bookmarks**: Claude Code and Codex sessions use separate bookmark caches so provider sessions with similar IDs do not collide. - **Customizable shortcuts**: All keyboard shortcuts are rebindable in Settings > Shortcuts, including new Claude, Codex, and terminal tab commands. - **tmux integration**: When tmux is installed, classic terminal tabs are transparently wrapped in tmux sessions. Quit and relaunch Deckard to resume shell state, scrollback, running processes, and environment. tmux options are editable in Settings > Terminal. - **Drag and drop**: Drag files from Finder into any terminal surface. Paths are shell-escaped and inserted automatically. @@ -88,7 +88,6 @@ Ships with 486 built-in themes in Ghostty format and loads custom themes from `~ | Session explorer listing | Yes | Yes | | Timeline and action view | Yes | Yes | | Bookmarks | Yes | Yes | -| Cached summaries | Yes | Yes | | Provider-specific badges | Yes | Yes | | Context, quota, token rate | Yes | Yes, when Codex writes `token_count` events | @@ -136,11 +135,11 @@ On launch, Deckard installs Claude Code integrations idempotently: 1. **Lifecycle hooks**: a shell script and entries in `~/.claude/settings.json` notify Deckard when Claude starts thinking, finishes a response, needs tool approval, encounters an error, or emits status-line quota data. Communication happens over a Unix domain socket. 2. **`/deckard` skill**: a Claude Code slash command at `~/.claude/commands/deckard.md` for filing bug reports and feature requests directly from a session. -Deckard reads Claude session JSONL files under `~/.claude/projects` for session discovery, timelines, action extraction, context usage, summaries, resume, and fork-at-turn. +Deckard reads Claude session JSONL files under `~/.claude/projects` for session discovery, timelines, context usage, resume, and fork-at-turn. **Codex** -Deckard reads Codex rollout files under `~/.codex/sessions` and the local Codex state database at `~/.codex/state_5.sqlite`. That provides project-scoped session discovery, resume, fork, fork-at-turn, timeline parsing, action extraction, badges, context usage, quota percentages, and token-rate calculation when Codex has written the corresponding events. +Deckard reads Codex rollout files under `~/.codex/sessions` and the local Codex state database at `~/.codex/state_5.sqlite`. That provides project-scoped session discovery, resume, fork, fork-at-turn, timeline parsing, badges, context usage, quota percentages, and token-rate calculation when Codex has written the corresponding events. Deckard does not install Codex hooks. It launches the Codex CLI directly with `codex`, `codex resume`, or `codex fork`. diff --git a/Sources/Detection/ContextMonitor.swift b/Sources/Detection/ContextMonitor.swift index a3a4984..79386d7 100644 --- a/Sources/Detection/ContextMonitor.swift +++ b/Sources/Detection/ContextMonitor.swift @@ -430,26 +430,6 @@ class ContextMonitor { trimmed.hasPrefix("") } - private func codexActionDescription(name: String, arguments: String?) -> String { - guard let arguments, - let data = arguments.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return name - } - - if let cmd = json["cmd"] as? String ?? json["command"] as? String { - let brief = cmd.split(separator: "\n").first.map(String.init) ?? cmd - return "\(name): \(String(brief.prefix(50)))" - } - if let path = json["path"] as? String ?? json["file_path"] as? String { - return "\(name) \((path as NSString).lastPathComponent)" - } - if let pattern = json["pattern"] as? String { - return "\(name) \(pattern)" - } - return name - } - /// Parses a session JSONL file and returns an ordered list of user turns. /// Deduplicates by promptId — only the first occurrence with non-empty content is kept. func parseTimeline(sessionId: String, projectPath: String, kind: TabKind = .claude) -> [TimelineEntry] { @@ -502,8 +482,7 @@ class ContextMonitor { index: entries.count, promptId: promptId, message: text.trimmingCharacters(in: .whitespacesAndNewlines), - timestamp: timestamp, - actionSummary: nil + timestamp: timestamp )) } @@ -534,107 +513,13 @@ class ContextMonitor { index: entries.count, promptId: "\(sessionId)-\(entries.count)", message: text, - timestamp: timestamp, - actionSummary: nil + timestamp: timestamp )) } return entries } - /// Extracts a raw description of tool uses for each user turn in a session. - /// Returns a dictionary mapping turn index to a list of action descriptions. - func parseActions(sessionId: String, projectPath: String, kind: TabKind = .claude) -> [Int: [String]] { - if kind == .codex { - return parseCodexActions(sessionId: sessionId, projectPath: projectPath) - } - - let encoded = projectPath.claudeProjectDirName - let jsonlPath = NSHomeDirectory() + "/.claude/projects/\(encoded)/\(sessionId).jsonl" - - guard let data = try? Data(contentsOf: URL(fileURLWithPath: jsonlPath)), - let content = String(data: data, encoding: .utf8) else { return [:] } - - var result: [Int: [String]] = [:] - var currentTurnIndex = -1 - var seenPromptIds = Set() - - for line in content.split(separator: "\n") { - guard let lineData = line.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any], - let type = json["type"] as? String else { continue } - - if type == "user", let promptId = json["promptId"] as? String, - !seenPromptIds.contains(promptId) { - let msg = json["message"] as? [String: Any] - var text = "" - if let c = msg?["content"] as? String { - text = c - } else if let arr = msg?["content"] as? [[String: Any]] { - text = arr.first(where: { $0["type"] as? String == "text" })?["text"] as? String ?? "" - } - guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - seenPromptIds.insert(promptId) - continue - } - seenPromptIds.insert(promptId) - currentTurnIndex += 1 - } else if type == "assistant", currentTurnIndex >= 0 { - let msg = json["message"] as? [String: Any] - let inner = msg?["message"] as? [String: Any] ?? msg - guard let contentArr = inner?["content"] as? [[String: Any]] else { continue } - - for block in contentArr { - guard block["type"] as? String == "tool_use", - let name = block["name"] as? String else { continue } - let input = block["input"] as? [String: Any] ?? [:] - var desc = name - if let fp = input["file_path"] as? String { - let filename = (fp as NSString).lastPathComponent - desc = "\(name) \(filename)" - } else if let cmd = input["command"] as? String { - let brief = cmd.split(separator: "\n").first.map(String.init) ?? cmd - desc = "\(name): \(String(brief.prefix(50)))" - } else if let pattern = input["pattern"] as? String { - desc = "\(name) \(pattern)" - } - result[currentTurnIndex, default: []].append(desc) - } - } - } - - return result - } - - private func parseCodexActions(sessionId: String, projectPath: String) -> [Int: [String]] { - guard let fileURL = codexSessionFileURL(sessionId: sessionId), - let data = try? Data(contentsOf: fileURL), - let content = String(data: data, encoding: .utf8) else { return [:] } - - var result: [Int: [String]] = [:] - var currentTurnIndex = -1 - - for line in content.split(separator: "\n") { - guard let json = parseJSONObject(line), - json["type"] as? String == "response_item", - let payload = json["payload"] as? [String: Any], - let payloadType = payload["type"] as? String else { continue } - - if codexRealUserMessageText(from: json) != nil { - currentTurnIndex += 1 - continue - } - - if payloadType == "function_call", currentTurnIndex >= 0, - let name = payload["name"] as? String { - let desc = codexActionDescription(name: name, arguments: payload["arguments"] as? String) - result[currentTurnIndex, default: []].append(desc) - } - } - - return result - } - /// Creates a truncated copy of a session JSONL, keeping everything up to (and including /// the full response for) the Nth unique user turn. Returns the new session ID. func truncateSession(sessionId: String, projectPath: String, afterTurnIndex: Int, kind: TabKind = .claude) -> String? { diff --git a/Sources/Session/SessionExplorerModels.swift b/Sources/Session/SessionExplorerModels.swift index 89b3557..4018fa1 100644 --- a/Sources/Session/SessionExplorerModels.swift +++ b/Sources/Session/SessionExplorerModels.swift @@ -9,7 +9,6 @@ struct ExplorerSessionInfo { var messageCount: Int let firstUserMessage: String var savedName: String? - var summary: String? var isBookmarked: Bool var cacheKey: String { @@ -23,7 +22,6 @@ struct ExplorerSessionInfo { messageCount: Int, firstUserMessage: String, savedName: String?, - summary: String?, isBookmarked: Bool) { self.agentKind = agentKind self.sessionId = sessionId @@ -32,7 +30,6 @@ struct ExplorerSessionInfo { self.messageCount = messageCount self.firstUserMessage = firstUserMessage self.savedName = savedName - self.summary = summary self.isBookmarked = isBookmarked } } @@ -43,5 +40,4 @@ struct TimelineEntry { let promptId: String let message: String let timestamp: Date? - var actionSummary: String? } diff --git a/Sources/Session/SessionExplorerTimelineView.swift b/Sources/Session/SessionExplorerTimelineView.swift index dbef4af..91644fd 100644 --- a/Sources/Session/SessionExplorerTimelineView.swift +++ b/Sources/Session/SessionExplorerTimelineView.swift @@ -11,21 +11,11 @@ class SessionExplorerTimelineController: NSObject, NSTableViewDataSource, NSTabl private var currentSession: ExplorerSessionInfo? private var entries: [TimelineEntry] = [] private var forkAtPointEnabled = false - private var summarizeSpinner: NSProgressIndicator? // Callbacks var onResume: ((String) -> Void)? var onFork: ((String) -> Void)? var onForkAtPoint: ((String, Int) -> Void)? - var onSummarize: (() -> Void)? - - private var summarizeBtn: NSButton? - - private let relativeFormatter: RelativeDateTimeFormatter = { - let f = RelativeDateTimeFormatter() - f.unitsStyle = .abbreviated - return f - }() private let timeFormatter: DateFormatter = { let f = DateFormatter() @@ -57,8 +47,6 @@ class SessionExplorerTimelineController: NSObject, NSTableViewDataSource, NSTabl } struct TimelineOptions { - let cachedActionSummaries: [Int: String] - let summarizeEnabled: Bool let resumeEnabled: Bool let forkAtPointEnabled: Bool let scrollToIndex: Int? @@ -71,15 +59,10 @@ class SessionExplorerTimelineController: NSObject, NSTableViewDataSource, NSTabl self.entries = entries self.forkAtPointEnabled = options.forkAtPointEnabled - // Apply cached action summaries - for i in 0.. NSView { + private func makeHeader(session: ExplorerSessionInfo, resumeEnabled: Bool) -> NSView { let header = NSView() header.wantsLayer = true header.layer?.backgroundColor = NSColor(white: 0, alpha: 0.1).cgColor @@ -182,50 +140,12 @@ class SessionExplorerTimelineController: NSObject, NSTableViewDataSource, NSTabl subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 2), subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor), + subtitle.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -12), buttonStack.topAnchor.constraint(equalTo: header.topAnchor, constant: 12), buttonStack.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -16), ]) - // Track what the current bottom element is - var bottomAnchorView: NSView = subtitle - - // Show existing summary if cached - if let summary = session.summary { - let field = NSTextField(labelWithString: summary) - field.font = .systemFont(ofSize: 12) - field.textColor = .secondaryLabelColor - field.lineBreakMode = .byTruncatingTail - field.maximumNumberOfLines = 5 - field.cell?.wraps = true - field.cell?.isScrollable = false - field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - field.translatesAutoresizingMaskIntoConstraints = false - header.addSubview(field) - - NSLayoutConstraint.activate([ - field.leadingAnchor.constraint(equalTo: title.leadingAnchor), - field.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -16), - field.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4), - ]) - bottomAnchorView = field - } - - // Summarize button — always present, disabled when nothing to summarize - let btn = NSButton(title: "Summarize with Haiku", target: self, action: #selector(summarizeClicked)) - btn.bezelStyle = .rounded - btn.controlSize = .small - btn.isEnabled = summarizeEnabled - btn.translatesAutoresizingMaskIntoConstraints = false - self.summarizeBtn = btn - header.addSubview(btn) - - NSLayoutConstraint.activate([ - btn.topAnchor.constraint(equalTo: bottomAnchorView.bottomAnchor, constant: 8), - btn.leadingAnchor.constraint(equalTo: title.leadingAnchor), - ]) - btn.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -12).isActive = true - return header } @@ -239,10 +159,6 @@ class SessionExplorerTimelineController: NSObject, NSTableViewDataSource, NSTabl onFork?(session.sessionId) } - @objc private func summarizeClicked() { - onSummarize?() - } - // MARK: - Empty State private func showEmptyState() { @@ -321,24 +237,6 @@ class SessionExplorerTimelineController: NSObject, NSTableViewDataSource, NSTabl metaField.translatesAutoresizingMaskIntoConstraints = false cell.addSubview(metaField) - // Action summary (what Claude did in response) - let actionField: NSTextField? - if let summary = entry.actionSummary, !summary.isEmpty { - let field = NSTextField(labelWithString: "\u{2192} \(summary)") - field.font = .systemFont(ofSize: 11) - field.textColor = .secondaryLabelColor - field.lineBreakMode = .byTruncatingTail - field.maximumNumberOfLines = 5 - field.cell?.wraps = true - field.cell?.isScrollable = false - field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - field.translatesAutoresizingMaskIntoConstraints = false - cell.addSubview(field) - actionField = field - } else { - actionField = nil - } - // Fork here button (icon rotated 180° so arrows point down) let forkBtn: NSButton? if forkAtPointEnabled { @@ -391,6 +289,7 @@ class SessionExplorerTimelineController: NSObject, NSTableViewDataSource, NSTabl // Meta metaField.leadingAnchor.constraint(equalTo: msgField.leadingAnchor), metaField.topAnchor.constraint(equalTo: msgField.bottomAnchor, constant: 2), + metaField.bottomAnchor.constraint(equalTo: cell.bottomAnchor, constant: -8), ]) @@ -401,17 +300,6 @@ class SessionExplorerTimelineController: NSObject, NSTableViewDataSource, NSTabl ]) } - if let actionField { - NSLayoutConstraint.activate([ - actionField.leadingAnchor.constraint(equalTo: msgField.leadingAnchor), - actionField.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -16), - actionField.topAnchor.constraint(equalTo: metaField.bottomAnchor, constant: 1), - actionField.bottomAnchor.constraint(equalTo: cell.bottomAnchor, constant: -8), - ]) - } else { - metaField.bottomAnchor.constraint(equalTo: cell.bottomAnchor, constant: -8).isActive = true - } - return cell } diff --git a/Sources/Session/SessionExplorerWindowController.swift b/Sources/Session/SessionExplorerWindowController.swift index ed7a242..3a1b43e 100644 --- a/Sources/Session/SessionExplorerWindowController.swift +++ b/Sources/Session/SessionExplorerWindowController.swift @@ -184,7 +184,6 @@ class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, messageCount: session.messageCount, firstUserMessage: session.firstUserMessage, savedName: (name?.isEmpty == false) ? name : nil, - summary: SummaryManager.shared.cachedSummary(forSessionId: session.sessionId, kind: session.kind), isBookmarked: bookmarkedIds.contains(session.sessionId) ) } @@ -212,7 +211,6 @@ class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, if !query.isEmpty { sessions = sessions.filter { ($0.savedName ?? "").lowercased().contains(query) || - ($0.summary ?? "").lowercased().contains(query) || $0.firstUserMessage.lowercased().contains(query) } } @@ -314,60 +312,17 @@ class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, let updatedSession = allSessions.first(where: { $0.cacheKey == cacheKey }) ?? session - let cachedActionSummaries = SummaryManager.shared.cachedTurnSummaries(forSessionId: sessionId, kind: session.agentKind) - - let actions = ContextMonitor.shared.parseActions(sessionId: sessionId, projectPath: projectPath, kind: session.agentKind) - let hasUncachedActions = entries.contains { entry in - let turnActions = actions[entry.index] ?? [] - return !turnActions.isEmpty && cachedActionSummaries[entry.index] == nil - } - let cachedTurnCount = SummaryManager.shared.cachedSummaryTurnCount(forSessionId: sessionId, kind: session.agentKind) - let needsSessionSummary = updatedSession.summary == nil || cachedTurnCount < entries.count - let summarizeEnabled = needsSessionSummary || hasUncachedActions - let isOpen = openSessionIds.contains(updatedSession.cacheKey) timelineController?.showTimeline( session: updatedSession, entries: entries, options: .init( - cachedActionSummaries: cachedActionSummaries, - summarizeEnabled: summarizeEnabled, resumeEnabled: !isOpen, forkAtPointEnabled: updatedSession.agentKind.isAgent, scrollToIndex: scrollToMessageIndex ) ) - - timelineController?.onSummarize = { [weak self] in - self?.summarizeAll(session: updatedSession, entries: entries, actions: actions) - } - } - - private func summarizeAll(session: ExplorerSessionInfo, entries: [TimelineEntry], actions: [Int: [String]]) { - timelineController?.setSummarizing(true) - - SummaryManager.shared.generateCombinedSummaries( - SummaryManager.CombinedSummaryRequest( - sessionId: session.sessionId, - projectPath: projectPath, - kind: session.agentKind, - currentTurnCount: entries.count, - actions: actions) - ) { [weak self] sessionSummary, actionSummaries in - guard let self, self.selectedSessionId == session.cacheKey else { return } - - if let summary = sessionSummary, - let idx = self.allSessions.firstIndex(where: { $0.cacheKey == session.cacheKey }) { - self.allSessions[idx].summary = summary - if let fIdx = self.filteredSessions.firstIndex(where: { $0.cacheKey == session.cacheKey }) { - self.filteredSessions[fIdx].summary = summary - } - } - - // Rebuild right pane with updated data - self.selectSession(cacheKey: session.cacheKey, scrollToMessageIndex: nil) - } } // MARK: - NSSplitViewDelegate diff --git a/Sources/Session/SummaryManager.swift b/Sources/Session/SummaryManager.swift deleted file mode 100644 index 8918416..0000000 --- a/Sources/Session/SummaryManager.swift +++ /dev/null @@ -1,424 +0,0 @@ -import Foundation - -/// Generates and caches AI-generated session summaries by shelling out to `claude --print`. -class SummaryManager { - static let shared = SummaryManager() - - struct CombinedSummaryRequest { - let sessionId: String - let projectPath: String - let kind: TabKind - let currentTurnCount: Int - let actions: [Int: [String]] - } - - private let fileURL: URL = { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - let deckardDir = appSupport.appendingPathComponent("Deckard") - try? FileManager.default.createDirectory(at: deckardDir, withIntermediateDirectories: true) - return deckardDir.appendingPathComponent("session-summaries.json") - }() - - private var cache: [String: CachedSummary]? - private var inFlightSessionIds = Set() - - struct CachedSummary: Codable { - let summary: String - let generatedAt: Date - var turnCount: Int? - } - - /// Returns a cached summary for the session, or nil if not yet generated. - func cachedSummary(forSessionId sessionId: String) -> String? { - cachedSummary(forSessionId: sessionId, kind: .claude) - } - - func cachedSummary(forSessionId sessionId: String, kind: TabKind) -> String? { - let all = loadAll() - return all[cacheKey(sessionId: sessionId, kind: kind)]?.summary - } - - /// Returns the cached turn count for a session summary, or 0 if unknown. - func cachedSummaryTurnCount(forSessionId sessionId: String) -> Int { - cachedSummaryTurnCount(forSessionId: sessionId, kind: .claude) - } - - func cachedSummaryTurnCount(forSessionId sessionId: String, kind: TabKind) -> Int { - let all = loadAll() - return all[cacheKey(sessionId: sessionId, kind: kind)]?.turnCount ?? 0 - } - - /// Returns true if a summary generation is currently in progress for this session. - func isGenerating(sessionId: String) -> Bool { - inFlightSessionIds.contains(sessionId) - } - - /// Generates a session summary. If a cached summary exists but the session has more - /// turns than when it was generated, the summary is regenerated. - /// Calls `completion` on the main thread with the result. - func generateSummary(sessionId: String, projectPath: String, currentTurnCount: Int, completion: @escaping (String?) -> Void) { - generateSummary(sessionId: sessionId, projectPath: projectPath, kind: .claude, currentTurnCount: currentTurnCount, completion: completion) - } - - func generateSummary(sessionId: String, projectPath: String, kind: TabKind, currentTurnCount: Int, completion: @escaping (String?) -> Void) { - let cacheKey = cacheKey(sessionId: sessionId, kind: kind) - let all = loadAll() - let cached = all[cacheKey] - - // Return cached if it covers all current turns - if let cached, (cached.turnCount ?? 0) >= currentTurnCount { - completion(cached.summary) - return - } - - // Already in flight? - guard !inFlightSessionIds.contains(cacheKey) else { return } - inFlightSessionIds.insert(cacheKey) - - // Parse user messages for the prompt - let entries = ContextMonitor.shared.parseTimeline(sessionId: sessionId, projectPath: projectPath, kind: kind) - guard !entries.isEmpty else { - inFlightSessionIds.remove(cacheKey) - completion(cached?.summary) - return - } - - let userMessages = entries.map { $0.message }.joined(separator: "\n---\n") - let prompt = "Summarize this \(kind.displayName) session in 1-2 concise sentences, focusing on what was accomplished. Only output the summary, nothing else.\n\nUser messages:\n\(userMessages)" - - DispatchQueue.global(qos: .utility).async { [weak self] in - let result = self?.runClaudePrint(prompt: prompt) - - DispatchQueue.main.async { - self?.inFlightSessionIds.remove(cacheKey) - - if let summary = result, !summary.isEmpty { - self?.saveSummary(sessionId: cacheKey, summary: summary, turnCount: entries.count) - completion(summary) - } else { - completion(cached?.summary) - } - } - } - } - - // MARK: - Combined Summary (session + actions in one AI call) - - /// Generates both a session summary and per-turn action summaries in a single haiku call. - /// Calls `completion` on the main thread with (sessionSummary, actionSummaries). - func generateCombinedSummaries( - sessionId: String, - projectPath: String, - currentTurnCount: Int, - actions: [Int: [String]], - completion: @escaping (String?, [Int: String]) -> Void - ) { - generateCombinedSummaries( - CombinedSummaryRequest( - sessionId: sessionId, - projectPath: projectPath, - kind: .claude, - currentTurnCount: currentTurnCount, - actions: actions), - completion: completion) - } - - func generateCombinedSummaries( - _ request: CombinedSummaryRequest, - completion: @escaping (String?, [Int: String]) -> Void - ) { - let sessionId = request.sessionId - let projectPath = request.projectPath - let kind = request.kind - let currentTurnCount = request.currentTurnCount - let actions = request.actions - let sessionCacheKey = cacheKey(sessionId: sessionId, kind: kind) - let key = "combined-\(sessionCacheKey)" - guard !inFlightSessionIds.contains(key) else { return } - inFlightSessionIds.insert(key) - - // Determine what needs generation - let cachedSessionSummary = loadAll()[sessionCacheKey] - let needsSessionSummary = cachedSessionSummary == nil || (cachedSessionSummary?.turnCount ?? 0) < currentTurnCount - - let existingTurnSummaries = cachedTurnSummaries(forSessionId: sessionId, kind: kind) - let nonEmpty = actions.filter { !$0.value.isEmpty } - let needsTurnSummaries = nonEmpty.filter { existingTurnSummaries[$0.key] == nil } - - // If nothing to do, return cached - if !needsSessionSummary && needsTurnSummaries.isEmpty { - inFlightSessionIds.remove(key) - completion(cachedSessionSummary?.summary, existingTurnSummaries) - return - } - - // Build combined prompt - let entries = ContextMonitor.shared.parseTimeline(sessionId: sessionId, projectPath: projectPath, kind: kind) - let userMessages = entries.map { $0.message }.joined(separator: "\n---\n") - - var promptParts: [String] = [] - - if needsSessionSummary { - promptParts.append("PART 1: Summarize this \(kind.displayName) session in 1-2 concise sentences, focusing on what was accomplished. Output on a line starting with \"SESSION:\".") - promptParts.append("\nUser messages:\n\(userMessages)\n") - } - - if !needsTurnSummaries.isEmpty { - promptParts.append("PART 2: For each numbered turn below, write a single short sentence (max 10 words) summarizing what was done. Output one line per turn in the format \"N: summary\".\n") - for turnIndex in needsTurnSummaries.keys.sorted() { - let actionList = needsTurnSummaries[turnIndex]!.joined(separator: ", ") - promptParts.append("\(turnIndex): \(actionList)") - } - } - - promptParts.append("\nOutput nothing else.") - let prompt = promptParts.joined(separator: "\n") - - DispatchQueue.global(qos: .utility).async { [weak self] in - let result = self?.runClaudePrint(prompt: prompt) - - DispatchQueue.main.async { - self?.inFlightSessionIds.remove(key) - - var sessionSummary: String? - var newTurnSummaries: [Int: String] = [:] - - if let output = result { - for line in output.components(separatedBy: "\n") { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.hasPrefix("SESSION:") { - sessionSummary = String(trimmed.dropFirst("SESSION:".count)).trimmingCharacters(in: .whitespacesAndNewlines) - } else if let colonIdx = trimmed.firstIndex(of: ":") { - let numStr = trimmed[trimmed.startIndex.. [Int: String] { - cachedTurnSummaries(forSessionId: sessionId, kind: .claude) - } - - func cachedTurnSummaries(forSessionId sessionId: String, kind: TabKind) -> [Int: String] { - let all = loadAllTurnSummaries() - guard let cached = all[cacheKey(sessionId: sessionId, kind: kind)] else { return [:] } - var result: [Int: String] = [:] - for (key, value) in cached.summaries { - if let idx = Int(key) { result[idx] = value } - } - return result - } - - /// Generates action summaries for turns that don't already have cached summaries. - /// Returns cached summaries immediately via `completion`, then generates missing ones - /// and calls `completion` again with the full set when done. - /// `actions` maps turn index to raw action descriptions. `totalTurnCount` is the current - /// number of turns in the session (used to detect continued sessions). - func generateTurnSummaries(sessionId: String, actions: [Int: [String]], completion: @escaping ([Int: String]) -> Void) { - generateTurnSummaries(sessionId: sessionId, kind: .claude, actions: actions, completion: completion) - } - - func generateTurnSummaries(sessionId: String, kind: TabKind, actions: [Int: [String]], completion: @escaping ([Int: String]) -> Void) { - let sessionCacheKey = cacheKey(sessionId: sessionId, kind: kind) - let key = "turns-\(sessionCacheKey)" - - // Load existing cached summaries - let existing = cachedTurnSummaries(forSessionId: sessionId, kind: kind) - - // Figure out which turns need summarization (have actions but no cached summary) - let nonEmpty = actions.filter { !$0.value.isEmpty } - let needsSummary = nonEmpty.filter { existing[$0.key] == nil } - - // If everything is cached, return immediately - if needsSummary.isEmpty { - completion(existing) - return - } - - // Return what we have so far - if !existing.isEmpty { - completion(existing) - } - - // Don't double-generate - guard !inFlightSessionIds.contains(key) else { return } - inFlightSessionIds.insert(key) - - // Build prompt only for new turns - var promptLines = ["For each numbered turn below, write a single short sentence (max 10 words) summarizing what was done. Output one line per turn in the format \"N: summary\". No other text.\n"] - for turnIndex in needsSummary.keys.sorted() { - let actionList = needsSummary[turnIndex]!.joined(separator: ", ") - promptLines.append("\(turnIndex): \(actionList)") - } - let prompt = promptLines.joined(separator: "\n") - - DispatchQueue.global(qos: .utility).async { [weak self] in - let result = self?.runClaudePrint(prompt: prompt) - - DispatchQueue.main.async { - self?.inFlightSessionIds.remove(key) - - var newSummaries: [Int: String] = [:] - if let output = result { - for line in output.components(separatedBy: "\n") { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard let colonIdx = trimmed.firstIndex(of: ":") else { continue } - let numStr = trimmed[trimmed.startIndex.. [String: CachedTurnSummaries] { - if let cached = turnSummariesCache { return cached } - guard let data = try? Data(contentsOf: turnSummariesURL), - let dict = try? JSONDecoder().decode([String: CachedTurnSummaries].self, from: data) else { - turnSummariesCache = [:] - return [:] - } - turnSummariesCache = dict - return dict - } - - private func saveTurnSummaries(sessionId: String, summaries: [Int: String]) { - var all = loadAllTurnSummaries() - var codable: [String: String] = [:] - for (key, value) in summaries { codable[String(key)] = value } - all[sessionId] = CachedTurnSummaries(summaries: codable) - turnSummariesCache = all - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - guard let data = try? encoder.encode(all) else { return } - try? data.write(to: turnSummariesURL, options: .atomic) - } - - // MARK: - Private - - private func cacheKey(sessionId: String, kind: TabKind) -> String { - kind == .claude ? sessionId : "\(kind.rawValue):\(sessionId)" - } - - private func runClaudePrint(prompt: String) -> String? { - let process = Process() - // Search common locations for the claude binary - let candidates = ["/usr/local/bin/claude", "/opt/homebrew/bin/claude"] - var claudePath: String? - for path in candidates { - if FileManager.default.isExecutableFile(atPath: path) { - claudePath = path - break - } - } - // Fallback: search PATH - if claudePath == nil, let pathEnv = ProcessInfo.processInfo.environment["PATH"] { - for dir in pathEnv.split(separator: ":") { - let full = "\(dir)/claude" - if FileManager.default.isExecutableFile(atPath: full) { - claudePath = full - break - } - } - } - guard let resolvedPath = claudePath else { return nil } - - process.executableURL = URL(fileURLWithPath: resolvedPath) - process.arguments = ["--print", "--model", "haiku", "--effort", "low", "-p", prompt] - - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - - do { - try process.run() - } catch { - return nil - } - - let data = stdout.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - - guard process.terminationStatus == 0 else { return nil } - return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func loadAll() -> [String: CachedSummary] { - if let cached = cache { return cached } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - guard let data = try? Data(contentsOf: fileURL), - let dict = try? decoder.decode([String: CachedSummary].self, from: data) else { - cache = [:] - return [:] - } - cache = dict - return dict - } - - private func saveSummary(sessionId: String, summary: String, turnCount: Int) { - var all = loadAll() - all[sessionId] = CachedSummary(summary: summary, generatedAt: Date(), turnCount: turnCount) - cache = all - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - guard let data = try? encoder.encode(all) else { return } - try? data.write(to: fileURL, options: .atomic) - } -} From 54699a60a1e05fed3056fbc1770bc18d53f96c59 Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Wed, 29 Apr 2026 10:10:28 +0200 Subject: [PATCH 04/15] Support image paste in terminal tabs --- Sources/Terminal/TerminalSurface.swift | 59 ++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/Sources/Terminal/TerminalSurface.swift b/Sources/Terminal/TerminalSurface.swift index 9b95f41..d230864 100644 --- a/Sources/Terminal/TerminalSurface.swift +++ b/Sources/Terminal/TerminalSurface.swift @@ -6,6 +6,23 @@ import SwiftTerm /// LocalProcessTerminalView subclass that accepts file drags from Finder /// and pastes shell-escaped paths into the terminal. private class DeckardTerminalView: LocalProcessTerminalView { + private static let kittyCommandVPasteSequence = Array("\u{1B}[118;9u".utf8) + private static let imagePasteboardTypes: [NSPasteboard.PasteboardType] = [ + .png, + .tiff + ] + private static let imageFileExtensions: Set = [ + "gif", + "heic", + "heif", + "jpeg", + "jpg", + "png", + "tif", + "tiff", + "webp" + ] + override init(frame: CGRect) { super.init(frame: frame) registerForDraggedTypes([.fileURL]) @@ -24,6 +41,21 @@ private class DeckardTerminalView: LocalProcessTerminalView { return super.draggingEntered(sender) } + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if Self.isPasteShortcut(event) { + paste(event) + return true + } + return super.performKeyEquivalent(with: event) + } + + override func paste(_ sender: Any) { + if forwardImagePasteShortcutToTerminal() { + return + } + super.paste(sender) + } + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { guard let urls = sender.draggingPasteboard.readObjects( forClasses: [NSURL.self], @@ -37,6 +69,33 @@ private class DeckardTerminalView: LocalProcessTerminalView { return true } + private func forwardImagePasteShortcutToTerminal() -> Bool { + guard Self.pasteboardContainsImage(NSPasteboard.general) else { return false } + send(Self.kittyCommandVPasteSequence) + return true + } + + private static func isPasteShortcut(_ event: NSEvent) -> Bool { + let flags = event.modifierFlags.intersection([.command, .shift, .option, .control]) + return flags == .command && event.charactersIgnoringModifiers?.lowercased() == "v" + } + + private static func pasteboardContainsImage(_ pasteboard: NSPasteboard) -> Bool { + if pasteboard.availableType(from: imagePasteboardTypes) != nil { + return true + } + if pasteboard.canReadObject(forClasses: [NSImage.self], options: nil) { + return true + } + guard let urls = pasteboard.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true] + ) as? [URL] else { + return false + } + return urls.contains { imageFileExtensions.contains($0.pathExtension.lowercased()) } + } + /// Escape a file path for safe pasting into a shell. private static func shellEscape(_ path: String) -> String { let special: Set = [" ", "'", "\"", "\\", "(", ")", "[", "]", From 0f946b03b2455275671bfdc0ef05ba82c5e3d347 Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Wed, 29 Apr 2026 10:10:37 +0200 Subject: [PATCH 05/15] Improve Codex configuration and telemetry --- README.md | 12 +- Sources/App/AppDelegate.swift | 4 +- Sources/App/ClaudeCLIFlags.swift | 291 ++++++++++---- Sources/Detection/ContextMonitor.swift | 385 +++++++++++++++++-- Sources/Detection/ProcessMonitor.swift | 17 + Sources/Session/SessionState.swift | 1 + Sources/Window/ClaudeArgsField.swift | 31 +- Sources/Window/DeckardWindowController.swift | 198 ++++++++-- Sources/Window/SettingsWindow.swift | 161 ++++++-- Sources/Window/SidebarController.swift | 30 ++ Tests/ClaudeCLIFlagsTests.swift | 94 +++++ Tests/ContextMonitorTests.swift | 104 +++++ Tests/SessionStateTests.swift | 6 +- 13 files changed, 1155 insertions(+), 179 deletions(-) diff --git a/README.md b/README.md index 2ca7f95..9b6c387 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,9 @@ Agent usage stats appear only on tabs where Deckard can read real provider data. | Metric | Claude Code tabs | Codex tabs | Terminal tabs | | --- | --- | --- | --- | -| Context usage bar | Yes, from Claude session usage entries | Yes, from Codex `token_count` rollout events when present | No | -| 5-hour quota | Yes, from Claude status-line hook data | Yes, from Codex rollout rate-limit data when present | No | -| 7-day quota | Yes, from Claude status-line hook data | Yes, from Codex rollout rate-limit data when present | No | +| Context usage bar | Yes, from Claude session usage entries | No reliable local signal; hidden | No | +| 5-hour quota | Yes, from Claude status-line hook data | Yes, from Codex app-server rate-limit data with rollout fallback | No | +| 7-day quota | Yes, from Claude status-line hook data | Yes, from Codex app-server rate-limit data with rollout fallback | No | | Tokens per minute | Yes, from recent Claude output token usage | Yes, from recent Codex generated token usage | No | Classic terminal tabs intentionally do not show agent context, quota, or token-rate panels. @@ -89,7 +89,7 @@ Ships with 486 built-in themes in Ghostty format and loads custom themes from `~ | Timeline and action view | Yes | Yes | | Bookmarks | Yes | Yes | | Provider-specific badges | Yes | Yes | -| Context, quota, token rate | Yes | Yes, when Codex writes `token_count` events | +| Context, quota, token rate | Yes | Quota via Codex app-server; token rate from `token_count` events when present | Deckard aims for equal day-to-day workflows across Claude Code and Codex. Some telemetry is necessarily provider-specific because the CLIs expose different local data. When Deckard cannot read a metric reliably, it hides that metric instead of showing stale data from another tab or provider. @@ -139,7 +139,9 @@ Deckard reads Claude session JSONL files under `~/.claude/projects` for session **Codex** -Deckard reads Codex rollout files under `~/.codex/sessions` and the local Codex state database at `~/.codex/state_5.sqlite`. That provides project-scoped session discovery, resume, fork, fork-at-turn, timeline parsing, badges, context usage, quota percentages, and token-rate calculation when Codex has written the corresponding events. +Deckard reads Codex rollout files under `~/.codex/sessions` and the local Codex state database at `~/.codex/state_5.sqlite`. That provides project-scoped session discovery, resume, fork, fork-at-turn, timeline parsing, badges, and token-rate calculation when Codex has written the corresponding events. + +For Codex quota, Deckard keeps a local `codex app-server --listen stdio://` JSON-RPC connection open while needed and calls `account/rateLimits/read`, falling back to rollout `token_count` rate-limit events if the app-server data is unavailable. Deckard does not install Codex hooks. It launches the Codex CLI directly with `codex`, `codex resume`, or `codex fork`. diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 053680d..d2b4b59 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -61,9 +61,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { log.log("startup", "Installing Claude Code hooks...") DeckardHooksInstaller.installIfNeeded() - // Parse Claude CLI flags for autocomplete in settings. + // Parse agent CLI flags for autocomplete in settings. log.log("startup", "Loading Claude CLI flags...") ClaudeCLIFlags.shared.load() + log.log("startup", "Loading Codex CLI flags...") + CodexCLIFlags.shared.load() // Clean up orphaned tmux sessions from previous runs if TerminalSurface.tmuxAvailable { diff --git a/Sources/App/ClaudeCLIFlags.swift b/Sources/App/ClaudeCLIFlags.swift index 30d8b64..ae1b6cd 100644 --- a/Sources/App/ClaudeCLIFlags.swift +++ b/Sources/App/ClaudeCLIFlags.swift @@ -1,6 +1,6 @@ import Foundation -/// Represents a single CLI flag parsed from `claude --help`. +/// Represents a single CLI flag parsed from a supported agent CLI's help output. struct ClaudeFlag { let longName: String let shortName: String? @@ -15,90 +15,98 @@ struct ClaudeFlag { } } -/// Parses and caches CLI flags from `claude --help`. -final class ClaudeCLIFlags { - - static let shared = ClaudeCLIFlags() - private init() {} - - /// Parsed flags. Empty until `load()` completes (or if claude is not installed). - private(set) var flags: [ClaudeFlag] = [] - - /// Flags Deckard manages internally — excluded from suggestions. - static let blocklist: Set = [ - "--resume", "--continue", "--fork-session", "--print", "--version", "--help", - "--output-format", "--input-format", "--include-partial-messages", - "--replay-user-messages", "--json-schema", "--max-budget-usd", - "--no-session-persistence", "--fallback-model", "--from-pr", "--session-id", - ] - - /// Flags whose parsed valueType is overridden. - /// e.g. --worktree normally takes an optional [name], but we force it to boolean - /// so users can't pin a worktree name as a persistent default (which breaks sessions). - static let valueTypeOverrides: [String: ClaudeFlag.ValueType] = [ - "--worktree": .boolean, - "--tmux": .boolean, - ] - - /// Posted on the main thread when flags finish loading. - static let didLoadNotification = Notification.Name("ClaudeCLIFlagsDidLoad") +private enum CLIHelpParser { + /// Parse CLI help output into structured flags. + static func parse( + helpOutput: String, + blocklist: Set, + valueTypeOverrides: [String: ClaudeFlag.ValueType] = [:] + ) -> [ClaudeFlag] { + let lines = helpOutput.components(separatedBy: .newlines) + var results: [ClaudeFlag] = [] + var index = 0 - /// Run `claude --help` asynchronously and parse the output. - func load() { - DispatchQueue.global(qos: .utility).async { [weak self] in - guard let output = Self.runClaudeHelp() else { return } - let parsed = Self.parse(helpOutput: output) - DispatchQueue.main.async { - self?.flags = parsed - NotificationCenter.default.post(name: Self.didLoadNotification, object: nil) + while index < lines.count { + guard let header = parseOptionHeader(lines[index]) else { + index += 1 + continue } - } - } - /// Parse `claude --help` output into structured flags. - static func parse(helpOutput: String) -> [ClaudeFlag] { - // Matches lines like: - // --flag Description - // -s, --flag Description - // --aliasA, --aliasB Description - // Groups: (1) short flag, (2) last long flag, (3) value placeholder, (4) description - let pattern = #"^\s+(?:(-\w),\s+)?(?:--[\w-]+,\s+)*(--[\w-]+)(?:\s+[\[<]([^\]>]+)[\]>](?:\.{3})?)?\s{2,}(.+)$"# - guard let regex = try? NSRegularExpression(pattern: pattern, options: .anchorsMatchLines) else { - return [] - } + var descriptionParts: [String] = [] + if let inlineDescription = header.inlineDescription { + descriptionParts.append(inlineDescription) + } - var results: [ClaudeFlag] = [] - let nsString = helpOutput as NSString + var nextIndex = index + 1 + while nextIndex < lines.count { + let line = lines[nextIndex] + if parseOptionHeader(line) != nil { break } + if !line.isEmpty, line.first?.isWhitespace != true { break } - regex.enumerateMatches(in: helpOutput, range: NSRange(location: 0, length: nsString.length)) { match, _, _ in - guard let match else { return } + let trimmed = line.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty { + descriptionParts.append(trimmed) + } + nextIndex += 1 + } - let shortName = match.range(at: 1).location != NSNotFound - ? nsString.substring(with: match.range(at: 1)) : nil - let longName = nsString.substring(with: match.range(at: 2)) - let placeholder = match.range(at: 3).location != NSNotFound - ? nsString.substring(with: match.range(at: 3)) : nil - let desc = nsString.substring(with: match.range(at: 4)) - .replacingOccurrences(of: " +", with: " ", options: .regularExpression) // Collapse multi-space runs from column alignment + index = max(nextIndex, index + 1) - // Skip blocklisted flags - if blocklist.contains(longName) { return } + if blocklist.contains(header.longName) { continue } - let parsedType = Self.determineValueType(placeholder: placeholder, description: desc) - let valueType = valueTypeOverrides[longName] ?? parsedType + let description = descriptionParts + .joined(separator: " ") + .replacingOccurrences(of: " +", with: " ", options: .regularExpression) + let parsedType = determineValueType(placeholder: header.placeholder, description: description) + let valueType = valueTypeOverrides[header.longName] ?? parsedType results.append(ClaudeFlag( - longName: longName, - shortName: shortName, - description: desc, + longName: header.longName, + shortName: header.shortName, + description: description, valueType: valueType, - valuePlaceholder: valueType == .boolean ? nil : placeholder.map { "<\($0)>" } + valuePlaceholder: valueType == .boolean ? nil : header.placeholder.map { "<\($0)>" } )) } return results } + private struct OptionHeader { + let shortName: String? + let longName: String + let placeholder: String? + let inlineDescription: String? + } + + private static func parseOptionHeader(_ line: String) -> OptionHeader? { + // Matches lines like: + // --flag Description + // -s, --flag + // --aliasA, --aliasB Description + // Groups: (1) short flag, (2) last long flag, (3) value placeholder, (4) description + let pattern = #"^\s+(?:(-\w),\s+)?(?:--[\w-]+,\s+)*(--[\w-]+)(?:\s+[\[<]([^\]>]+)[\]>](?:\.{3})?)?(?:\s{2,}(.+))?\s*$"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) + else { return nil } + + let nsString = line as NSString + let shortName = match.range(at: 1).location != NSNotFound + ? nsString.substring(with: match.range(at: 1)) : nil + let longName = nsString.substring(with: match.range(at: 2)) + let placeholder = match.range(at: 3).location != NSNotFound + ? nsString.substring(with: match.range(at: 3)) : nil + let inlineDescription = match.range(at: 4).location != NSNotFound + ? nsString.substring(with: match.range(at: 4)) : nil + + return OptionHeader( + shortName: shortName, + longName: longName, + placeholder: placeholder, + inlineDescription: inlineDescription + ) + } + private static func determineValueType(placeholder: String?, description: String) -> ClaudeFlag.ValueType { guard placeholder != nil else { return .boolean } @@ -116,6 +124,45 @@ final class ClaudeCLIFlags { } } + // Clap-style possible values: [possible values: a, b, c] + if let valuesMatch = description.range(of: #"\[possible values:\s*([^\]]+)\]"#, options: [.regularExpression, .caseInsensitive]) { + let valuesText = String(description[valuesMatch]) + .replacingOccurrences(of: #"\[possible values:\s*"#, with: "", options: [.regularExpression, .caseInsensitive]) + .replacingOccurrences(of: "]", with: "") + let values = valuesText.split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + if !values.isEmpty { + return .enumeration(values) + } + } + + // Clap-style bullet list after "Possible values:". + if description.range(of: "Possible values:", options: .caseInsensitive) != nil { + let pattern = #"-\s+([a-zA-Z][\w-]*)\s*:"# + if let regex = try? NSRegularExpression(pattern: pattern) { + let nsStr = description as NSString + let matches = regex.matches(in: description, range: NSRange(location: 0, length: nsStr.length)) + let values = matches.map { nsStr.substring(with: $0.range(at: 1)) } + if !values.isEmpty { + return .enumeration(values) + } + } + } + + // Simple parenthetical choices: (lmstudio or ollama) + if let orMatch = description.range( + of: #"\(([a-zA-Z][\w-]{0,19})\s+or\s+([a-zA-Z][\w-]{0,19})\)"#, + options: .regularExpression + ) { + let text = String(description[orMatch].dropFirst().dropLast()) + let values = text.components(separatedBy: " or ") + .map { $0.trimmingCharacters(in: .whitespaces) } + if values.count == 2 { + return .enumeration(values) + } + } + // Informal enum: description ends with (word, word, word) if let informalMatch = description.range( of: #"\(([a-zA-Z][\w-]{0,19}(?:,\s*[a-zA-Z][\w-]{0,19}){1,7})\)\s*$"#, @@ -131,6 +178,56 @@ final class ClaudeCLIFlags { return .freeText } +} + +/// Parses and caches CLI flags from `claude --help`. +final class ClaudeCLIFlags { + + static let shared = ClaudeCLIFlags() + private init() {} + + /// Parsed flags. Empty until `load()` completes (or if claude is not installed). + private(set) var flags: [ClaudeFlag] = [] + + /// Flags Deckard manages internally — excluded from suggestions. + static let blocklist: Set = [ + "--resume", "--continue", "--fork-session", "--print", "--version", "--help", + "--output-format", "--input-format", "--include-partial-messages", + "--replay-user-messages", "--json-schema", "--max-budget-usd", + "--no-session-persistence", "--fallback-model", "--from-pr", "--session-id", + ] + + /// Flags whose parsed valueType is overridden. + /// e.g. --worktree normally takes an optional [name], but we force it to boolean + /// so users can't pin a worktree name as a persistent default (which breaks sessions). + static let valueTypeOverrides: [String: ClaudeFlag.ValueType] = [ + "--worktree": .boolean, + "--tmux": .boolean, + ] + + /// Posted on the main thread when flags finish loading. + static let didLoadNotification = Notification.Name("ClaudeCLIFlagsDidLoad") + + /// Run `claude --help` asynchronously and parse the output. + func load() { + DispatchQueue.global(qos: .utility).async { [weak self] in + guard let output = Self.runClaudeHelp() else { return } + let parsed = Self.parse(helpOutput: output) + DispatchQueue.main.async { + self?.flags = parsed + NotificationCenter.default.post(name: Self.didLoadNotification, object: nil) + } + } + } + + /// Parse `claude --help` output into structured flags. + static func parse(helpOutput: String) -> [ClaudeFlag] { + CLIHelpParser.parse( + helpOutput: helpOutput, + blocklist: blocklist, + valueTypeOverrides: valueTypeOverrides + ) + } private static func runClaudeHelp() -> String? { // Use a login shell so the user's full PATH is available. @@ -155,6 +252,64 @@ final class ClaudeCLIFlags { } } +/// Parses and caches CLI flags from `codex --help`. +final class CodexCLIFlags { + + static let shared = CodexCLIFlags() + private init() {} + + /// Parsed flags. Empty until `load()` completes (or if codex is not installed). + private(set) var flags: [ClaudeFlag] = [] + + /// Flags Deckard manages internally — excluded from suggestions. + static let blocklist: Set = [ + "--help", "--version", + // Deckard launches Codex in the project directory already; suggesting + // --cd as a persistent default would make tabs ignore their project root. + "--cd", + ] + + /// Posted on the main thread when flags finish loading. + static let didLoadNotification = Notification.Name("CodexCLIFlagsDidLoad") + + /// Run `codex --help` asynchronously and parse the output. + func load() { + DispatchQueue.global(qos: .utility).async { [weak self] in + guard let output = Self.runCodexHelp() else { return } + let parsed = Self.parse(helpOutput: output) + DispatchQueue.main.async { + self?.flags = parsed + NotificationCenter.default.post(name: Self.didLoadNotification, object: nil) + } + } + } + + /// Parse `codex --help` output into structured flags. + static func parse(helpOutput: String) -> [ClaudeFlag] { + CLIHelpParser.parse(helpOutput: helpOutput, blocklist: blocklist) + } + + private static func runCodexHelp() -> String? { + // Use a login shell so the user's full PATH is available. + let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" + let process = Process() + process.executableURL = URL(fileURLWithPath: shell) + process.arguments = ["-l", "-c", "codex --help"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) + } catch { + return nil + } + } +} + /// A single chip representing one CLI argument (flag + optional value). struct ArgsChip: Equatable { let flag: String // e.g. "--permission-mode" diff --git a/Sources/Detection/ContextMonitor.swift b/Sources/Detection/ContextMonitor.swift index 79386d7..95efd0b 100644 --- a/Sources/Detection/ContextMonitor.swift +++ b/Sources/Detection/ContextMonitor.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation extension String { @@ -54,6 +55,21 @@ class ContextMonitor { let sparklineData: [Double] } + private let codexAppServerPollInterval: TimeInterval = 60 + private let codexAppServerCacheMaxAge: TimeInterval = 15 * 60 + private let codexAppServerTimeout: TimeInterval = 8 + private let codexAppServerLock = NSLock() + private var codexAppServerCachedQuota: QuotaMonitor.QuotaSnapshot? + private var codexAppServerCachedAt: Date? + private var codexAppServerLastAttempt: Date? + private var codexAppServerRequestInFlight = false + private let codexAppServerProcessLock = NSLock() + private var codexAppServerProcess: Process? + private var codexAppServerInput: FileHandle? + private var codexAppServerOutput: FileHandle? + private var codexAppServerReadBuffer = Data() + private var codexAppServerNextRequestId = 2 + struct SessionInfo { let sessionId: String let modificationDate: Date @@ -183,6 +199,56 @@ class ContextMonitor { return nil } + func codexSessionInfo(openedByProcessId processId: pid_t, projectPath: String) -> SessionInfo? { + guard let fileURL = codexOpenRolloutFileURL(processId: processId) else { + return nil + } + + let resolvedProjectPath = (projectPath as NSString).resolvingSymlinksInPath + return parseCodexSessionInfo(fileURL: fileURL, projectPath: resolvedProjectPath) + } + + private func codexOpenRolloutFileURL(processId: pid_t) -> URL? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof") + process.arguments = ["-Fn", "-p", String(processId)] + process.standardError = FileHandle.nullDevice + + let output = Pipe() + process.standardOutput = output + + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + + let data = output.fileHandleForReading.readDataToEndOfFile() + guard let raw = String(data: data, encoding: .utf8) else { return nil } + + let rootPath = codexSessionsRoot.path + "/" + let candidates = raw + .split(separator: "\n") + .compactMap { line -> URL? in + guard line.first == "n" else { return nil } + let path = String(line.dropFirst()) + guard path.hasPrefix(rootPath), + path.hasSuffix(".jsonl"), + (path as NSString).lastPathComponent.hasPrefix("rollout-") else { + return nil + } + return URL(fileURLWithPath: path) + } + + return candidates.max { lhs, rhs in + let lhsDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + let rhsDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + return lhsDate < rhsDate + } + } + func latestCodexSession(forProjectPath projectPath: String, after date: Date, excluding excludedIds: Set = []) -> SessionInfo? { listCodexSessions(forProjectPath: projectPath) .first { $0.modificationDate >= date && !excludedIds.contains($0.sessionId) } @@ -193,6 +259,10 @@ class ContextMonitor { return nil } + return parseCodexActivityInfo(from: content) + } + + func parseCodexActivityInfo(from content: String) -> CodexActivityInfo? { var latest: CodexActivityInfo? for line in content.split(separator: "\n") { guard let json = parseJSONObject(line), @@ -206,10 +276,10 @@ class ContextMonitor { case "task_started": isBusy = true isError = false - case "task_complete", "task_cancelled": + case "task_complete", "task_cancelled", "turn_aborted": isBusy = false isError = false - case "task_failed": + case "task_failed", "error": isBusy = false isError = true default: @@ -223,14 +293,31 @@ class ContextMonitor { return latest } - func getCodexUsage(sessionId: String) -> CodexUsageInfo? { - guard let content = codexTailContent(sessionId: sessionId, maxBytes: 1024 * 1024) else { + func getCodexUsage(sessionId: String?) -> CodexUsageInfo? { + let fileUsage: CodexUsageInfo? + if let sessionId, + let content = codexTailContent(sessionId: sessionId, maxBytes: 1024 * 1024) { + fileUsage = parseCodexUsage(from: content) + } else { + fileUsage = nil + } + + let appServerQuota = codexAppServerQuotaSnapshot() + let quotaSnapshot = appServerQuota ?? fileUsage?.quotaSnapshot + + guard fileUsage != nil || quotaSnapshot != nil else { return nil } - let now = Date() + return CodexUsageInfo( + context: nil, + quotaSnapshot: quotaSnapshot, + tokenRate: fileUsage?.tokenRate, + sparklineData: fileUsage?.sparklineData ?? []) + } + + func parseCodexUsage(from content: String, now: Date = Date()) -> CodexUsageInfo? { let cutoff = now.addingTimeInterval(-300) - var context: ContextUsage? var quotaSnapshot: QuotaMonitor.QuotaSnapshot? var generatedEvents: [(timestamp: Date, tokens: Int)] = [] @@ -242,23 +329,10 @@ class ContextMonitor { let timestamp = (json["timestamp"] as? String).flatMap { codexTimestampFormatter.date(from: $0) } - if let info = payload["info"] as? [String: Any], - let lastUsage = info["last_token_usage"] as? [String: Any] { - let contextWindow = codexInt(info["model_context_window"]) ?? 0 - let totalTokens = codexInt(lastUsage["total_tokens"]) - ?? ((codexInt(lastUsage["input_tokens"]) ?? 0) - + (codexInt(lastUsage["output_tokens"]) ?? 0) - + (codexInt(lastUsage["reasoning_output_tokens"]) ?? 0)) - - if contextWindow > 0, totalTokens > 0 { - context = ContextUsage( - model: "codex", - inputTokens: totalTokens, - cacheReadTokens: 0, - contextLimit: contextWindow) - } + if let info = payload["info"] as? [String: Any] { + let lastUsage = info["last_token_usage"] as? [String: Any] - if let timestamp, timestamp >= cutoff { + if let lastUsage, let timestamp, timestamp >= cutoff { let generated = (codexInt(lastUsage["output_tokens"]) ?? 0) + (codexInt(lastUsage["reasoning_output_tokens"]) ?? 0) if generated > 0 { @@ -276,17 +350,259 @@ class ContextMonitor { let tokenRate = codexTokenRate(from: generatedEvents, now: now) let sparklineData = generatedEvents.suffix(30).map { Double($0.tokens) } - guard context != nil || quotaSnapshot != nil || tokenRate != nil || !sparklineData.isEmpty else { + guard quotaSnapshot != nil || tokenRate != nil || !sparklineData.isEmpty else { return nil } return CodexUsageInfo( - context: context, + context: nil, quotaSnapshot: quotaSnapshot, tokenRate: tokenRate, sparklineData: sparklineData) } + func parseCodexAppServerQuotaResponse(_ response: [String: Any], now: Date = Date()) -> QuotaMonitor.QuotaSnapshot? { + guard response["error"] == nil, + let result = response["result"] as? [String: Any] else { return nil } + + if let byLimitId = result["rateLimitsByLimitId"] as? [String: Any] { + if let codex = byLimitId["codex"] as? [String: Any], + let snapshot = codexQuotaSnapshot(from: codex, timestamp: now) { + return snapshot + } + + for value in byLimitId.values { + guard let limit = value as? [String: Any], + limit["limitId"] as? String == "codex", + let snapshot = codexQuotaSnapshot(from: limit, timestamp: now) else { continue } + return snapshot + } + } + + if let rateLimits = result["rateLimits"] as? [String: Any] { + return codexQuotaSnapshot(from: rateLimits, timestamp: now) + } + if let rateLimits = result["rate_limits"] as? [String: Any] { + return codexQuotaSnapshot(from: rateLimits, timestamp: now) + } + + return nil + } + + private func codexAppServerQuotaSnapshot(now: Date = Date()) -> QuotaMonitor.QuotaSnapshot? { + codexAppServerLock.lock() + if let cached = codexAppServerFreshEnoughCache(now: now, maxAge: codexAppServerPollInterval) { + codexAppServerLock.unlock() + return cached + } + + if codexAppServerRequestInFlight || + codexAppServerLastAttempt.map({ now.timeIntervalSince($0) < codexAppServerPollInterval }) == true { + let cached = codexAppServerFreshEnoughCache(now: now, maxAge: codexAppServerCacheMaxAge) + codexAppServerLock.unlock() + return cached + } + + codexAppServerRequestInFlight = true + codexAppServerLastAttempt = now + codexAppServerLock.unlock() + + let snapshot = fetchCodexAppServerQuotaSnapshot(now: now) + let completedAt = Date() + + codexAppServerLock.lock() + codexAppServerRequestInFlight = false + if let snapshot { + codexAppServerCachedQuota = snapshot + codexAppServerCachedAt = completedAt + } + let cached = codexAppServerFreshEnoughCache(now: completedAt, maxAge: codexAppServerCacheMaxAge) + codexAppServerLock.unlock() + + return snapshot ?? cached + } + + private func codexAppServerFreshEnoughCache(now: Date, maxAge: TimeInterval) -> QuotaMonitor.QuotaSnapshot? { + guard let cached = codexAppServerCachedQuota, + let cachedAt = codexAppServerCachedAt, + now.timeIntervalSince(cachedAt) <= maxAge else { return nil } + return cached + } + + private func fetchCodexAppServerQuotaSnapshot(now: Date) -> QuotaMonitor.QuotaSnapshot? { + codexAppServerProcessLock.lock() + defer { codexAppServerProcessLock.unlock() } + + let deadline = Date().addingTimeInterval(codexAppServerTimeout) + guard ensureCodexAppServerRunning(deadline: deadline), + let input = codexAppServerInput, + let output = codexAppServerOutput else { + DiagnosticLog.shared.log("context", "codex app-server quota refresh could not initialize app-server") + return nil + } + + let requestId = codexAppServerNextRequestId + codexAppServerNextRequestId += 1 + + guard writeCodexAppServerMessage(["method": "account/rateLimits/read", "id": requestId], to: input), + let response = readCodexAppServerResponse(id: requestId, from: output, deadline: deadline) else { + stopCodexAppServer() + DiagnosticLog.shared.log("context", "codex app-server quota refresh did not return a usable response") + return nil + } + + if let error = response["error"] as? [String: Any], + let message = error["message"] as? String { + DiagnosticLog.shared.log("context", "codex app-server quota refresh failed: \(message)") + } + + return parseCodexAppServerQuotaResponse(response, now: now) + } + + private func ensureCodexAppServerRunning(deadline: Date) -> Bool { + if codexAppServerProcess?.isRunning == true, + codexAppServerInput != nil, + codexAppServerOutput != nil { + return true + } + + stopCodexAppServer() + + let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" + let process = Process() + process.executableURL = URL(fileURLWithPath: shell) + process.arguments = ["-l", "-c", "codex app-server --listen stdio://"] + + let inputPipe = Pipe() + let outputPipe = Pipe() + process.standardInput = inputPipe + process.standardOutput = outputPipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + DiagnosticLog.shared.log("context", "codex app-server quota refresh failed to start: \(error.localizedDescription)") + return false + } + + codexAppServerProcess = process + codexAppServerInput = inputPipe.fileHandleForWriting + codexAppServerOutput = outputPipe.fileHandleForReading + codexAppServerReadBuffer = Data() + codexAppServerNextRequestId = 2 + + guard writeCodexAppServerMessage([ + "method": "initialize", + "id": 1, + "params": [ + "clientInfo": [ + "name": "deckard", + "title": "Deckard", + "version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0", + ], + ], + ], to: inputPipe.fileHandleForWriting), + readCodexAppServerResponse(id: 1, from: outputPipe.fileHandleForReading, deadline: deadline) != nil, + writeCodexAppServerMessage(["method": "initialized"], to: inputPipe.fileHandleForWriting) else { + stopCodexAppServer() + return false + } + + return true + } + + private func stopCodexAppServer() { + try? codexAppServerInput?.close() + if let process = codexAppServerProcess, process.isRunning { + process.terminate() + process.waitUntilExit() + } + codexAppServerProcess = nil + codexAppServerInput = nil + codexAppServerOutput = nil + codexAppServerReadBuffer = Data() + codexAppServerNextRequestId = 2 + } + + private func writeCodexAppServerMessage(_ message: [String: Any], to handle: FileHandle) -> Bool { + guard JSONSerialization.isValidJSONObject(message), + var data = try? JSONSerialization.data(withJSONObject: message) else { return false } + data.append(0x0A) + return writeCodexAppServerData(data, to: handle.fileDescriptor) + } + + private func writeCodexAppServerData(_ data: Data, to fd: Int32) -> Bool { + data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return false } + var offset = 0 + while offset < rawBuffer.count { + let written = Darwin.write(fd, baseAddress.advanced(by: offset), rawBuffer.count - offset) + if written > 0 { + offset += written + } else if written == -1 && errno == EINTR { + continue + } else { + return false + } + } + return true + } + } + + private func readCodexAppServerResponse(id: Int, from handle: FileHandle, deadline: Date) -> [String: Any]? { + let fd = handle.fileDescriptor + let oldFlags = fcntl(fd, F_GETFL) + if oldFlags >= 0 { + _ = fcntl(fd, F_SETFL, oldFlags | O_NONBLOCK) + } + defer { + if oldFlags >= 0 { + _ = fcntl(fd, F_SETFL, oldFlags) + } + } + + var chunk = [UInt8](repeating: 0, count: 4096) + + while Date() < deadline { + if let response = codexAppServerBufferedResponse(id: id) { + return response + } + + let count = chunk.withUnsafeMutableBufferPointer { pointer -> Int in + guard let baseAddress = pointer.baseAddress else { return -1 } + return Darwin.read(fd, baseAddress, pointer.count) + } + + if count > 0 { + codexAppServerReadBuffer.append(chunk, count: count) + if let response = codexAppServerBufferedResponse(id: id) { + return response + } + } else if count == 0 { + return nil + } else if errno == EAGAIN || errno == EWOULDBLOCK { + usleep(20_000) + } else { + return nil + } + } + + return nil + } + + private func codexAppServerBufferedResponse(id: Int) -> [String: Any]? { + while let newlineIndex = codexAppServerReadBuffer.firstIndex(of: 0x0A) { + let lineData = codexAppServerReadBuffer[.. SessionInfo? { guard let data = try? Data(contentsOf: fileURL), let content = String(data: data, encoding: .utf8) else { return nil } @@ -378,13 +694,15 @@ class ContextMonitor { } private func codexQuotaSnapshot(from rateLimits: [String: Any], timestamp: Date) -> QuotaMonitor.QuotaSnapshot? { - guard let primary = rateLimits["primary"] as? [String: Any], - let secondary = rateLimits["secondary"] as? [String: Any] else { return nil } + let primary = rateLimits["primary"] as? [String: Any] + let secondary = rateLimits["secondary"] as? [String: Any] + + guard primary != nil || secondary != nil else { return nil } - let primaryUsed = codexDouble(primary["used_percent"]) ?? 0 - let secondaryUsed = codexDouble(secondary["used_percent"]) ?? 0 - let primaryReset = codexDouble(primary["resets_at"]).map { Date(timeIntervalSince1970: $0) } - let secondaryReset = codexDouble(secondary["resets_at"]).map { Date(timeIntervalSince1970: $0) } + let primaryUsed = codexDouble(primary?["used_percent"] ?? primary?["usedPercent"]) ?? 0 + let secondaryUsed = codexDouble(secondary?["used_percent"] ?? secondary?["usedPercent"]) ?? 0 + let primaryReset = codexDouble(primary?["resets_at"] ?? primary?["resetsAt"]).map { Date(timeIntervalSince1970: $0) } + let secondaryReset = codexDouble(secondary?["resets_at"] ?? secondary?["resetsAt"]).map { Date(timeIntervalSince1970: $0) } guard primaryUsed > 0 || secondaryUsed > 0 || primaryReset != nil || secondaryReset != nil else { return nil @@ -423,6 +741,13 @@ class ContextMonitor { return nil } + private func codexResponseId(_ value: Any?) -> Int? { + if let int = value as? Int { return int } + if let double = value as? Double { return Int(double) } + if let string = value as? String { return Int(string) } + return nil + } + private func isSyntheticCodexUserMessage(_ text: String) -> Bool { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.hasPrefix("") || diff --git a/Sources/Detection/ProcessMonitor.swift b/Sources/Detection/ProcessMonitor.swift index bed0723..baa20e1 100644 --- a/Sources/Detection/ProcessMonitor.swift +++ b/Sources/Detection/ProcessMonitor.swift @@ -89,6 +89,23 @@ class ProcessMonitor { } } + /// Return the registered shell PID for a tab. Agent tabs use `exec`, so this + /// PID becomes the long-running agent process after startup. + func shellPid(forSurface surfaceId: UUID) -> pid_t? { + queue.sync { + if let cached = cachedPids[surfaceId] { + return cached.shell + } + guard let shellPid = registeredShellPids[surfaceId.uuidString] else { + return nil + } + if let info = getKInfoProc(pid: shellPid) { + cachedPids[surfaceId] = (login: info.kp_eproc.e_ppid, shell: shellPid) + } + return shellPid + } + } + // MARK: - Core Poll (called on queue) private func _poll(tabs: [TabInfo]) -> [UUID: ActivityInfo] { diff --git a/Sources/Session/SessionState.swift b/Sources/Session/SessionState.swift index 2180f70..e2370b9 100644 --- a/Sources/Session/SessionState.swift +++ b/Sources/Session/SessionState.swift @@ -55,6 +55,7 @@ struct ProjectState: Codable { var selectedTabIndex: Int var tabs: [ProjectTabState] var defaultArgs: String? + var defaultCodexArgs: String? } struct ProjectTabState: Codable { diff --git a/Sources/Window/ClaudeArgsField.swift b/Sources/Window/ClaudeArgsField.swift index 9e2b268..3ff8362 100644 --- a/Sources/Window/ClaudeArgsField.swift +++ b/Sources/Window/ClaudeArgsField.swift @@ -5,6 +5,25 @@ import ObjectiveC /// A chip-based text field for entering Claude CLI arguments with autocomplete. final class ClaudeArgsField: NSView { + enum FlagSource { + case claude + case codex + + var flags: [ClaudeFlag] { + switch self { + case .claude: return ClaudeCLIFlags.shared.flags + case .codex: return CodexCLIFlags.shared.flags + } + } + + var didLoadNotification: Notification.Name { + switch self { + case .claude: return ClaudeCLIFlags.didLoadNotification + case .codex: return CodexCLIFlags.didLoadNotification + } + } + } + var onChange: ((String) -> Void)? private var chips: [ArgsChip] = [] @@ -12,6 +31,7 @@ final class ClaudeArgsField: NSView { private var chipViews: [NSView] = [] private var selectedChipIndex: Int? private var pendingFlag: ClaudeFlag? + private var flagSource: FlagSource = .claude private var chipContainerHeightConstraint: NSLayoutConstraint? private var lastLayoutWidth: CGFloat = 0 @@ -20,7 +40,8 @@ final class ClaudeArgsField: NSView { set { loadChips(from: newValue) } } - override init(frame frameRect: NSRect) { + init(frame frameRect: NSRect, flagSource: FlagSource = .claude) { + self.flagSource = flagSource super.init(frame: frameRect) setup() } @@ -48,7 +69,7 @@ final class ClaudeArgsField: NSView { // Re-parse chips when CLI flags finish loading (they load async at startup). NotificationCenter.default.addObserver( self, selector: #selector(flagsDidLoad), - name: ClaudeCLIFlags.didLoadNotification, object: nil + name: flagSource.didLoadNotification, object: nil ) } @@ -79,7 +100,7 @@ final class ClaudeArgsField: NSView { // MARK: - Chip Management private func loadChips(from string: String) { - chips = ArgsChip.deserialize(string, knownFlags: ClaudeCLIFlags.shared.flags) + chips = ArgsChip.deserialize(string, knownFlags: flagSource.flags) rebuildChipViews() } @@ -390,7 +411,7 @@ extension ClaudeArgsField: NSTextFieldDelegate, NSTableViewDataSource, NSTableVi let query = text.replacingOccurrences(of: #"^-{0,2}"#, with: "", options: .regularExpression) guard !query.isEmpty else { // Show all flags if user just typed "--" - let allFlags = ClaudeCLIFlags.shared.flags.filter { flag in + let allFlags = flagSource.flags.filter { flag in !chips.contains(where: { $0.flag == flag.longName }) } if allFlags.isEmpty { @@ -410,7 +431,7 @@ extension ClaudeArgsField: NSTextFieldDelegate, NSTableViewDataSource, NSTableVi let addedFlagNames = Set(chips.map(\.flag)) var scored: [(flag: ClaudeFlag, score: Double)] = [] - for flag in ClaudeCLIFlags.shared.flags { + for flag in flagSource.flags { // Skip already-added flags if addedFlagNames.contains(flag.longName) { continue } diff --git a/Sources/Window/DeckardWindowController.swift b/Sources/Window/DeckardWindowController.swift index d6389fb..71bee0a 100644 --- a/Sources/Window/DeckardWindowController.swift +++ b/Sources/Window/DeckardWindowController.swift @@ -88,6 +88,7 @@ class ProjectItem { var tabs: [TabItem] = [] var selectedTabIndex: Int = 0 var defaultArgs: String? + var defaultCodexArgs: String? init(path: String) { self.id = UUID() @@ -574,6 +575,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { if let snapshot = recentlyClosedProjects.first(where: { $0.path == project.path }) { recentlyClosedProjects.removeAll { $0.path == project.path } project.name = snapshot.name + project.defaultArgs = snapshot.defaultArgs + project.defaultCodexArgs = snapshot.defaultCodexArgs for ts in snapshot.tabs { createTabInProject(project, kind: ts.kind, name: ts.name, sessionIdToResume: ts.kind.isAgent ? ts.sessionId : nil, @@ -630,7 +633,9 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { ProjectTabState(id: tab.id.uuidString, name: tab.name, kind: tab.kind, sessionId: tab.sessionId, tmuxSessionName: tab.surface.tmuxSessionName) - } + }, + defaultArgs: project.defaultArgs, + defaultCodexArgs: project.defaultCodexArgs ) recentlyClosedProjects.removeAll { $0.path == project.path } recentlyClosedProjects.append(snapshot) @@ -751,7 +756,6 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { func createTabInProject(_ project: ProjectItem, kind: TabKind, name: String? = nil, sessionIdToResume: String? = nil, forkSession: Bool = false, tmuxSessionToResume: String? = nil, extraArgs: String? = nil) { let surface = TerminalSurface() - let discoveryStart = Date().addingTimeInterval(-2) let tabName: String if let name = name { tabName = name @@ -800,15 +804,19 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // clear hides the echoed command; exec replaces the shell. initialInput = "clear && exec claude\(claudeArgs)\n" } else if kind == .codex { + let resolvedArgs = extraArgs ?? project.defaultCodexArgs ?? UserDefaults.standard.string(forKey: "codexExtraArgs") ?? "" + let codexOptions = resolvedArgs.isEmpty ? "" : " \(resolvedArgs)" var codexArgs = "" if let sessionIdToResume { if forkSession { - codexArgs = " fork \(sessionIdToResume)" + codexArgs = "\(codexOptions) fork \(sessionIdToResume)" } else if ContextMonitor.shared.codexSessionFileURL(sessionId: sessionIdToResume) != nil { - codexArgs = " resume \(sessionIdToResume)" + codexArgs = "\(codexOptions) resume \(sessionIdToResume)" } else { tab.sessionId = nil } + } else { + codexArgs = codexOptions } initialInput = "clear && exec codex\(codexArgs)\n" } else { @@ -834,13 +842,11 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { tabCreationOrder.append(tab.id) if kind == .codex && (tab.sessionId == nil || forkSession) { - var excluded = Set(project.tabs.compactMap { $0.kind == .codex ? $0.sessionId : nil }) - if let sessionIdToResume { excluded.insert(sessionIdToResume) } - scheduleCodexSessionDiscovery(forSurfaceId: tab.id, projectPath: project.path, after: discoveryStart, excluding: excluded) + scheduleCodexSessionDiscovery(forSurfaceId: tab.id, projectPath: project.path) } } - private func scheduleCodexSessionDiscovery(forSurfaceId surfaceId: UUID, projectPath: String, after date: Date, excluding excludedIds: Set) { + private func scheduleCodexSessionDiscovery(forSurfaceId surfaceId: UUID, projectPath: String) { for delay in [1.0, 3.0, 8.0] { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self, @@ -848,17 +854,10 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { tab.kind == .codex, tab.sessionId == nil else { return } - let openCodexIds = Set(self.projects.flatMap { project in - project.tabs.compactMap { other -> String? in - guard other.id != surfaceId, other.kind == .codex else { return nil } - return other.sessionId - } - }) - let excluded = excludedIds.union(openCodexIds) - guard let session = ContextMonitor.shared.latestCodexSession( - forProjectPath: projectPath, - after: date, - excluding: excluded + guard let processId = ProcessMonitor.shared.shellPid(forSurface: surfaceId), + let session = ContextMonitor.shared.codexSessionInfo( + openedByProcessId: processId, + projectPath: projectPath ) else { return } self.updateSessionId(forSurfaceId: surfaceId.uuidString, sessionId: session.sessionId) @@ -898,6 +897,20 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { self.createTabInProject(project, kind: .claude, extraArgs: args) self.finalizeTabCreation(in: project) } + } else if kind == .codex && UserDefaults.standard.bool(forKey: "promptForCodexSessionArgs") { + promptForCodexArgs(for: project) { [weak self] args in + guard let self else { return } + guard let args else { + self.isCreatingTab = false + return + } + guard self.projects.contains(where: { $0 === project }) else { + self.isCreatingTab = false + return + } + self.createTabInProject(project, kind: .codex, extraArgs: args) + self.finalizeTabCreation(in: project) + } } else { createTabInProject(project, kind: kind) finalizeTabCreation(in: project) @@ -941,6 +954,34 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } + private func promptForCodexArgs(for project: ProjectItem, completion: @escaping (String?) -> Void) { + let alert = NSAlert() + alert.messageText = "Codex Arguments" + alert.informativeText = "Arguments passed to this session:" + alert.addButton(withTitle: "Start") + alert.addButton(withTitle: "Cancel") + + let field = ClaudeArgsField( + frame: NSRect(x: 0, y: 0, width: 400, height: 60), + flagSource: .codex + ) + field.stringValue = project.defaultCodexArgs ?? UserDefaults.standard.string(forKey: "codexExtraArgs") ?? "" + alert.accessoryView = field + + guard let window else { + completion(nil) + return + } + + alert.beginSheetModal(for: window) { response in + if response == .alertFirstButtonReturn { + completion(field.stringValue) + } else { + completion(nil) + } + } + } + func closeCurrentTab() { guard let project = currentProject else { return } let idx = project.selectedTabIndex @@ -1132,14 +1173,24 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } private func updateCodexUsage(for tab: TabItem) { - guard let sessionId = tab.sessionId else { + guard let project = currentProject else { quotaView.clear() return } let tabName = tab.name let tabId = tab.id + let initialSessionId = tab.sessionId + let projectPath = project.path DispatchQueue.global(qos: .utility).async { + var sessionId = initialSessionId + if sessionId == nil, + let processId = ProcessMonitor.shared.shellPid(forSurface: tabId) { + sessionId = ContextMonitor.shared.codexSessionInfo( + openedByProcessId: processId, + projectPath: projectPath + )?.sessionId + } let usage = ContextMonitor.shared.getCodexUsage(sessionId: sessionId) DispatchQueue.main.async { [weak self] in guard let self = self else { return } @@ -1151,6 +1202,11 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { return } + if let sessionId, activeTab.sessionId != sessionId { + self.updateSessionId(forSurfaceId: tabId.uuidString, sessionId: sessionId) + return + } + guard let usage else { self.quotaView.clear() return @@ -1167,40 +1223,81 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // MARK: - Process Monitor + private struct CodexBadgePollTarget { + let surfaceId: UUID + let projectPath: String + let sessionId: String? + let processId: pid_t? + } + + private struct CodexBadgePollResult { + let states: [UUID: ContextMonitor.CodexActivityInfo] + let discoveredSessionIds: [UUID: String] + } + private func startProcessMonitor() { processMonitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in guard let self = self else { return } // Build tab infos — order doesn't matter since PID matching // is done via control socket registration, not sorted order. var tabInfos: [ProcessMonitor.TabInfo] = [] + var codexTargets: [CodexBadgePollTarget] = [] for project in self.projects { for tab in project.tabs { tabInfos.append(ProcessMonitor.TabInfo( surfaceId: tab.id, kind: tab.kind, name: tab.name, projectPath: project.path)) + if tab.kind == .codex { + codexTargets.append(CodexBadgePollTarget( + surfaceId: tab.id, + projectPath: project.path, + sessionId: tab.sessionId, + processId: ProcessMonitor.shared.shellPid(forSurface: tab.id))) + } } } DispatchQueue.global(qos: .utility).async { let states = ProcessMonitor.shared.poll(tabs: tabInfos) - let codexStates = self.pollCodexBadgeStates() + let codexResult = self.pollCodexBadgeStates(for: codexTargets) DispatchQueue.main.async { + self.applyCodexSessionDiscoveries(codexResult.discoveredSessionIds) self.applyTerminalBadgeStates(states) - self.applyCodexBadgeStates(codexStates) + self.applyCodexBadgeStates(codexResult.states) } } } } - private func pollCodexBadgeStates() -> [UUID: ContextMonitor.CodexActivityInfo] { + private func pollCodexBadgeStates(for targets: [CodexBadgePollTarget]) -> CodexBadgePollResult { var states: [UUID: ContextMonitor.CodexActivityInfo] = [:] - for project in projects { - for tab in project.tabs where tab.kind == .codex { - guard let sessionId = tab.sessionId, - let state = ContextMonitor.shared.codexActivityInfo(sessionId: sessionId) else { continue } - states[tab.id] = state + var discoveredSessionIds: [UUID: String] = [:] + + for target in targets { + var sessionId = target.sessionId + if sessionId == nil, + let processId = target.processId, + let session = ContextMonitor.shared.codexSessionInfo( + openedByProcessId: processId, + projectPath: target.projectPath + ), + !discoveredSessionIds.values.contains(session.sessionId) { + sessionId = session.sessionId + discoveredSessionIds[target.surfaceId] = session.sessionId } + + guard let sessionId, + let state = ContextMonitor.shared.codexActivityInfo(sessionId: sessionId) else { continue } + states[target.surfaceId] = state + } + + return CodexBadgePollResult(states: states, discoveredSessionIds: discoveredSessionIds) + } + + private func applyCodexSessionDiscoveries(_ discoveredSessionIds: [UUID: String]) { + guard !discoveredSessionIds.isEmpty else { return } + for (surfaceId, sessionId) in discoveredSessionIds { + updateSessionId(forSurfaceId: surfaceId.uuidString, sessionId: sessionId) } - return states } private func applyCodexBadgeStates(_ states: [UUID: ContextMonitor.CodexActivityInfo]) { @@ -1490,7 +1587,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { tmuxSessionName: tab.surface.tmuxSessionName ) }, - defaultArgs: project.defaultArgs + defaultArgs: project.defaultArgs, + defaultCodexArgs: project.defaultCodexArgs ) } @@ -1543,6 +1641,32 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } let selectedIdx = min(max(state.selectedTabIndex, 0), projectStates.count - 1) + var codexRestoreCandidatesByPath: [String: [String]] = [:] + var usedCodexSessionIds = Set(projectStates.flatMap { project in + project.tabs.compactMap { tab in + tab.kind == .codex ? tab.sessionId : nil + } + }) + + func recoverCodexSessionId(for projectPath: String, tabName: String) -> String? { + let resolvedPath = (projectPath as NSString).resolvingSymlinksInPath + if codexRestoreCandidatesByPath[resolvedPath] == nil { + codexRestoreCandidatesByPath[resolvedPath] = ContextMonitor.shared + .listCodexSessions(forProjectPath: resolvedPath) + .map(\.sessionId) + } + + while var candidates = codexRestoreCandidatesByPath[resolvedPath], !candidates.isEmpty { + let sessionId = candidates.removeFirst() + codexRestoreCandidatesByPath[resolvedPath] = candidates + guard usedCodexSessionIds.insert(sessionId).inserted else { continue } + DiagnosticLog.shared.log("restore", + "recovered missing Codex session id for \(tabName)@\(resolvedPath): \(sessionId)") + return sessionId + } + + return nil + } // Phase 1: Create the active project's active tab immediately so the user // sees a working terminal right away. Collect remaining tabs for Phase 2. @@ -1552,17 +1676,23 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { let project = ProjectItem(path: ps.path) project.name = ps.name project.defaultArgs = ps.defaultArgs + project.defaultCodexArgs = ps.defaultCodexArgs let selTab = min(max(ps.selectedTabIndex, 0), max(ps.tabs.count - 1, 0)) for (t, ts) in ps.tabs.enumerated() { + var restoredTab = ts + if restoredTab.kind == .codex, restoredTab.sessionId == nil { + restoredTab.sessionId = recoverCodexSessionId(for: ps.path, tabName: restoredTab.name) + } + if i == selectedIdx && t == selTab { // Create the active tab's surface synchronously - createTabInProject(project, kind: ts.kind, name: ts.name, - sessionIdToResume: ts.kind.isAgent ? ts.sessionId : nil, - tmuxSessionToResume: ts.tmuxSessionName) + createTabInProject(project, kind: restoredTab.kind, name: restoredTab.name, + sessionIdToResume: restoredTab.kind.isAgent ? restoredTab.sessionId : nil, + tmuxSessionToResume: restoredTab.tmuxSessionName) } else { - pending.append((project: project, tab: ts, originalIndex: t)) + pending.append((project: project, tab: restoredTab, originalIndex: t)) } } diff --git a/Sources/Window/SettingsWindow.swift b/Sources/Window/SettingsWindow.swift index 9edf866..4906742 100644 --- a/Sources/Window/SettingsWindow.swift +++ b/Sources/Window/SettingsWindow.swift @@ -6,6 +6,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie private enum Pane: String, CaseIterable { case general = "General" + case agents = "Agents" case theme = "Theme" case terminal = "Terminal" case shortcuts = "Shortcuts" @@ -14,6 +15,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie var icon: NSImage { switch self { case .general: return NSImage(systemSymbolName: "gearshape", accessibilityDescription: "General")! + case .agents: return NSImage(systemSymbolName: "slider.horizontal.3", accessibilityDescription: "Agents")! case .theme: return NSImage(systemSymbolName: "paintpalette", accessibilityDescription: "Theme")! case .terminal: return NSImage(systemSymbolName: "terminal", accessibilityDescription: "Terminal")! case .shortcuts: return NSImage(systemSymbolName: "keyboard", accessibilityDescription: "Shortcuts")! @@ -104,6 +106,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie let newView: NSView switch pane { case .general: newView = makeGeneralPane() + case .agents: newView = makeAgentsPane() case .theme: newView = makeThemePane() case .terminal: newView = makeTerminalPane() case .shortcuts: newView = makeShortcutsPane() @@ -129,41 +132,6 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie grid.rowSpacing = 6 grid.columnSpacing = 8 - // Extra arguments - let extraArgsLabel = NSTextField(labelWithString: "Default Claude arguments:") - extraArgsLabel.alignment = .right - - let extraArgsField = ClaudeArgsField(frame: NSRect(x: 0, y: 0, width: 400, height: 60)) - extraArgsField.stringValue = UserDefaults.standard.string(forKey: "claudeExtraArgs") ?? "" - extraArgsField.translatesAutoresizingMaskIntoConstraints = false - extraArgsField.heightAnchor.constraint(greaterThanOrEqualToConstant: 36).isActive = true - extraArgsField.onChange = { newValue in - UserDefaults.standard.set(newValue, forKey: "claudeExtraArgs") - } - - grid.addRow(with: [extraArgsLabel, extraArgsField]) - - let extraArgsHelp = NSTextField(labelWithString: "Arguments passed to every new Claude Code session. Can be overridden per project.") - extraArgsHelp.font = .systemFont(ofSize: 11) - extraArgsHelp.textColor = .secondaryLabelColor - grid.addRow(with: [NSGridCell.emptyContentView, extraArgsHelp]) - - // Per-session args checkbox - let perSessionCheck = NSButton(checkboxWithTitle: "Customize arguments per session", target: self, action: #selector(perSessionArgsToggled(_:))) - perSessionCheck.state = UserDefaults.standard.bool(forKey: "promptForSessionArgs") ? .on : .off - grid.addRow(with: [NSGridCell.emptyContentView, perSessionCheck]) - - let perSessionHelp = NSTextField(labelWithString: "Show a dialog to set arguments when creating a new Claude tab.") - perSessionHelp.font = .systemFont(ofSize: 11) - perSessionHelp.textColor = .secondaryLabelColor - grid.addRow(with: [NSGridCell.emptyContentView, perSessionHelp]) - - // Spacer - let spacer = NSView() - spacer.translatesAutoresizingMaskIntoConstraints = false - spacer.heightAnchor.constraint(equalToConstant: 8).isActive = true - grid.addRow(with: [NSGridCell.emptyContentView, spacer]) - // Default tabs let tabConfigLabel = NSTextField(labelWithString: "Default tabs:") tabConfigLabel.alignment = .right @@ -198,10 +166,133 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie return pane } + // MARK: - Agents Pane + + private func makeAgentsPane() -> NSView { + let pane = NSView() + + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .width + stack.spacing = 18 + stack.translatesAutoresizingMaskIntoConstraints = false + + stack.addArrangedSubview(makeAgentDefaultsSection( + title: "Claude Code", + fieldLabel: "Default arguments:", + defaultsKey: "claudeExtraArgs", + flagSource: .claude, + help: "Arguments passed to every new Claude Code session. Can be overridden per project.", + checkboxTitle: "Customize Claude arguments per session", + checkboxHelp: "Show a dialog to set arguments when creating a new Claude tab.", + checkboxDefaultsKey: "promptForSessionArgs", + checkboxAction: #selector(perSessionArgsToggled(_:)) + )) + + stack.addArrangedSubview(makeAgentDefaultsSection( + title: "Codex", + fieldLabel: "Default parameters:", + defaultsKey: "codexExtraArgs", + flagSource: .codex, + help: "Codex CLI parameters passed to every new Codex session, such as model, effort, sandbox, and approval settings. Can be overridden per project.", + checkboxTitle: "Customize Codex parameters per session", + checkboxHelp: "Show a dialog to set parameters when creating a new Codex tab.", + checkboxDefaultsKey: "promptForCodexSessionArgs", + checkboxAction: #selector(codexPerSessionArgsToggled(_:)) + )) + + pane.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: pane.topAnchor, constant: 20), + stack.leadingAnchor.constraint(equalTo: pane.leadingAnchor, constant: 40), + stack.trailingAnchor.constraint(equalTo: pane.trailingAnchor, constant: -40), + ]) + + return pane + } + + private func makeAgentDefaultsSection( + title: String, + fieldLabel: String, + defaultsKey: String, + flagSource: ClaudeArgsField.FlagSource, + help: String, + checkboxTitle: String, + checkboxHelp: String, + checkboxDefaultsKey: String, + checkboxAction: Selector + ) -> NSView { + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = NSTextField(labelWithString: title) + titleLabel.font = .systemFont(ofSize: 13, weight: .semibold) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + let grid = NSGridView(numberOfColumns: 2, rows: 0) + grid.translatesAutoresizingMaskIntoConstraints = false + grid.column(at: 0).xPlacement = .trailing + grid.column(at: 0).width = 150 + grid.column(at: 1).xPlacement = .fill + grid.rowSpacing = 6 + grid.columnSpacing = 8 + + let argsLabel = NSTextField(labelWithString: fieldLabel) + argsLabel.alignment = .right + + let argsField = ClaudeArgsField( + frame: NSRect(x: 0, y: 0, width: 400, height: 60), + flagSource: flagSource + ) + argsField.stringValue = UserDefaults.standard.string(forKey: defaultsKey) ?? "" + argsField.translatesAutoresizingMaskIntoConstraints = false + argsField.heightAnchor.constraint(greaterThanOrEqualToConstant: 42).isActive = true + argsField.onChange = { newValue in + UserDefaults.standard.set(newValue, forKey: defaultsKey) + } + + grid.addRow(with: [argsLabel, argsField]) + + let helpLabel = NSTextField(labelWithString: help) + helpLabel.font = .systemFont(ofSize: 11) + helpLabel.textColor = .secondaryLabelColor + helpLabel.lineBreakMode = .byWordWrapping + helpLabel.maximumNumberOfLines = 2 + helpLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + grid.addRow(with: [NSGridCell.emptyContentView, helpLabel]) + + let perSessionCheck = NSButton(checkboxWithTitle: checkboxTitle, target: self, action: checkboxAction) + perSessionCheck.state = UserDefaults.standard.bool(forKey: checkboxDefaultsKey) ? .on : .off + grid.addRow(with: [NSGridCell.emptyContentView, perSessionCheck]) + + let perSessionHelp = NSTextField(labelWithString: checkboxHelp) + perSessionHelp.font = .systemFont(ofSize: 11) + perSessionHelp.textColor = .secondaryLabelColor + grid.addRow(with: [NSGridCell.emptyContentView, perSessionHelp]) + + container.addSubview(titleLabel) + container.addSubview(grid) + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: container.topAnchor), + titleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor), + + grid.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), + grid.leadingAnchor.constraint(equalTo: container.leadingAnchor), + grid.trailingAnchor.constraint(equalTo: container.trailingAnchor), + grid.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + return container + } + @objc private func perSessionArgsToggled(_ sender: NSButton) { UserDefaults.standard.set(sender.state == .on, forKey: "promptForSessionArgs") } + @objc private func codexPerSessionArgsToggled(_ sender: NSButton) { + UserDefaults.standard.set(sender.state == .on, forKey: "promptForCodexSessionArgs") + } + @objc private func vibrancyToggled(_ sender: NSButton) { let enabled = sender.state == .on UserDefaults.standard.set(enabled, forKey: "sidebarVibrancy") diff --git a/Sources/Window/SidebarController.swift b/Sources/Window/SidebarController.swift index 5eaafa1..e7864a8 100644 --- a/Sources/Window/SidebarController.swift +++ b/Sources/Window/SidebarController.swift @@ -589,6 +589,11 @@ extension DeckardWindowController { defaultArgsItem.representedObject = project menu.addItem(defaultArgsItem) + let defaultCodexArgsItem = NSMenuItem(title: "Default Codex Arguments\u{2026}", action: #selector(defaultCodexArgsMenuAction(_:)), keyEquivalent: "") + defaultCodexArgsItem.target = self + defaultCodexArgsItem.representedObject = project + menu.addItem(defaultCodexArgsItem) + menu.addItem(.separator()) // Folder options @@ -720,6 +725,31 @@ extension DeckardWindowController { } } + @objc func defaultCodexArgsMenuAction(_ sender: NSMenuItem) { + guard let project = sender.representedObject as? ProjectItem, + let window else { return } + + let alert = NSAlert() + alert.messageText = "Default Codex Arguments for \(project.name)" + alert.informativeText = "These arguments will be used for new Codex tabs in this project, overriding global defaults. Leave empty to clear." + alert.addButton(withTitle: "Save") + alert.addButton(withTitle: "Cancel") + + let field = ClaudeArgsField( + frame: NSRect(x: 0, y: 0, width: 400, height: 60), + flagSource: .codex + ) + field.stringValue = project.defaultCodexArgs ?? "" + alert.accessoryView = field + + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertFirstButtonReturn else { return } + let value = field.stringValue.trimmingCharacters(in: .whitespaces) + project.defaultCodexArgs = value.isEmpty ? nil : value + self?.saveState() + } + } + // MARK: - Sidebar Selection func updateSidebarSelection() { diff --git a/Tests/ClaudeCLIFlagsTests.swift b/Tests/ClaudeCLIFlagsTests.swift index a215ec7..f4262b2 100644 --- a/Tests/ClaudeCLIFlagsTests.swift +++ b/Tests/ClaudeCLIFlagsTests.swift @@ -155,3 +155,97 @@ final class ArgsSerializationTests: XCTestCase { XCTAssertTrue(chips.isEmpty) } } + +final class CodexCLIFlagsTests: XCTestCase { + + private let sampleHelp = """ + Options: + -c, --config + Override a configuration value that would otherwise be loaded from `~/.codex/config.toml`. + + -m, --model + Model the agent should use + + -s, --sandbox + Select the sandbox policy to use when executing model-generated shell commands + + [possible values: read-only, workspace-write, danger-full-access] + + -a, --ask-for-approval + Configure when the model requires human approval before executing a command + + Possible values: + - untrusted: Only run trusted commands without asking + - on-request: The model decides when to ask + - never: Never ask for user approval + + --local-provider + Specify which local provider to use (lmstudio or ollama). If not specified with --oss, + will use config default or show selection + + -C, --cd

+ Tell the agent to use the specified directory as its working root + + --full-auto + Convenience alias for low-friction sandboxed automatic execution + + -h, --help + Print help + + -V, --version + Print version + """ + + func testParsesMultilineCodexOptions() { + let flags = CodexCLIFlags.parse(helpOutput: sampleHelp) + + let sandbox = flags.first { $0.longName == "--sandbox" } + XCTAssertEqual(sandbox?.shortName, "-s") + guard case .enumeration(let values) = sandbox?.valueType else { + XCTFail("Expected sandbox enum") + return + } + XCTAssertEqual(values, ["read-only", "workspace-write", "danger-full-access"]) + } + + func testParsesCodexBulletPossibleValues() { + let flags = CodexCLIFlags.parse(helpOutput: sampleHelp) + + let approval = flags.first { $0.longName == "--ask-for-approval" } + guard case .enumeration(let values) = approval?.valueType else { + XCTFail("Expected approval enum") + return + } + XCTAssertEqual(values, ["untrusted", "on-request", "never"]) + } + + func testParsesCodexFreeTextAndBooleanFlags() { + let flags = CodexCLIFlags.parse(helpOutput: sampleHelp) + + let model = flags.first { $0.longName == "--model" } + let fullAuto = flags.first { $0.longName == "--full-auto" } + + XCTAssertEqual(model?.valueType, .freeText) + XCTAssertEqual(model?.valuePlaceholder, "") + XCTAssertEqual(fullAuto?.valueType, .boolean) + } + + func testParsesCodexParentheticalChoices() { + let flags = CodexCLIFlags.parse(helpOutput: sampleHelp) + + let provider = flags.first { $0.longName == "--local-provider" } + guard case .enumeration(let values) = provider?.valueType else { + XCTFail("Expected provider enum") + return + } + XCTAssertEqual(values, ["lmstudio", "ollama"]) + } + + func testCodexBlocklistExcludesDeckardManagedFlags() { + let longNames = CodexCLIFlags.parse(helpOutput: sampleHelp).map(\.longName) + + XCTAssertFalse(longNames.contains("--cd")) + XCTAssertFalse(longNames.contains("--help")) + XCTAssertFalse(longNames.contains("--version")) + } +} diff --git a/Tests/ContextMonitorTests.swift b/Tests/ContextMonitorTests.swift index b62b0ac..168a251 100644 --- a/Tests/ContextMonitorTests.swift +++ b/Tests/ContextMonitorTests.swift @@ -138,6 +138,104 @@ final class ContextMonitorTests: XCTestCase { XCTAssertEqual(usage?.cacheReadTokens, 30_000) } + // MARK: - Codex activity parsing + + func testParseCodexActivityInfoReportsStartedTaskBusy() { + let content = """ + {"timestamp":"2026-04-28T19:55:08.554Z","type":"event_msg","payload":{"type":"task_started"}} + {"timestamp":"2026-04-28T19:55:08.763Z","type":"event_msg","payload":{"type":"token_count"}} + """ + + let activity = ContextMonitor.shared.parseCodexActivityInfo(from: content) + + XCTAssertEqual(activity?.isBusy, true) + XCTAssertEqual(activity?.isError, false) + } + + func testParseCodexActivityInfoTreatsTurnAbortedAsIdle() { + let content = """ + {"timestamp":"2026-04-28T19:55:08.554Z","type":"event_msg","payload":{"type":"task_started"}} + {"timestamp":"2026-04-28T19:55:10.124Z","type":"event_msg","payload":{"type":"turn_aborted"}} + """ + + let activity = ContextMonitor.shared.parseCodexActivityInfo(from: content) + + XCTAssertEqual(activity?.isBusy, false) + XCTAssertEqual(activity?.isError, false) + } + + func testParseCodexActivityInfoTreatsErrorAsError() { + let content = """ + {"timestamp":"2026-04-28T19:55:08.554Z","type":"event_msg","payload":{"type":"task_started"}} + {"timestamp":"2026-04-28T19:55:10.124Z","type":"event_msg","payload":{"type":"error"}} + """ + + let activity = ContextMonitor.shared.parseCodexActivityInfo(from: content) + + XCTAssertEqual(activity?.isBusy, false) + XCTAssertEqual(activity?.isError, true) + } + + func testParseCodexUsageReadsQuotaAndTokenRateButDoesNotReportContext() throws { + let now = try XCTUnwrap(codexDate("2026-04-28T12:42:05.267Z")) + let content = """ + {"timestamp":"2026-04-28T12:41:05.267Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":48536,"cached_input_tokens":23296,"output_tokens":940,"reasoning_output_tokens":241,"total_tokens":49476},"last_token_usage":{"input_tokens":35145,"cached_input_tokens":11648,"output_tokens":419,"reasoning_output_tokens":139,"total_tokens":35564},"model_context_window":258400},"rate_limits":{"primary":{"used_percent":12.5,"window_minutes":300,"resets_at":1777398056},"secondary":{"used_percent":3.25,"window_minutes":10080,"resets_at":1777984856}}}} + """ + + let usage = ContextMonitor.shared.parseCodexUsage(from: content, now: now) + + XCTAssertNil(usage?.context) + XCTAssertEqual(usage?.quotaSnapshot?.fiveHourUsed, 12.5) + XCTAssertEqual(usage?.quotaSnapshot?.sevenDayUsed, 3.25) + XCTAssertEqual(try XCTUnwrap(usage?.tokenRate?.tokensPerMinute), 558, accuracy: 0.01) + XCTAssertEqual(usage?.sparklineData, [558.0]) + } + + func testParseCodexUsageAllowsRateLimitsWhenInfoIsNull() throws { + let now = try XCTUnwrap(codexDate("2026-04-28T12:42:05.267Z")) + let content = """ + {"timestamp":"2026-04-28T12:41:05.267Z","type":"event_msg","payload":{"type":"token_count","info":null,"rate_limits":{"primary":{"used_percent":0.0,"window_minutes":300,"resets_at":1777398056},"secondary":{"used_percent":0.0,"window_minutes":10080,"resets_at":1777984856}}}} + """ + + let usage = ContextMonitor.shared.parseCodexUsage(from: content, now: now) + + XCTAssertNil(usage?.context) + XCTAssertEqual(usage?.quotaSnapshot?.fiveHourUsed, 0.0) + XCTAssertEqual(usage?.quotaSnapshot?.sevenDayUsed, 0.0) + XCTAssertNotNil(usage?.quotaSnapshot?.fiveHourResetsAt) + XCTAssertNil(usage?.tokenRate) + } + + func testParseCodexAppServerQuotaResponseReadsCamelCaseRateLimits() throws { + let now = try XCTUnwrap(codexDate("2026-04-28T12:42:05.267Z")) + let data = try XCTUnwrap(""" + {"id":2,"result":{"rateLimits":{"limitId":"codex","primary":{"usedPercent":12.5,"windowDurationMins":300,"resetsAt":1777398056},"secondary":{"usedPercent":3.25,"windowDurationMins":10080,"resetsAt":1777984856},"planType":"pro"},"rateLimitsByLimitId":{"codex":{"limitId":"codex","primary":{"usedPercent":25.0,"windowDurationMins":300,"resetsAt":1777399056},"secondary":{"usedPercent":6.5,"windowDurationMins":10080,"resetsAt":1777985856},"planType":"pro"}}}} + """.data(using: .utf8)) + let response = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + let snapshot = ContextMonitor.shared.parseCodexAppServerQuotaResponse(response, now: now) + + XCTAssertEqual(snapshot?.fiveHourUsed, 25.0) + XCTAssertEqual(snapshot?.sevenDayUsed, 6.5) + XCTAssertEqual(snapshot?.lastUpdated, now) + XCTAssertEqual(snapshot?.fiveHourResetsAt?.timeIntervalSince1970, 1777399056) + XCTAssertEqual(snapshot?.sevenDayResetsAt?.timeIntervalSince1970, 1777985856) + } + + func testParseCodexAppServerQuotaResponseAllowsNullSecondaryWindow() throws { + let now = try XCTUnwrap(codexDate("2026-04-28T12:42:05.267Z")) + let data = try XCTUnwrap(""" + {"id":2,"result":{"rateLimits":{"limitId":"codex","primary":{"usedPercent":17.75,"windowDurationMins":300,"resetsAt":1777398056},"secondary":null,"planType":"pro"}}} + """.data(using: .utf8)) + let response = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + let snapshot = ContextMonitor.shared.parseCodexAppServerQuotaResponse(response, now: now) + + XCTAssertEqual(snapshot?.fiveHourUsed, 17.75) + XCTAssertEqual(snapshot?.sevenDayUsed, 0) + XCTAssertNil(snapshot?.sevenDayResetsAt) + } + func testParseUsageNoUsageLines() { let content = """ {"type":"user","message":{"content":"hello"}} @@ -304,6 +402,12 @@ final class ContextMonitorTests: XCTestCase { "{\"type\":\"assistant\",\"message\":{\"model\":\"\(model)\",\"usage\":{\"input_tokens\":\(input),\"cache_read_input_tokens\":\(cacheRead)}}}" } + private func codexDate(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: value) + } + private func makeTempJSONL(lines: [String]) -> String { let path = NSTemporaryDirectory() + "deckard-test-\(UUID().uuidString).jsonl" let content = lines.joined(separator: "\n") + (lines.isEmpty ? "" : "\n") diff --git a/Tests/SessionStateTests.swift b/Tests/SessionStateTests.swift index b4c31b5..2dac0af 100644 --- a/Tests/SessionStateTests.swift +++ b/Tests/SessionStateTests.swift @@ -19,7 +19,9 @@ final class SessionStateTests: XCTestCase { tabs: [ ProjectTabState(id: "tab-1", name: "Claude", isClaude: true, sessionId: "sess-1"), ProjectTabState(id: "tab-2", name: "Terminal", isClaude: false, sessionId: nil), - ] + ], + defaultArgs: "--permission-mode acceptEdits", + defaultCodexArgs: "--ask-for-approval never --sandbox workspace-write" ) ] @@ -35,6 +37,8 @@ final class SessionStateTests: XCTestCase { XCTAssertEqual(decoded.projects?[0].tabs[0].isClaude, true) XCTAssertEqual(decoded.projects?[0].tabs[0].sessionId, "sess-1") XCTAssertNil(decoded.projects?[0].tabs[1].sessionId) + XCTAssertEqual(decoded.projects?[0].defaultArgs, "--permission-mode acceptEdits") + XCTAssertEqual(decoded.projects?[0].defaultCodexArgs, "--ask-for-approval never --sandbox workspace-write") } func testEmptyStateRoundtrip() throws { From f37d5d5abebcc4f92c3f6f055f9a92dd003f9074 Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Wed, 29 Apr 2026 10:10:49 +0200 Subject: [PATCH 06/15] Fix badge pulse animation lifecycle --- Sources/Window/SidebarViews.swift | 49 ++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/Sources/Window/SidebarViews.swift b/Sources/Window/SidebarViews.swift index d651a78..aca96de 100644 --- a/Sources/Window/SidebarViews.swift +++ b/Sources/Window/SidebarViews.swift @@ -127,15 +127,12 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { } static func addPulseAnimation(to view: NSView) { + if let badgeView = view as? BadgeShapeView { + badgeView.setPulseAnimationEnabled(true) + return + } guard let layer = view.layer else { return } - let anim = CABasicAnimation(keyPath: "opacity") - anim.fromValue = 1.0 - anim.toValue = 0.3 - anim.duration = 1.2 - anim.autoreverses = true - anim.repeatCount = .infinity - anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - layer.add(anim, forKey: "pulse") + layer.add(BadgeShapeView.makePulseAnimation(), forKey: BadgeShapeView.pulseAnimationKey) } static func tooltipForBadge(_ state: TabItem.BadgeState, activity: ProcessMonitor.ActivityInfo? = nil) -> String { @@ -889,7 +886,10 @@ class AddTabButton: NSView { /// Draws a badge dot using a CAShapeLayer for customizable shapes. class BadgeShapeView: NSView { + static let pulseAnimationKey = "pulse" + private let shapeLayer = CAShapeLayer() + private var isPulseAnimationEnabled = false init(shape: TabItem.BadgeShape, color: NSColor, size: CGFloat = 7) { super.init(frame: NSRect(x: 0, y: 0, width: size, height: size)) @@ -905,6 +905,23 @@ class BadgeShapeView: NSView { required init?(coder: NSCoder) { fatalError() } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if isPulseAnimationEnabled { + startPulseAnimation() + } + } + + func setPulseAnimationEnabled(_ enabled: Bool) { + isPulseAnimationEnabled = enabled + if enabled { + startPulseAnimation() + } else { + shapeLayer.removeAnimation(forKey: Self.pulseAnimationKey) + shapeLayer.opacity = 1.0 + } + } + func updateAppearance(shape: TabItem.BadgeShape, color: NSColor, size: CGFloat = 7) { let rect = CGRect(x: 0, y: 0, width: size, height: size) shapeLayer.path = Self.path(for: shape, in: rect) @@ -912,6 +929,22 @@ class BadgeShapeView: NSView { shapeLayer.frame = rect } + private func startPulseAnimation() { + shapeLayer.removeAnimation(forKey: Self.pulseAnimationKey) + shapeLayer.add(Self.makePulseAnimation(), forKey: Self.pulseAnimationKey) + } + + static func makePulseAnimation() -> CABasicAnimation { + let anim = CABasicAnimation(keyPath: "opacity") + anim.fromValue = 1.0 + anim.toValue = 0.3 + anim.duration = 1.2 + anim.autoreverses = true + anim.repeatCount = .infinity + anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + return anim + } + static func path(for shape: TabItem.BadgeShape, in rect: CGRect) -> CGPath { let w = rect.width let cx = rect.midX From 9225d08272e1b31870532cd8d4aaf1635e140eea Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Wed, 29 Apr 2026 10:12:41 +0200 Subject: [PATCH 07/15] Use timer-driven badge pulse animation --- Sources/Window/SidebarViews.swift | 43 ++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/Sources/Window/SidebarViews.swift b/Sources/Window/SidebarViews.swift index aca96de..e735566 100644 --- a/Sources/Window/SidebarViews.swift +++ b/Sources/Window/SidebarViews.swift @@ -890,6 +890,8 @@ class BadgeShapeView: NSView { private let shapeLayer = CAShapeLayer() private var isPulseAnimationEnabled = false + private var pulseTimer: Timer? + private var pulseStartTime: TimeInterval = 0 init(shape: TabItem.BadgeShape, color: NSColor, size: CGFloat = 7) { super.init(frame: NSRect(x: 0, y: 0, width: size, height: size)) @@ -905,9 +907,15 @@ class BadgeShapeView: NSView { required init?(coder: NSCoder) { fatalError() } + deinit { + pulseTimer?.invalidate() + } + override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - if isPulseAnimationEnabled { + if window == nil { + stopPulseAnimation(resetOpacity: false) + } else if isPulseAnimationEnabled { startPulseAnimation() } } @@ -917,8 +925,7 @@ class BadgeShapeView: NSView { if enabled { startPulseAnimation() } else { - shapeLayer.removeAnimation(forKey: Self.pulseAnimationKey) - shapeLayer.opacity = 1.0 + stopPulseAnimation(resetOpacity: true) } } @@ -930,8 +937,36 @@ class BadgeShapeView: NSView { } private func startPulseAnimation() { + stopPulseAnimation(resetOpacity: false) + pulseStartTime = CACurrentMediaTime() + let timer = Timer(timeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in + self?.updatePulseOpacity() + } + RunLoop.main.add(timer, forMode: .common) + pulseTimer = timer + updatePulseOpacity() + } + + private func stopPulseAnimation(resetOpacity: Bool) { + pulseTimer?.invalidate() + pulseTimer = nil shapeLayer.removeAnimation(forKey: Self.pulseAnimationKey) - shapeLayer.add(Self.makePulseAnimation(), forKey: Self.pulseAnimationKey) + if resetOpacity { + alphaValue = 1.0 + shapeLayer.opacity = 1.0 + } + } + + private func updatePulseOpacity() { + let halfCycle: TimeInterval = 1.2 + let cycle = halfCycle * 2 + let elapsed = CACurrentMediaTime() - pulseStartTime + let position = elapsed.truncatingRemainder(dividingBy: cycle) + let rawProgress = position <= halfCycle + ? position / halfCycle + : (cycle - position) / halfCycle + let eased = 0.5 - 0.5 * cos(rawProgress * .pi) + alphaValue = CGFloat(1.0 - (0.7 * eased)) } static func makePulseAnimation() -> CABasicAnimation { From aeb1966cdd7e13fee9d14680e5fb84fb9787b143 Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Wed, 29 Apr 2026 10:30:29 +0200 Subject: [PATCH 08/15] Drive badge pulse through shape opacity --- Sources/Window/SidebarViews.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/Window/SidebarViews.swift b/Sources/Window/SidebarViews.swift index e735566..11d30f3 100644 --- a/Sources/Window/SidebarViews.swift +++ b/Sources/Window/SidebarViews.swift @@ -953,6 +953,7 @@ class BadgeShapeView: NSView { shapeLayer.removeAnimation(forKey: Self.pulseAnimationKey) if resetOpacity { alphaValue = 1.0 + layer?.opacity = 1.0 shapeLayer.opacity = 1.0 } } @@ -966,7 +967,10 @@ class BadgeShapeView: NSView { ? position / halfCycle : (cycle - position) / halfCycle let eased = 0.5 - 0.5 * cos(rawProgress * .pi) - alphaValue = CGFloat(1.0 - (0.7 * eased)) + let opacity = Float(1.0 - (0.7 * eased)) + alphaValue = CGFloat(opacity) + layer?.opacity = opacity + shapeLayer.opacity = opacity } static func makePulseAnimation() -> CABasicAnimation { From 8f5e7bf32c454b20e41a02778947935cf90fd682 Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Wed, 29 Apr 2026 10:41:22 +0200 Subject: [PATCH 09/15] Fix agent settings lint violation --- Sources/Window/SettingsWindow.swift | 54 ++++++++++++++++------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/Sources/Window/SettingsWindow.swift b/Sources/Window/SettingsWindow.swift index 4906742..9cbae22 100644 --- a/Sources/Window/SettingsWindow.swift +++ b/Sources/Window/SettingsWindow.swift @@ -168,6 +168,18 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie // MARK: - Agents Pane + private struct AgentDefaultsSection { + let title: String + let fieldLabel: String + let defaultsKey: String + let flagSource: ClaudeArgsField.FlagSource + let help: String + let checkboxTitle: String + let checkboxHelp: String + let checkboxDefaultsKey: String + let checkboxAction: Selector + } + private func makeAgentsPane() -> NSView { let pane = NSView() @@ -177,7 +189,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie stack.spacing = 18 stack.translatesAutoresizingMaskIntoConstraints = false - stack.addArrangedSubview(makeAgentDefaultsSection( + stack.addArrangedSubview(makeAgentDefaultsSection(AgentDefaultsSection( title: "Claude Code", fieldLabel: "Default arguments:", defaultsKey: "claudeExtraArgs", @@ -187,9 +199,9 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie checkboxHelp: "Show a dialog to set arguments when creating a new Claude tab.", checkboxDefaultsKey: "promptForSessionArgs", checkboxAction: #selector(perSessionArgsToggled(_:)) - )) + ))) - stack.addArrangedSubview(makeAgentDefaultsSection( + stack.addArrangedSubview(makeAgentDefaultsSection(AgentDefaultsSection( title: "Codex", fieldLabel: "Default parameters:", defaultsKey: "codexExtraArgs", @@ -199,7 +211,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie checkboxHelp: "Show a dialog to set parameters when creating a new Codex tab.", checkboxDefaultsKey: "promptForCodexSessionArgs", checkboxAction: #selector(codexPerSessionArgsToggled(_:)) - )) + ))) pane.addSubview(stack) NSLayoutConstraint.activate([ @@ -211,21 +223,11 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie return pane } - private func makeAgentDefaultsSection( - title: String, - fieldLabel: String, - defaultsKey: String, - flagSource: ClaudeArgsField.FlagSource, - help: String, - checkboxTitle: String, - checkboxHelp: String, - checkboxDefaultsKey: String, - checkboxAction: Selector - ) -> NSView { + private func makeAgentDefaultsSection(_ section: AgentDefaultsSection) -> NSView { let container = NSView() container.translatesAutoresizingMaskIntoConstraints = false - let titleLabel = NSTextField(labelWithString: title) + let titleLabel = NSTextField(labelWithString: section.title) titleLabel.font = .systemFont(ofSize: 13, weight: .semibold) titleLabel.translatesAutoresizingMaskIntoConstraints = false @@ -237,23 +239,23 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie grid.rowSpacing = 6 grid.columnSpacing = 8 - let argsLabel = NSTextField(labelWithString: fieldLabel) + let argsLabel = NSTextField(labelWithString: section.fieldLabel) argsLabel.alignment = .right let argsField = ClaudeArgsField( frame: NSRect(x: 0, y: 0, width: 400, height: 60), - flagSource: flagSource + flagSource: section.flagSource ) - argsField.stringValue = UserDefaults.standard.string(forKey: defaultsKey) ?? "" + argsField.stringValue = UserDefaults.standard.string(forKey: section.defaultsKey) ?? "" argsField.translatesAutoresizingMaskIntoConstraints = false argsField.heightAnchor.constraint(greaterThanOrEqualToConstant: 42).isActive = true argsField.onChange = { newValue in - UserDefaults.standard.set(newValue, forKey: defaultsKey) + UserDefaults.standard.set(newValue, forKey: section.defaultsKey) } grid.addRow(with: [argsLabel, argsField]) - let helpLabel = NSTextField(labelWithString: help) + let helpLabel = NSTextField(labelWithString: section.help) helpLabel.font = .systemFont(ofSize: 11) helpLabel.textColor = .secondaryLabelColor helpLabel.lineBreakMode = .byWordWrapping @@ -261,11 +263,15 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie helpLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) grid.addRow(with: [NSGridCell.emptyContentView, helpLabel]) - let perSessionCheck = NSButton(checkboxWithTitle: checkboxTitle, target: self, action: checkboxAction) - perSessionCheck.state = UserDefaults.standard.bool(forKey: checkboxDefaultsKey) ? .on : .off + let perSessionCheck = NSButton( + checkboxWithTitle: section.checkboxTitle, + target: self, + action: section.checkboxAction + ) + perSessionCheck.state = UserDefaults.standard.bool(forKey: section.checkboxDefaultsKey) ? .on : .off grid.addRow(with: [NSGridCell.emptyContentView, perSessionCheck]) - let perSessionHelp = NSTextField(labelWithString: checkboxHelp) + let perSessionHelp = NSTextField(labelWithString: section.checkboxHelp) perSessionHelp.font = .systemFont(ofSize: 11) perSessionHelp.textColor = .secondaryLabelColor grid.addRow(with: [NSGridCell.emptyContentView, perSessionHelp]) From 8f5d41cebc139483e39f0f262ea2eef8ea0e93de Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Wed, 29 Apr 2026 11:28:39 +0200 Subject: [PATCH 10/15] Add Codex tab test coverage --- Tests/ControlMessageTests.swift | 26 +++++++ Tests/ProcessMonitorTests.swift | 16 ++++ Tests/SessionStateTests.swift | 65 +++++++++++++++- Tests/WindowControllerLogicTests.swift | 103 ++++++++++++++++++++++++- 4 files changed, 205 insertions(+), 5 deletions(-) diff --git a/Tests/ControlMessageTests.swift b/Tests/ControlMessageTests.swift index 0286f4e..c0c014b 100644 --- a/Tests/ControlMessageTests.swift +++ b/Tests/ControlMessageTests.swift @@ -136,6 +136,32 @@ final class ControlMessageTests: XCTestCase { XCTAssertEqual(decoded.workingDirectory, "/home") } + func testCodexTabInfoRoundtripIncludesKind() throws { + let tab = TabInfo( + id: "t-codex", + name: "Project/Codex", + isClaude: false, + kind: "codex", + isMaster: false, + sessionId: "codex-session", + badgeState: "codexThinking", + workingDirectory: "/repo" + ) + + let data = try JSONEncoder().encode(tab) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let decoded = try JSONDecoder().decode(TabInfo.self, from: data) + + XCTAssertEqual(json?["kind"] as? String, "codex") + XCTAssertEqual(decoded.id, "t-codex") + XCTAssertEqual(decoded.name, "Project/Codex") + XCTAssertFalse(decoded.isClaude) + XCTAssertEqual(decoded.kind, "codex") + XCTAssertEqual(decoded.sessionId, "codex-session") + XCTAssertEqual(decoded.badgeState, "codexThinking") + XCTAssertEqual(decoded.workingDirectory, "/repo") + } + // MARK: - Optional fields func testControlMessageOptionalFields() throws { diff --git a/Tests/ProcessMonitorTests.swift b/Tests/ProcessMonitorTests.swift index fb2720d..a158d90 100644 --- a/Tests/ProcessMonitorTests.swift +++ b/Tests/ProcessMonitorTests.swift @@ -94,4 +94,20 @@ final class ProcessMonitorTests: XCTestCase { XCTAssertEqual(tabInfo.name, "Claude") XCTAssertEqual(tabInfo.projectPath, "/Users/test/project") } + + func testCodexTabInfoConstruction() { + let uuid = UUID() + let tabInfo = ProcessMonitor.TabInfo( + surfaceId: uuid, + kind: .codex, + name: "Codex", + projectPath: "/Users/test/project" + ) + + XCTAssertEqual(tabInfo.surfaceId, uuid) + XCTAssertEqual(tabInfo.kind, .codex) + XCTAssertFalse(tabInfo.isClaude) + XCTAssertEqual(tabInfo.name, "Codex") + XCTAssertEqual(tabInfo.projectPath, "/Users/test/project") + } } diff --git a/Tests/SessionStateTests.swift b/Tests/SessionStateTests.swift index 2dac0af..c5e86f5 100644 --- a/Tests/SessionStateTests.swift +++ b/Tests/SessionStateTests.swift @@ -18,7 +18,8 @@ final class SessionStateTests: XCTestCase { selectedTabIndex: 0, tabs: [ ProjectTabState(id: "tab-1", name: "Claude", isClaude: true, sessionId: "sess-1"), - ProjectTabState(id: "tab-2", name: "Terminal", isClaude: false, sessionId: nil), + ProjectTabState(id: "tab-2", name: "Codex", kind: .codex, sessionId: "codex-1"), + ProjectTabState(id: "tab-3", name: "Terminal", isClaude: false, sessionId: nil), ], defaultArgs: "--permission-mode acceptEdits", defaultCodexArgs: "--ask-for-approval never --sandbox workspace-write" @@ -33,10 +34,14 @@ final class SessionStateTests: XCTestCase { XCTAssertEqual(decoded.selectedTabIndex, 3) XCTAssertEqual(decoded.defaultWorkingDirectory, "/Users/test/project") XCTAssertEqual(decoded.projects?.count, 1) - XCTAssertEqual(decoded.projects?[0].tabs.count, 2) + XCTAssertEqual(decoded.projects?[0].tabs.count, 3) XCTAssertEqual(decoded.projects?[0].tabs[0].isClaude, true) XCTAssertEqual(decoded.projects?[0].tabs[0].sessionId, "sess-1") - XCTAssertNil(decoded.projects?[0].tabs[1].sessionId) + XCTAssertEqual(decoded.projects?[0].tabs[1].kind, .codex) + XCTAssertEqual(decoded.projects?[0].tabs[1].isClaude, false) + XCTAssertEqual(decoded.projects?[0].tabs[1].sessionId, "codex-1") + XCTAssertEqual(decoded.projects?[0].tabs[2].kind, .terminal) + XCTAssertNil(decoded.projects?[0].tabs[2].sessionId) XCTAssertEqual(decoded.projects?[0].defaultArgs, "--permission-mode acceptEdits") XCTAssertEqual(decoded.projects?[0].defaultCodexArgs, "--ask-for-approval never --sandbox workspace-write") } @@ -104,10 +109,64 @@ final class SessionStateTests: XCTestCase { XCTAssertEqual(decoded.id, "t1") XCTAssertEqual(decoded.name, "Claude") + XCTAssertEqual(decoded.kind, .claude) XCTAssertTrue(decoded.isClaude) XCTAssertEqual(decoded.sessionId, "s1") } + func testProjectTabStateCodexRoundtrip() throws { + let tab = ProjectTabState( + id: "t-codex", + name: "Codex", + kind: .codex, + sessionId: "codex-session", + tmuxSessionName: "deckard-codex" + ) + + let data = try JSONEncoder().encode(tab) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let decoded = try JSONDecoder().decode(ProjectTabState.self, from: data) + + XCTAssertEqual(json?["kind"] as? String, "codex") + XCTAssertEqual(json?["isClaude"] as? Bool, false) + XCTAssertEqual(decoded.id, "t-codex") + XCTAssertEqual(decoded.name, "Codex") + XCTAssertEqual(decoded.kind, .codex) + XCTAssertFalse(decoded.isClaude) + XCTAssertEqual(decoded.sessionId, "codex-session") + XCTAssertEqual(decoded.tmuxSessionName, "deckard-codex") + } + + func testProjectTabStateDecodesCodexKindEvenWhenLegacyIsClaudeIsFalse() throws { + let json = """ + {"id": "tab-codex", "name": "Codex", "kind": "codex", "isClaude": false, "sessionId": "codex-1"} + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(ProjectTabState.self, from: json) + + XCTAssertEqual(decoded.kind, .codex) + XCTAssertFalse(decoded.isClaude) + XCTAssertEqual(decoded.sessionId, "codex-1") + } + + func testProjectTabStateLegacyClaudeDecodeWithoutKind() throws { + let json = """ + {"id": "tab-claude", "name": "Claude", "isClaude": true, "sessionId": "claude-1"} + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(ProjectTabState.self, from: json) + + XCTAssertEqual(decoded.kind, .claude) + XCTAssertTrue(decoded.isClaude) + XCTAssertEqual(decoded.sessionId, "claude-1") + } + + func testSessionCacheKeySeparatesCodexFromClaude() { + XCTAssertEqual(SessionManager.sessionCacheKey(sessionId: "shared-id", kind: .claude), "shared-id") + XCTAssertEqual(SessionManager.sessionCacheKey(sessionId: "shared-id", kind: .codex), "codex:shared-id") + XCTAssertEqual(SessionManager.sessionCacheKey(sessionId: "shared-id", kind: .terminal), "terminal:shared-id") + } + // MARK: - SessionManager save/load func testSessionManagerSaveAndLoad() throws { diff --git a/Tests/WindowControllerLogicTests.swift b/Tests/WindowControllerLogicTests.swift index d5a7fe0..4766a69 100644 --- a/Tests/WindowControllerLogicTests.swift +++ b/Tests/WindowControllerLogicTests.swift @@ -13,9 +13,15 @@ final class WindowControllerLogicTests: XCTestCase { XCTAssertEqual(TabItem.BadgeState.waitingForInput.rawValue, "waitingForInput") XCTAssertEqual(TabItem.BadgeState.needsPermission.rawValue, "needsPermission") XCTAssertEqual(TabItem.BadgeState.error.rawValue, "error") + XCTAssertEqual(TabItem.BadgeState.codexIdle.rawValue, "codexIdle") + XCTAssertEqual(TabItem.BadgeState.codexThinking.rawValue, "codexThinking") + XCTAssertEqual(TabItem.BadgeState.codexError.rawValue, "codexError") + XCTAssertEqual(TabItem.BadgeState.codexCompletedUnseen.rawValue, "codexCompletedUnseen") XCTAssertEqual(TabItem.BadgeState.terminalIdle.rawValue, "terminalIdle") XCTAssertEqual(TabItem.BadgeState.terminalActive.rawValue, "terminalActive") XCTAssertEqual(TabItem.BadgeState.terminalError.rawValue, "terminalError") + XCTAssertEqual(TabItem.BadgeState.completedUnseen.rawValue, "completedUnseen") + XCTAssertEqual(TabItem.BadgeState.terminalCompletedUnseen.rawValue, "terminalCompletedUnseen") } // MARK: - BadgeState from raw value @@ -23,6 +29,8 @@ final class WindowControllerLogicTests: XCTestCase { func testBadgeStateFromRawValue() { XCTAssertEqual(TabItem.BadgeState(rawValue: "thinking"), .thinking) XCTAssertEqual(TabItem.BadgeState(rawValue: "needsPermission"), .needsPermission) + XCTAssertEqual(TabItem.BadgeState(rawValue: "codexThinking"), .codexThinking) + XCTAssertEqual(TabItem.BadgeState(rawValue: "codexCompletedUnseen"), .codexCompletedUnseen) XCTAssertNil(TabItem.BadgeState(rawValue: "invalid")) } @@ -32,13 +40,29 @@ final class WindowControllerLogicTests: XCTestCase { let allCases: [TabItem.BadgeState] = [ .none, .idle, .thinking, .waitingForInput, .needsPermission, .error, + .codexIdle, .codexThinking, .codexError, .codexCompletedUnseen, .terminalIdle, .terminalActive, .terminalError, + .completedUnseen, .terminalCompletedUnseen, ] - XCTAssertEqual(allCases.count, 9) + XCTAssertEqual(allCases.count, 15) // Verify all have distinct raw values let rawValues = Set(allCases.map(\.rawValue)) - XCTAssertEqual(rawValues.count, 9) + XCTAssertEqual(rawValues.count, 15) + } + + // MARK: - TabKind + + func testTabKindDisplayNames() { + XCTAssertEqual(TabKind.claude.displayName, "Claude") + XCTAssertEqual(TabKind.codex.displayName, "Codex") + XCTAssertEqual(TabKind.terminal.displayName, "Terminal") + } + + func testTabKindAgentClassification() { + XCTAssertTrue(TabKind.claude.isAgent) + XCTAssertTrue(TabKind.codex.isAgent) + XCTAssertFalse(TabKind.terminal.isAgent) } // MARK: - ProjectItem @@ -118,6 +142,41 @@ final class WindowControllerLogicTests: XCTestCase { XCTAssertFalse(config.entries.isEmpty) } + func testDefaultTabConfigParsesCodexTabs() { + withUserDefaultsValue("defaultTabConfig", value: "claude, codex, terminal") { + let config = DefaultTabConfig.current + + XCTAssertEqual(config.entries.count, 3) + XCTAssertEqual(config.entries[0].kind, .claude) + XCTAssertEqual(config.entries[0].name, "Claude") + XCTAssertEqual(config.entries[1].kind, .codex) + XCTAssertEqual(config.entries[1].name, "Codex") + XCTAssertEqual(config.entries[2].kind, .terminal) + XCTAssertEqual(config.entries[2].name, "Terminal") + } + } + + // MARK: - Badge animation defaults + + func testCodexThinkingBadgeIsAnimatedByDefault() { + withUserDefaultsValue("badgeAnimate.codexThinking", value: nil) { + XCTAssertTrue(SettingsWindowController.isBadgeAnimated(.codexThinking)) + } + } + + func testCodexNonWorkingBadgesAreNotAnimatedByDefault() { + let keys = [ + "badgeAnimate.codexIdle", + "badgeAnimate.codexError", + "badgeAnimate.codexCompletedUnseen", + ] + withRemovedUserDefaults(keys) { + XCTAssertFalse(SettingsWindowController.isBadgeAnimated(.codexIdle)) + XCTAssertFalse(SettingsWindowController.isBadgeAnimated(.codexError)) + XCTAssertFalse(SettingsWindowController.isBadgeAnimated(.codexCompletedUnseen)) + } + } + // MARK: - ActivityInfo from ProcessMonitor func testProcessMonitorActivityInfoIsUsableInWindowContext() { @@ -135,4 +194,44 @@ final class WindowControllerLogicTests: XCTestCase { func testTabItemCannotBeCreatedWithoutSurface() throws { try XCTSkipIf(true, "TabItem requires TerminalSurface which needs SwiftTerm view hierarchy") } + + private func withUserDefaultsValue(_ key: String, value: Any?, run: () -> Void) { + let previous = UserDefaults.standard.object(forKey: key) + if let value { + UserDefaults.standard.set(value, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + + defer { + if let previous { + UserDefaults.standard.set(previous, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + } + + run() + } + + private func withRemovedUserDefaults(_ keys: [String], run: () -> Void) { + let previousValues = keys.map { key in + (key: key, value: UserDefaults.standard.object(forKey: key)) + } + for key in keys { + UserDefaults.standard.removeObject(forKey: key) + } + + defer { + for previous in previousValues { + if let value = previous.value { + UserDefaults.standard.set(value, forKey: previous.key) + } else { + UserDefaults.standard.removeObject(forKey: previous.key) + } + } + } + + run() + } } From 951095a06e1ae6a850bc06d19c8723761f7cf411 Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Wed, 29 Apr 2026 11:31:11 +0200 Subject: [PATCH 11/15] Use ad-hoc signing for debug tests --- Deckard.xcodeproj/project.pbxproj | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Deckard.xcodeproj/project.pbxproj b/Deckard.xcodeproj/project.pbxproj index bab46c3..0b89a08 100644 --- a/Deckard.xcodeproj/project.pbxproj +++ b/Deckard.xcodeproj/project.pbxproj @@ -644,7 +644,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Resources/Deckard.entitlements; - CODE_SIGN_IDENTITY = "Developer ID Application: Gilles Dubuc (TATY79TCRY)"; + CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = TATY79TCRY; COMBINE_HIDPI_IMAGES = YES; @@ -666,6 +666,8 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( From 86b4e9460f9963c60ef26b875cc4f9d24279f11d Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Thu, 30 Apr 2026 13:53:53 +0200 Subject: [PATCH 12/15] Fix top bar badge pulse rendering --- Sources/Window/SidebarViews.swift | 35 +++++++++++++++++++------------ 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/Sources/Window/SidebarViews.swift b/Sources/Window/SidebarViews.swift index 11d30f3..39075a4 100644 --- a/Sources/Window/SidebarViews.swift +++ b/Sources/Window/SidebarViews.swift @@ -884,19 +884,20 @@ class AddTabButton: NSView { // MARK: - BadgeShapeView -/// Draws a badge dot using a CAShapeLayer for customizable shapes. +/// Draws a badge dot using AppKit so the same pulse path works in both tab bars. class BadgeShapeView: NSView { static let pulseAnimationKey = "pulse" - private let shapeLayer = CAShapeLayer() + private var shape: TabItem.BadgeShape + private var color: NSColor private var isPulseAnimationEnabled = false private var pulseTimer: Timer? private var pulseStartTime: TimeInterval = 0 init(shape: TabItem.BadgeShape, color: NSColor, size: CGFloat = 7) { + self.shape = shape + self.color = color super.init(frame: NSRect(x: 0, y: 0, width: size, height: size)) - wantsLayer = true - layer?.addSublayer(shapeLayer) translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ widthAnchor.constraint(equalToConstant: size), @@ -907,6 +908,8 @@ class BadgeShapeView: NSView { required init?(coder: NSCoder) { fatalError() } + override var isOpaque: Bool { false } + deinit { pulseTimer?.invalidate() } @@ -930,10 +933,18 @@ class BadgeShapeView: NSView { } func updateAppearance(shape: TabItem.BadgeShape, color: NSColor, size: CGFloat = 7) { - let rect = CGRect(x: 0, y: 0, width: size, height: size) - shapeLayer.path = Self.path(for: shape, in: rect) - shapeLayer.fillColor = color.cgColor - shapeLayer.frame = rect + self.shape = shape + self.color = color + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + context.saveGState() + context.addPath(Self.path(for: shape, in: bounds)) + context.setFillColor(color.cgColor) + context.fillPath() + context.restoreGState() } private func startPulseAnimation() { @@ -950,11 +961,10 @@ class BadgeShapeView: NSView { private func stopPulseAnimation(resetOpacity: Bool) { pulseTimer?.invalidate() pulseTimer = nil - shapeLayer.removeAnimation(forKey: Self.pulseAnimationKey) + layer?.removeAnimation(forKey: Self.pulseAnimationKey) if resetOpacity { alphaValue = 1.0 - layer?.opacity = 1.0 - shapeLayer.opacity = 1.0 + needsDisplay = true } } @@ -969,8 +979,7 @@ class BadgeShapeView: NSView { let eased = 0.5 - 0.5 * cos(rawProgress * .pi) let opacity = Float(1.0 - (0.7 * eased)) alphaValue = CGFloat(opacity) - layer?.opacity = opacity - shapeLayer.opacity = opacity + needsDisplay = true } static func makePulseAnimation() -> CABasicAnimation { From 72e62e475af7eacdc7664683cee81326d8ab3b2b Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Thu, 30 Apr 2026 20:22:22 +0200 Subject: [PATCH 13/15] Fix terminal image paste shortcut handling --- Sources/Terminal/TerminalSurface.swift | 41 +++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/Sources/Terminal/TerminalSurface.swift b/Sources/Terminal/TerminalSurface.swift index d230864..eb3914a 100644 --- a/Sources/Terminal/TerminalSurface.swift +++ b/Sources/Terminal/TerminalSurface.swift @@ -22,15 +22,25 @@ private class DeckardTerminalView: LocalProcessTerminalView { "tiff", "webp" ] + var handlesPasteShortcuts = true + private var pasteShortcutMonitor: Any? override init(frame: CGRect) { super.init(frame: frame) registerForDraggedTypes([.fileURL]) + installPasteShortcutMonitor() } required init?(coder: NSCoder) { super.init(coder: coder) registerForDraggedTypes([.fileURL]) + installPasteShortcutMonitor() + } + + deinit { + if let pasteShortcutMonitor { + NSEvent.removeMonitor(pasteShortcutMonitor) + } } override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { @@ -69,6 +79,32 @@ private class DeckardTerminalView: LocalProcessTerminalView { return true } + private func installPasteShortcutMonitor() { + pasteShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, + self.handlesPasteShortcuts, + Self.isPasteShortcut(event), + self.shouldHandlePasteShortcut(event) else { + return event + } + + self.paste(event) + return nil + } + } + + private func shouldHandlePasteShortcut(_ event: NSEvent) -> Bool { + guard event.window === window else { return false } + guard let firstResponder = window?.firstResponder else { return false } + if firstResponder === self { + return true + } + guard let responderView = firstResponder as? NSView else { + return false + } + return responderView == self || responderView.isDescendant(of: self) + } + private func forwardImagePasteShortcutToTerminal() -> Bool { guard Self.pasteboardContainsImage(NSPasteboard.general) else { return false } send(Self.kittyCommandVPasteSequence) @@ -299,6 +335,7 @@ class TerminalSurface: NSObject, LocalProcessTerminalViewDelegate { // the command (e.g. user typing while a new Claude tab is starting). if let initialInput { pendingInitialInput = initialInput + terminalView.handlesPasteShortcuts = false let view = terminalView keyEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in // Swallow key events targeting our terminal view's window. @@ -306,7 +343,9 @@ class TerminalSurface: NSObject, LocalProcessTerminalViewDelegate { return event } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - guard let self, let input = self.pendingInitialInput else { return } + guard let self else { return } + defer { self.terminalView.handlesPasteShortcuts = true } + guard let input = self.pendingInitialInput else { return } self.pendingInitialInput = nil if let monitor = self.keyEventMonitor { NSEvent.removeMonitor(monitor) From 8f0e4cb6e76f7fb30acfd7479907adb1c14889e2 Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Thu, 30 Apr 2026 21:58:28 +0200 Subject: [PATCH 14/15] Improve Codex tab responsiveness --- Sources/Terminal/TerminalSurface.swift | 78 ++++++++++++++++++++ Sources/Window/DeckardWindowController.swift | 2 +- Tests/TerminalSurfaceTests.swift | 48 ++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/Sources/Terminal/TerminalSurface.swift b/Sources/Terminal/TerminalSurface.swift index eb3914a..a6300ce 100644 --- a/Sources/Terminal/TerminalSurface.swift +++ b/Sources/Terminal/TerminalSurface.swift @@ -23,7 +23,15 @@ private class DeckardTerminalView: LocalProcessTerminalView { "webp" ] var handlesPasteShortcuts = true + var stripsSynchronizedOutputSequences = false { + didSet { + if !stripsSynchronizedOutputSequences { + syncOutputFilterPendingBytes.removeAll(keepingCapacity: true) + } + } + } private var pasteShortcutMonitor: Any? + private var syncOutputFilterPendingBytes: [UInt8] = [] override init(frame: CGRect) { super.init(frame: frame) @@ -79,6 +87,19 @@ private class DeckardTerminalView: LocalProcessTerminalView { return true } + override func dataReceived(slice: ArraySlice) { + guard stripsSynchronizedOutputSequences else { + super.dataReceived(slice: slice) + return + } + + let filtered = TerminalOutputFilter.stripSynchronizedOutputSequences( + from: slice, + pending: &syncOutputFilterPendingBytes) + guard !filtered.isEmpty else { return } + feed(byteArray: filtered[...]) + } + private func installPasteShortcutMonitor() { pasteShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in guard let self, @@ -148,6 +169,56 @@ private class DeckardTerminalView: LocalProcessTerminalView { } } +enum TerminalOutputFilter { + private static let synchronizedOutputSequences = [ + Array("\u{1B}[?2026h".utf8), + Array("\u{1B}[?2026l".utf8), + ] + + static func stripSynchronizedOutputSequences( + from slice: ArraySlice, + pending: inout [UInt8] + ) -> [UInt8] { + guard !slice.isEmpty || !pending.isEmpty else { return [] } + + var bytes = pending + bytes.append(contentsOf: slice) + pending.removeAll(keepingCapacity: true) + + var output: [UInt8] = [] + output.reserveCapacity(bytes.count) + + var index = 0 + while index < bytes.count { + if let sequence = synchronizedOutputSequences.first(where: { matches($0, in: bytes, at: index) }) { + index += sequence.count + continue + } + + let remaining = bytes[index...] + if synchronizedOutputSequences.contains(where: { sequence in + remaining.count < sequence.count && sequence.starts(with: remaining) + }) { + pending = Array(remaining) + break + } + + output.append(bytes[index]) + index += 1 + } + + return output + } + + private static func matches(_ sequence: [UInt8], in bytes: [UInt8], at index: Int) -> Bool { + guard bytes.count - index >= sequence.count else { return false } + for offset in 0.. Date: Sat, 2 May 2026 18:22:10 +0200 Subject: [PATCH 15/15] fix: send empty bracketed paste for Claude image paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code on macOS detects an image-paste request via a bracketed paste with empty content (CSI 200~ ... CSI 201~), then reads the NSPasteboard itself. We were sending the kitty CSI-u Cmd+V sequence, which Claude does not recognize — its input parser then extracts the 118 parameter as the codepoint for 'v' and inserts it into the prompt. Codex still uses Ctrl+V. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Terminal/TerminalSurface.swift | 41 ++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/Sources/Terminal/TerminalSurface.swift b/Sources/Terminal/TerminalSurface.swift index a6300ce..50832cd 100644 --- a/Sources/Terminal/TerminalSurface.swift +++ b/Sources/Terminal/TerminalSurface.swift @@ -6,10 +6,33 @@ import SwiftTerm /// LocalProcessTerminalView subclass that accepts file drags from Finder /// and pastes shell-escaped paths into the terminal. private class DeckardTerminalView: LocalProcessTerminalView { - private static let kittyCommandVPasteSequence = Array("\u{1B}[118;9u".utf8) + private enum ImagePasteShortcut { + /// Empty bracketed paste — Claude Code on macOS treats an empty paste + /// as a hint to read the system pasteboard for an image. + case emptyBracketedPaste + case controlV + + var sequence: [UInt8] { + switch self { + case .emptyBracketedPaste: + return DeckardTerminalView.emptyBracketedPasteSequence + case .controlV: + return [0x16] + } + } + } + + private static let emptyBracketedPasteSequence = + Array("\u{1B}[200~\u{1B}[201~".utf8) private static let imagePasteboardTypes: [NSPasteboard.PasteboardType] = [ .png, - .tiff + .tiff, + NSPasteboard.PasteboardType("public.image"), + NSPasteboard.PasteboardType("public.jpeg"), + NSPasteboard.PasteboardType("public.heic"), + NSPasteboard.PasteboardType("public.heif"), + NSPasteboard.PasteboardType("com.compuserve.gif"), + NSPasteboard.PasteboardType("org.webmproject.webp") ] private static let imageFileExtensions: Set = [ "gif", @@ -23,6 +46,7 @@ private class DeckardTerminalView: LocalProcessTerminalView { "webp" ] var handlesPasteShortcuts = true + private var imagePasteShortcut: ImagePasteShortcut = .emptyBracketedPaste var stripsSynchronizedOutputSequences = false { didSet { if !stripsSynchronizedOutputSequences { @@ -33,6 +57,10 @@ private class DeckardTerminalView: LocalProcessTerminalView { private var pasteShortcutMonitor: Any? private var syncOutputFilterPendingBytes: [UInt8] = [] + func configureImagePasteShortcut(sessionType: String?) { + imagePasteShortcut = sessionType == "codex" ? .controlV : .emptyBracketedPaste + } + override init(frame: CGRect) { super.init(frame: frame) registerForDraggedTypes([.fileURL]) @@ -61,6 +89,7 @@ private class DeckardTerminalView: LocalProcessTerminalView { override func performKeyEquivalent(with event: NSEvent) -> Bool { if Self.isPasteShortcut(event) { + guard handlesPasteShortcuts else { return true } paste(event) return true } @@ -116,6 +145,7 @@ private class DeckardTerminalView: LocalProcessTerminalView { private func shouldHandlePasteShortcut(_ event: NSEvent) -> Bool { guard event.window === window else { return false } + if hasFocus { return true } guard let firstResponder = window?.firstResponder else { return false } if firstResponder === self { return true @@ -128,7 +158,7 @@ private class DeckardTerminalView: LocalProcessTerminalView { private func forwardImagePasteShortcutToTerminal() -> Bool { guard Self.pasteboardContainsImage(NSPasteboard.general) else { return false } - send(Self.kittyCommandVPasteSequence) + send(imagePasteShortcut.sequence) return true } @@ -325,8 +355,9 @@ class TerminalSurface: NSObject, LocalProcessTerminalViewDelegate { // Codex emits DEC 2026 synchronized-output markers around frequent // full-screen repaints. SwiftTerm snapshots the whole scrollback on // every begin marker, which makes long-running Codex sessions sluggish. - terminalView.stripsSynchronizedOutputSequences = - envVars["DECKARD_SESSION_TYPE"] == "codex" + let sessionType = envVars["DECKARD_SESSION_TYPE"] + terminalView.stripsSynchronizedOutputSequences = sessionType == "codex" + terminalView.configureImagePasteShortcut(sessionType: sessionType) // Build environment var env = ProcessInfo.processInfo.environment