From c50f6523d5480d4a8e7cedc17e2f4579a5158bd5 Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Sun, 12 Jul 2026 02:52:52 +0800 Subject: [PATCH 1/2] feat: add Grok CLI support at parity with Claude Code and Codex Adds grok as a third first-class agent tab kind: - New Grok tabs, resume, and whole-session fork (grok --resume [--fork-session]) - Session discovery/timelines from ~/.grok/sessions (summary.json, prompt_history.jsonl), status badges from events.jsonl turn events, context usage from signals.json - Poll-based session-id discovery (Grok holds no session file open, so lsof-based discovery is not possible) - GrokCLIFlags autocomplete, settings section, badge colors, menus, shortcuts (Cmd+Ctrl+T), per-workspace default args - Fork-at-point is gated off for Grok: its multi-file session format cannot be truncated safely Co-Authored-By: Claude Fable 5 --- README.md | 69 ++--- Sources/App/AppDelegate.swift | 15 ++ Sources/App/ClaudeCLIFlags.swift | 62 +++++ Sources/App/ShortcutNames.swift | 2 + Sources/Detection/ContextMonitor.swift | 255 +++++++++++++++++- .../SessionExplorerWindowController.swift | 2 +- Sources/Session/SessionState.swift | 10 + Sources/Terminal/TerminalSurface.swift | 1 + Sources/Window/ClaudeArgsField.swift | 3 + Sources/Window/DeckardWindowController.swift | 239 +++++++++++++++- Sources/Window/SettingsWindow.swift | 36 ++- Sources/Window/SidebarController.swift | 30 +++ Sources/Window/SidebarViews.swift | 26 +- Sources/Window/TabBarController.swift | 4 + Sources/Window/TabBarViews.swift | 6 + Tests/ClaudeCLIFlagsTests.swift | 64 +++++ Tests/ContextMonitorTests.swift | 84 ++++++ Tests/SessionStateTests.swift | 11 +- Tests/WindowControllerLogicTests.swift | 50 +++- 19 files changed, 920 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 9b6c387..8aaf6fe 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, 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), [Grok CLI](https://x.ai/), and classic terminal tabs. Deckard treats agent sessions as first-class tabs: Claude Code, Codex, and Grok can all 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). @@ -19,9 +19,9 @@ Run multiple agents side by side in a single window with project-aware tabs, ses ## Features -### Claude, Codex, and Terminal Tabs +### Claude, Codex, Grok, and Terminal Tabs -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. +Open Claude Code, Codex, Grok, 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 agent and terminal tabs @@ -33,15 +33,15 @@ Each open directory gets its own persisted tab set. Group related projects into ### Provider-Specific Status Badges -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. +Claude Code, Codex, and Grok 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 Grok badges from Grok session events; both show idle, working, error, and done-unvisited states. Terminal tabs use their own process-activity badges. Status indicator dots ### 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 and timeline views. +Browse past Claude Code, Codex, and Grok sessions with Cmd+Shift+E. The explorer lists all 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. +Fork-at-turn works for Claude Code and Codex. 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. Grok sessions can be forked whole (via `grok --resume --fork-session`) but not from a specific turn, because Grok's multi-file session format cannot be truncated safely. Session explorer window @@ -49,12 +49,12 @@ Fork-at-turn works for both agent providers. For Claude Code, Deckard truncates 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 | 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 | +| Metric | Claude Code tabs | Codex tabs | Grok tabs | Terminal tabs | +| --- | --- | --- | --- | --- | +| Context usage bar | Yes, from Claude session usage entries | No reliable local signal; hidden | Yes, from Grok session signals data | No | +| 5-hour quota | Yes, from Claude status-line hook data | Yes, from Codex app-server rate-limit data with rollout fallback | No local signal; hidden | No | +| 7-day quota | Yes, from Claude status-line hook data | Yes, from Codex app-server rate-limit data with rollout fallback | No local signal; hidden | No | +| Tokens per minute | Yes, from recent Claude output token usage | Yes, from recent Codex generated token usage | No local signal; hidden | No | Classic terminal tabs intentionally do not show agent context, quota, or token-rate panels. @@ -68,10 +68,10 @@ Ships with 486 built-in themes in Ghostty format and loads custom themes from `~ ### More -- **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**: 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. +- **Session persistence**: Claude Code sessions resume with `claude --resume`; Codex sessions resume with `codex resume`; Grok sessions resume with `grok --resume`. Tab structure and working directories are preserved across restarts. +- **Forking workflows**: Claude Code, Codex, and Grok sessions can be forked from the explorer; Claude Code and Codex can also fork from a specific user turn. +- **Bookmarks**: Claude Code, Codex, and Grok 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, Grok, 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. @@ -79,19 +79,19 @@ Ships with 486 built-in themes in Ghostty format and loads custom themes from `~ ## 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 | -| Provider-specific badges | Yes | Yes | -| 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. +| Workflow | Claude Code | Codex | Grok | +| --- | --- | --- | --- | +| Create new agent tab | Yes | Yes | Yes | +| Resume existing session | Yes | Yes | Yes | +| Fork existing session | Yes | Yes | Yes | +| Fork from a specific turn | Yes | Yes | No — Grok's multi-file session format cannot be truncated safely | +| Session explorer listing | Yes | Yes | Yes | +| Timeline and action view | Yes | Yes | Yes | +| Bookmarks | Yes | Yes | Yes | +| Provider-specific badges | Yes | Yes | Yes | +| Context, quota, token rate | Yes | Quota via Codex app-server; token rate from `token_count` events when present | Context usage from session signals | + +Deckard aims for equal day-to-day workflows across Claude Code, Codex, and Grok. 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 @@ -108,9 +108,10 @@ brew install gi11es/tap/deckard - macOS 14.0 (Sonoma) or later - [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 +- [Grok CLI](https://x.ai/) installed to use Grok 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. +Deckard can be used with any subset of the agent CLIs installed. Terminal tabs work without any agent CLI. ## Building @@ -145,6 +146,14 @@ For Codex quota, Deckard keeps a local `codex app-server --listen stdio://` JSON Deckard does not install Codex hooks. It launches the Codex CLI directly with `codex`, `codex resume`, or `codex fork`. +**Grok** + +Deckard reads Grok session data under `~/.grok/sessions`, which stores one directory per working directory with per-session `summary.json`, `signals.json`, and `events.jsonl` files plus a shared `prompt_history.jsonl`. That provides project-scoped session discovery, resume, whole-session fork, timeline parsing, badges, and context usage. + +Because Grok does not hold its session files open, Deckard discovers a new tab's session id by watching for the newest session that appears for the workspace after the tab launches. + +Deckard does not install Grok hooks. It launches the Grok CLI directly with `grok` or `grok --resume [--fork-session]`. + **Terminal** 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. diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index e32d9ab..f762beb 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -47,6 +47,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { 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(handleNewGrokTab), name: .deckardNewGrokTab, object: nil) nc.addObserver(self, selector: #selector(handleCloseTab), name: .deckardCloseTab, object: nil) // Start the control socket for hook communication. @@ -71,6 +72,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { ClaudeCLIFlags.shared.load() log.log("startup", "Loading Codex CLI flags...") CodexCLIFlags.shared.load() + log.log("startup", "Loading Grok CLI flags...") + GrokCLIFlags.shared.load() // Clean up orphaned tmux sessions from previous runs if TerminalSurface.tmuxAvailable { @@ -163,6 +166,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { windowController?.addTabToCurrentWorkspace(kind: .codex) } + @objc private func handleNewGrokTab() { + windowController?.addTabToCurrentWorkspace(kind: .grok) + } + @objc private func handleCloseTab() { windowController?.closeCurrentTab() } @@ -209,6 +216,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { codexItem.setShortcut(for: .newCodexTab) fileMenu.addItem(codexItem) + let grokItem = NSMenuItem(title: "New Grok Tab", action: #selector(newGrokTab), keyEquivalent: "") + grokItem.setShortcut(for: .newGrokTab) + fileMenu.addItem(grokItem) + let termItem = NSMenuItem(title: "New Terminal Tab", action: #selector(newTerminalTab), keyEquivalent: "") termItem.setShortcut(for: .newTerminalTab) fileMenu.addItem(termItem) @@ -321,6 +332,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { windowController?.addTabToCurrentWorkspace(kind: .codex) } + @objc private func newGrokTab() { + windowController?.addTabToCurrentWorkspace(kind: .grok) + } + @objc private func newTerminalTab() { windowController?.addTabToCurrentWorkspace(kind: .terminal) } diff --git a/Sources/App/ClaudeCLIFlags.swift b/Sources/App/ClaudeCLIFlags.swift index bbfbcac..6cad71d 100644 --- a/Sources/App/ClaudeCLIFlags.swift +++ b/Sources/App/ClaudeCLIFlags.swift @@ -310,6 +310,68 @@ final class CodexCLIFlags { } } +/// Parses and caches CLI flags from `grok --help`. +final class GrokCLIFlags { + + static let shared = GrokCLIFlags() + private init() {} + + /// Parsed flags. Empty until `load()` completes (or if grok is not installed). + private(set) var flags: [ClaudeFlag] = [] + + /// Flags Deckard manages internally — excluded from suggestions. + static let blocklist: Set = [ + "--help", "--version", + // Session management Deckard drives itself. + "--resume", "--continue", "--fork-session", "--session-id", + // Headless/single-turn modes that would break an interactive tab. + "--single", "--prompt-file", "--prompt-json", "--output-format", + "--json-schema", "--best-of-n", "--check", + // Deckard launches Grok in the workspace directory already. + "--cwd", + ] + + /// Posted on the main thread when flags finish loading. + static let didLoadNotification = Notification.Name("GrokCLIFlagsDidLoad") + + /// Run `grok --help` asynchronously and parse the output. + func load() { + DispatchQueue.global(qos: .utility).async { [weak self] in + guard let output = Self.runGrokHelp() else { return } + let parsed = Self.parse(helpOutput: output) + DispatchQueue.main.async { + self?.flags = parsed + NotificationCenter.default.post(name: Self.didLoadNotification, object: nil) + } + } + } + + /// Parse `grok --help` output into structured flags. + static func parse(helpOutput: String) -> [ClaudeFlag] { + CLIHelpParser.parse(helpOutput: helpOutput, blocklist: blocklist) + } + + private static func runGrokHelp() -> 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", "grok --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/App/ShortcutNames.swift b/Sources/App/ShortcutNames.swift index fb995ea..7733b97 100644 --- a/Sources/App/ShortcutNames.swift +++ b/Sources/App/ShortcutNames.swift @@ -5,6 +5,7 @@ extension KeyboardShortcuts.Name { static let openWorkspace = Self("openWorkspace", 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 newGrokTab = Self("newGrokTab", default: .init(.t, modifiers: [.command, .control])) static let newTerminalTab = Self("newTerminalTab", default: .init(.t, modifiers: [.command, .shift])) static let closeTab = Self("closeTab", default: .init(.w, modifiers: .command)) static let closeWorkspace = Self("closeWorkspace", default: .init(.w, modifiers: [.command, .shift])) @@ -39,6 +40,7 @@ let configurableShortcuts: [ShortcutEntry] = [ ShortcutEntry(name: .openWorkspace, label: "Open Workspace"), ShortcutEntry(name: .newClaudeTab, label: "New Claude Tab"), ShortcutEntry(name: .newCodexTab, label: "New Codex Tab"), + ShortcutEntry(name: .newGrokTab, label: "New Grok Tab"), ShortcutEntry(name: .newTerminalTab, label: "New Terminal Tab"), ShortcutEntry(name: .closeTab, label: "Close Tab"), ShortcutEntry(name: .closeWorkspace, label: "Close Workspace"), diff --git a/Sources/Detection/ContextMonitor.swift b/Sources/Detection/ContextMonitor.swift index 07e30da..0101052 100644 --- a/Sources/Detection/ContextMonitor.swift +++ b/Sources/Detection/ContextMonitor.swift @@ -55,6 +55,16 @@ class ContextMonitor { let sparklineData: [Double] } + struct GrokActivityInfo { + let isBusy: Bool + let isError: Bool + let timestamp: Date? + } + + struct GrokUsageInfo { + let context: ContextUsage? + } + private let codexAppServerPollInterval: TimeInterval = 60 private let codexAppServerCacheMaxAge: TimeInterval = 15 * 60 private let codexAppServerTimeout: TimeInterval = 8 @@ -153,9 +163,11 @@ class ContextMonitor { return results } - /// Lists Claude and Codex sessions for a workspace, sorted by most recent first. + /// Lists Claude, Codex, and Grok sessions for a workspace, sorted by most recent first. func listAllSessions(forWorkspacePath workspacePath: String) -> [SessionInfo] { - (listSessions(forWorkspacePath: workspacePath) + listCodexSessions(forWorkspacePath: workspacePath)) + (listSessions(forWorkspacePath: workspacePath) + + listCodexSessions(forWorkspacePath: workspacePath) + + listGrokSessions(forWorkspacePath: workspacePath)) .sorted { $0.modificationDate > $1.modificationDate } } @@ -755,12 +767,247 @@ class ContextMonitor { trimmed.hasPrefix("") } + // MARK: - Grok + + /// Grok stores sessions under one directory per percent-encoded working + /// directory: ~/.grok/sessions///, with a + /// summary.json, signals.json, and events.jsonl per session, plus a + /// prompt_history.jsonl shared by all sessions of that working directory. + private var grokSessionsRoot: URL { + URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".grok/sessions") + } + + /// Lists Grok sessions for a workspace by scanning ~/.grok/sessions. + func listGrokSessions(forWorkspacePath workspacePath: String) -> [SessionInfo] { + guard let workspaceDir = grokWorkspaceDirURL(forWorkspacePath: workspacePath) else { return [] } + + let prompts = grokPromptHistory(inWorkspaceDir: workspaceDir) + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory( + at: workspaceDir, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + var results: [SessionInfo] = [] + for sessionDir in entries { + guard (try? sessionDir.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true, + let info = parseGrokSessionInfo(sessionDir: sessionDir, prompts: prompts) else { continue } + results.append(info) + } + + results.sort { $0.modificationDate > $1.modificationDate } + return results + } + + /// Finds the ~/.grok/sessions/ directory whose percent-decoded + /// name matches the workspace path. Matching by decoding sidesteps having to + /// reproduce the CLI's exact percent-encoding alphabet. + private func grokWorkspaceDirURL(forWorkspacePath workspacePath: String) -> URL? { + let resolved = (workspacePath as NSString).resolvingSymlinksInPath + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory( + at: grokSessionsRoot, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return nil } + + for dir in entries { + guard (try? dir.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true, + let decoded = dir.lastPathComponent.removingPercentEncoding else { continue } + if (decoded as NSString).resolvingSymlinksInPath == resolved { + return dir + } + } + return nil + } + + /// Locates the session directory for a Grok session id across all workspaces. + func grokSessionDirURL(sessionId: String) -> URL? { + let fm = FileManager.default + guard let workspaceDirs = try? fm.contentsOfDirectory( + at: grokSessionsRoot, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return nil } + + for workspaceDir in workspaceDirs { + let candidate = workspaceDir.appendingPathComponent(sessionId) + var isDir: ObjCBool = false + if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue { + return candidate + } + } + return nil + } + + func latestGrokSession(forWorkspacePath workspacePath: String, after date: Date, excluding excludedIds: Set = []) -> SessionInfo? { + listGrokSessions(forWorkspacePath: workspacePath) + .first { $0.modificationDate >= date && !excludedIds.contains($0.sessionId) } + } + + /// First prompt and prompt count per session id, from the shared + /// prompt_history.jsonl. Bash-mode prompts are skipped — they are shell + /// commands typed in the TUI, not agent turns. + private func grokPromptHistory(inWorkspaceDir workspaceDir: URL) -> [String: (first: String, count: Int)] { + let historyURL = workspaceDir.appendingPathComponent("prompt_history.jsonl") + guard let data = try? Data(contentsOf: historyURL), + let content = String(data: data, encoding: .utf8) else { return [:] } + + var result: [String: (first: String, count: Int)] = [:] + for line in content.split(separator: "\n") { + guard let json = parseJSONObject(line), + json["is_bash"] as? Bool != true, + let sessionId = json["session_id"] as? String, + let prompt = json["prompt"] as? String else { continue } + if var existing = result[sessionId] { + existing.count += 1 + result[sessionId] = existing + } else { + let first = prompt.split(separator: "\n").first.map(String.init)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + result[sessionId] = (first: first, count: 1) + } + } + return result + } + + private func parseGrokSessionInfo(sessionDir: URL, prompts: [String: (first: String, count: Int)]) -> SessionInfo? { + let summaryURL = sessionDir.appendingPathComponent("summary.json") + guard let data = try? Data(contentsOf: summaryURL), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + + let info = json["info"] as? [String: Any] + guard let sessionId = info?["id"] as? String ?? (sessionDir.lastPathComponent.isEmpty ? nil : sessionDir.lastPathComponent) else { + return nil + } + + let modDate = (json["updated_at"] as? String).flatMap { codexTimestampFormatter.date(from: $0) } + ?? (try? FileManager.default.attributesOfItem(atPath: summaryURL.path))?[.modificationDate] as? Date + ?? Date.distantPast + + let prompt = prompts[sessionId] + var firstMessage = prompt?.first ?? "" + if firstMessage.isEmpty { + firstMessage = json["generated_title"] as? String + ?? json["session_summary"] as? String + ?? "" + } + + return SessionInfo( + sessionId: sessionId, + modificationDate: modDate, + firstUserMessage: firstMessage, + messageCount: prompt?.count ?? 0, + kind: .grok, + filePath: sessionDir + ) + } + + func grokActivityInfo(sessionId: String) -> GrokActivityInfo? { + guard let sessionDir = grokSessionDirURL(sessionId: sessionId), + let content = tailContent(of: sessionDir.appendingPathComponent("events.jsonl"), maxBytes: 256 * 1024) else { + return nil + } + return parseGrokActivityInfo(from: content) + } + + func parseGrokActivityInfo(from content: String) -> GrokActivityInfo? { + var latest: GrokActivityInfo? + for line in content.split(separator: "\n") { + guard let json = parseJSONObject(line), + let type = json["type"] as? String else { continue } + + let isBusy: Bool + let isError: Bool + switch type { + case "turn_started": + isBusy = true + isError = false + case "turn_ended": + isBusy = false + isError = json["outcome"] as? String == "error" + default: + continue + } + + let timestamp = (json["ts"] as? String).flatMap { codexTimestampFormatter.date(from: $0) } + latest = GrokActivityInfo(isBusy: isBusy, isError: isError, timestamp: timestamp) + } + return latest + } + + func getGrokUsage(sessionId: String?) -> GrokUsageInfo? { + guard let sessionId, + let sessionDir = grokSessionDirURL(sessionId: sessionId), + let data = try? Data(contentsOf: sessionDir.appendingPathComponent("signals.json")), + let content = String(data: data, encoding: .utf8) else { return nil } + return parseGrokUsage(from: content) + } + + /// Parse context usage from a Grok signals.json payload. + func parseGrokUsage(from content: String) -> GrokUsageInfo? { + guard let data = content.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let used = codexInt(json["contextTokensUsed"]), + let limit = codexInt(json["contextWindowTokens"]), + limit > 0, used > 0 else { return nil } + + let model = json["primaryModelId"] as? String ?? "" + return GrokUsageInfo(context: ContextUsage( + model: model, + inputTokens: used, + cacheReadTokens: 0, + contextLimit: limit)) + } + + private func tailContent(of fileURL: URL, maxBytes: UInt64) -> String? { + guard 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 parseGrokTimeline(sessionId: String, workspacePath: String) -> [TimelineEntry] { + guard let sessionDir = grokSessionDirURL(sessionId: sessionId) else { return [] } + let historyURL = sessionDir.deletingLastPathComponent().appendingPathComponent("prompt_history.jsonl") + guard let data = try? Data(contentsOf: historyURL), + let content = String(data: data, encoding: .utf8) else { return [] } + + var entries: [TimelineEntry] = [] + for line in content.split(separator: "\n") { + guard let json = parseJSONObject(line), + json["session_id"] as? String == sessionId, + json["is_bash"] as? Bool != true, + let prompt = json["prompt"] as? String, + !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + + let timestamp = (json["timestamp"] as? String).flatMap { codexTimestampFormatter.date(from: $0) } + entries.append(TimelineEntry( + index: entries.count, + promptId: "\(sessionId)-\(entries.count)", + message: prompt.trimmingCharacters(in: .whitespacesAndNewlines), + timestamp: timestamp + )) + } + return entries + } + /// 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, workspacePath: String, kind: TabKind = .claude) -> [TimelineEntry] { if kind == .codex { return parseCodexTimeline(sessionId: sessionId, workspacePath: workspacePath) } + if kind == .grok { + return parseGrokTimeline(sessionId: sessionId, workspacePath: workspacePath) + } let encoded = workspacePath.claudeProjectDirName let jsonlPath = NSHomeDirectory() + "/.claude/projects/\(encoded)/\(sessionId).jsonl" @@ -853,7 +1100,9 @@ class ContextMonitor { return truncateClaudeSession(sessionId: sessionId, workspacePath: workspacePath, afterTurnIndex: afterTurnIndex) case .codex: return truncateCodexSession(sessionId: sessionId, workspacePath: workspacePath, afterTurnIndex: afterTurnIndex) - case .terminal: + case .grok, .terminal: + // Grok sessions span several interdependent files (chat_history, + // updates, events); Deckard cannot safely truncate-and-fork them. return nil } } diff --git a/Sources/Session/SessionExplorerWindowController.swift b/Sources/Session/SessionExplorerWindowController.swift index 297e211..eb362c1 100644 --- a/Sources/Session/SessionExplorerWindowController.swift +++ b/Sources/Session/SessionExplorerWindowController.swift @@ -319,7 +319,7 @@ class SessionExplorerWindowController: NSWindowController, NSSplitViewDelegate, entries: entries, options: .init( resumeEnabled: !isOpen, - forkAtPointEnabled: updatedSession.agentKind.isAgent, + forkAtPointEnabled: updatedSession.agentKind.supportsForkAtPoint, scrollToIndex: scrollToMessageIndex ) ) diff --git a/Sources/Session/SessionState.swift b/Sources/Session/SessionState.swift index e518f9a..8c0bc71 100644 --- a/Sources/Session/SessionState.swift +++ b/Sources/Session/SessionState.swift @@ -3,17 +3,26 @@ import Foundation enum TabKind: String, Codable, CaseIterable { case claude case codex + case grok case terminal var displayName: String { switch self { case .claude: return "Claude" case .codex: return "Codex" + case .grok: return "Grok" case .terminal: return "Terminal" } } var isAgent: Bool { + self != .terminal + } + + /// Whether Deckard can truncate this agent's session files to fork + /// from an earlier turn (Grok's multi-file session format can't be + /// truncated safely). + var supportsForkAtPoint: Bool { self == .claude || self == .codex } } @@ -98,6 +107,7 @@ struct WorkspaceState: Codable { var tabs: [WorkspaceTabState] var defaultArgs: String? var defaultCodexArgs: String? + var defaultGrokArgs: String? } struct WorkspaceTabState: Codable { diff --git a/Sources/Terminal/TerminalSurface.swift b/Sources/Terminal/TerminalSurface.swift index 14ec9f4..fddc485 100644 --- a/Sources/Terminal/TerminalSurface.swift +++ b/Sources/Terminal/TerminalSurface.swift @@ -698,6 +698,7 @@ extension Notification.Name { static let deckardSurfaceClosed = Notification.Name("deckardSurfaceClosed") static let deckardNewTab = Notification.Name("deckardNewTab") static let deckardNewCodexTab = Notification.Name("deckardNewCodexTab") + static let deckardNewGrokTab = Notification.Name("deckardNewGrokTab") static let deckardCloseTab = Notification.Name("deckardCloseTab") static let deckardFontChanged = Notification.Name("deckardFontChanged") static let deckardScrollbackChanged = Notification.Name("deckardScrollbackChanged") diff --git a/Sources/Window/ClaudeArgsField.swift b/Sources/Window/ClaudeArgsField.swift index 3ff8362..0245bfa 100644 --- a/Sources/Window/ClaudeArgsField.swift +++ b/Sources/Window/ClaudeArgsField.swift @@ -8,11 +8,13 @@ final class ClaudeArgsField: NSView { enum FlagSource { case claude case codex + case grok var flags: [ClaudeFlag] { switch self { case .claude: return ClaudeCLIFlags.shared.flags case .codex: return CodexCLIFlags.shared.flags + case .grok: return GrokCLIFlags.shared.flags } } @@ -20,6 +22,7 @@ final class ClaudeArgsField: NSView { switch self { case .claude: return ClaudeCLIFlags.didLoadNotification case .codex: return CodexCLIFlags.didLoadNotification + case .grok: return GrokCLIFlags.didLoadNotification } } } diff --git a/Sources/Window/DeckardWindowController.swift b/Sources/Window/DeckardWindowController.swift index 5ab1a9f..3d74776 100644 --- a/Sources/Window/DeckardWindowController.swift +++ b/Sources/Window/DeckardWindowController.swift @@ -22,9 +22,14 @@ class TabItem { var badgeState: BadgeState = .none /// Set during restore — suppresses completedUnseen until hook.session-start fires. var suppressUnseen: Bool = false + /// Creation time — used to discover Grok session ids by matching sessions + /// that appeared after the tab launched (Grok holds no session file open, + /// so lsof-based discovery is not possible). + let createdAt = Date() var isClaude: Bool { kind == .claude } var isCodex: Bool { kind == .codex } + var isGrok: Bool { kind == .grok } var isTerminal: Bool { kind == .terminal } var isAgent: Bool { kind.isAgent } @@ -44,6 +49,10 @@ class TabItem { case codexThinking case codexError case codexCompletedUnseen + case grokIdle + case grokThinking + case grokError + case grokCompletedUnseen case terminalIdle // muted teal - terminal at prompt case terminalActive // teal pulsing - terminal foreground process has activity case terminalError // red - terminal process exited with error @@ -89,6 +98,7 @@ class WorkspaceItem { var selectedTabIndex: Int = 0 var defaultArgs: String? var defaultCodexArgs: String? + var defaultGrokArgs: String? init(path: String) { self.id = UUID() @@ -139,6 +149,7 @@ struct DefaultTabConfig { switch trimmed { case "claude": return (kind: .claude, name: "Claude") case "codex": return (kind: .codex, name: "Codex") + case "grok": return (kind: .grok, name: "Grok") case "terminal": return (kind: .terminal, name: "Terminal") default: return nil } @@ -577,6 +588,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { workspace.name = snapshot.name workspace.defaultArgs = snapshot.defaultArgs workspace.defaultCodexArgs = snapshot.defaultCodexArgs + workspace.defaultGrokArgs = snapshot.defaultGrokArgs for ts in snapshot.tabs { createTabInWorkspace(workspace, kind: ts.kind, name: ts.name, sessionIdToResume: ts.kind.isAgent ? ts.sessionId : nil, @@ -635,7 +647,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { tmuxSessionName: tab.surface.tmuxSessionName) }, defaultArgs: workspace.defaultArgs, - defaultCodexArgs: workspace.defaultCodexArgs + defaultCodexArgs: workspace.defaultCodexArgs, + defaultGrokArgs: workspace.defaultGrokArgs ) recentlyClosedWorkspaces.removeAll { $0.path == workspace.path } recentlyClosedWorkspaces.append(snapshot) @@ -745,6 +758,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { return .idle case .codex: return .codexIdle + case .grok: + return .grokIdle case .terminal: return .terminalIdle } @@ -819,6 +834,21 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { codexArgs = codexOptions } initialInput = "clear && exec codex\(codexArgs)\n" + } else if kind == .grok { + let resolvedArgs = extraArgs ?? workspace.defaultGrokArgs ?? UserDefaults.standard.string(forKey: "grokExtraArgs") ?? "" + let grokOptions = resolvedArgs.isEmpty ? "" : " \(resolvedArgs)" + var grokArgs = "" + if let sessionIdToResume { + if ContextMonitor.shared.grokSessionDirURL(sessionId: sessionIdToResume) != nil { + let forkFlag = forkSession ? " --fork-session" : "" + grokArgs = "\(grokOptions) --resume \(sessionIdToResume)\(forkFlag)" + } else { + tab.sessionId = nil + } + } else { + grokArgs = grokOptions + } + initialInput = "clear && exec grok\(grokArgs)\n" } else { initialInput = nil } @@ -911,6 +941,20 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { self.createTabInWorkspace(workspace, kind: .codex, extraArgs: args) self.finalizeTabCreation(in: workspace) } + } else if kind == .grok && UserDefaults.standard.bool(forKey: "promptForGrokSessionArgs") { + promptForGrokArgs(for: workspace) { [weak self] args in + guard let self else { return } + guard let args else { + self.isCreatingTab = false + return + } + guard self.workspaces.contains(where: { $0 === workspace }) else { + self.isCreatingTab = false + return + } + self.createTabInWorkspace(workspace, kind: .grok, extraArgs: args) + self.finalizeTabCreation(in: workspace) + } } else { createTabInWorkspace(workspace, kind: kind) finalizeTabCreation(in: workspace) @@ -982,6 +1026,34 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } + private func promptForGrokArgs(for workspace: WorkspaceItem, completion: @escaping (String?) -> Void) { + let alert = NSAlert() + alert.messageText = "Grok 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: .grok + ) + field.stringValue = workspace.defaultGrokArgs ?? UserDefaults.standard.string(forKey: "grokExtraArgs") ?? "" + 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 workspace = currentWorkspace else { return } let idx = workspace.selectedTabIndex @@ -1020,6 +1092,10 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { tab.badgeState = .codexIdle rebuildSidebar() rebuildTabBar() + case .grokCompletedUnseen: + tab.badgeState = .grokIdle + rebuildSidebar() + rebuildTabBar() case .terminalCompletedUnseen: tab.badgeState = .terminalIdle rebuildSidebar() @@ -1131,6 +1207,11 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { contextTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in self?.updateCodexUsage(for: tab) } + case .grok: + updateGrokUsage(for: tab) + contextTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in + self?.updateGrokUsage(for: tab) + } case .terminal: quotaView.clear() } @@ -1221,6 +1302,38 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } + private func updateGrokUsage(for tab: TabItem) { + guard currentWorkspace != nil else { + quotaView.clear() + return + } + + let tabName = tab.name + let tabId = tab.id + let sessionId = tab.sessionId + DispatchQueue.global(qos: .utility).async { + let usage = ContextMonitor.shared.getGrokUsage(sessionId: sessionId) + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + guard let workspace = self.currentWorkspace, + let activeTab = workspace.tabs[safe: workspace.selectedTabIndex], + activeTab.id == tabId else { + DiagnosticLog.shared.log("context", + "updateGrokUsage: 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: nil, tokenRate: nil, sparklineData: []) + } + } + } + // MARK: - Process Monitor private struct CodexBadgePollTarget { @@ -1235,6 +1348,18 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { let discoveredSessionIds: [UUID: String] } + private struct GrokBadgePollTarget { + let surfaceId: UUID + let workspacePath: String + let sessionId: String? + let createdAt: Date + } + + private struct GrokBadgePollResult { + let states: [UUID: ContextMonitor.GrokActivityInfo] + let discoveredSessionIds: [UUID: String] + } + private func startProcessMonitor() { processMonitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in guard let self = self else { return } @@ -1242,6 +1367,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { // is done via control socket registration, not sorted order. var tabInfos: [ProcessMonitor.TabInfo] = [] var codexTargets: [CodexBadgePollTarget] = [] + var grokTargets: [GrokBadgePollTarget] = [] + var knownGrokSessionIds = Set() for workspace in self.workspaces { for tab in workspace.tabs { tabInfos.append(ProcessMonitor.TabInfo( @@ -1254,15 +1381,28 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { sessionId: tab.sessionId, processId: ProcessMonitor.shared.shellPid(forSurface: tab.id))) } + if tab.kind == .grok { + grokTargets.append(GrokBadgePollTarget( + surfaceId: tab.id, + workspacePath: workspace.path, + sessionId: tab.sessionId, + createdAt: tab.createdAt)) + if let sessionId = tab.sessionId { + knownGrokSessionIds.insert(sessionId) + } + } } } DispatchQueue.global(qos: .utility).async { let states = ProcessMonitor.shared.poll(tabs: tabInfos) let codexResult = self.pollCodexBadgeStates(for: codexTargets) + let grokResult = self.pollGrokBadgeStates(for: grokTargets, knownSessionIds: knownGrokSessionIds) DispatchQueue.main.async { self.applyCodexSessionDiscoveries(codexResult.discoveredSessionIds) + self.applyCodexSessionDiscoveries(grokResult.discoveredSessionIds) self.applyTerminalBadgeStates(states) self.applyCodexBadgeStates(codexResult.states) + self.applyGrokBadgeStates(grokResult.states) } } } @@ -1293,6 +1433,69 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { return CodexBadgePollResult(states: states, discoveredSessionIds: discoveredSessionIds) } + private func pollGrokBadgeStates(for targets: [GrokBadgePollTarget], knownSessionIds: Set) -> GrokBadgePollResult { + var states: [UUID: ContextMonitor.GrokActivityInfo] = [:] + var discoveredSessionIds: [UUID: String] = [:] + + for target in targets { + var sessionId = target.sessionId + if sessionId == nil { + // Grok holds no session file open, so discover by picking the + // newest session for the workspace that appeared after this tab + // launched and isn't claimed by another tab. + let excluded = knownSessionIds.union(discoveredSessionIds.values) + if let session = ContextMonitor.shared.latestGrokSession( + forWorkspacePath: target.workspacePath, + after: target.createdAt, + excluding: excluded + ) { + sessionId = session.sessionId + discoveredSessionIds[target.surfaceId] = session.sessionId + } + } + + guard let sessionId, + let state = ContextMonitor.shared.grokActivityInfo(sessionId: sessionId) else { continue } + states[target.surfaceId] = state + } + + return GrokBadgePollResult(states: states, discoveredSessionIds: discoveredSessionIds) + } + + private func applyGrokBadgeStates(_ states: [UUID: ContextMonitor.GrokActivityInfo]) { + var changed = false + for workspace in workspaces { + for tab in workspace.tabs where tab.kind == .grok { + guard let state = states[tab.id] else { continue } + + let newBadge: TabItem.BadgeState + if state.isBusy { + newBadge = .grokThinking + } else if state.isError { + newBadge = .grokError + } else if tab.badgeState == .grokThinking { + let visible = isTabVisible(tab.id.uuidString) + newBadge = visible ? .grokIdle : .grokCompletedUnseen + } else if tab.badgeState == .grokCompletedUnseen { + newBadge = .grokCompletedUnseen + } else { + newBadge = .grokIdle + } + + if tab.badgeState != newBadge { + DiagnosticLog.shared.log("badge", + "grok badge: workspace=\(workspace.path) tab=\"\(tab.name)\" busy=\(state.isBusy) error=\(state.isError) -> \(newBadge)") + tab.badgeState = newBadge + changed = true + } + } + } + if changed { + rebuildSidebar() + rebuildTabBar() + } + } + private func applyCodexSessionDiscoveries(_ discoveredSessionIds: [UUID: String]) { guard !discoveredSessionIds.isEmpty else { return } for (surfaceId, sessionId) in discoveredSessionIds { @@ -1614,7 +1817,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { ) }, defaultArgs: workspace.defaultArgs, - defaultCodexArgs: workspace.defaultCodexArgs + defaultCodexArgs: workspace.defaultCodexArgs, + defaultGrokArgs: workspace.defaultGrokArgs ) } @@ -1694,6 +1898,33 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { return nil } + var grokRestoreCandidatesByPath: [String: [String]] = [:] + var usedGrokSessionIds = Set(workspaceStates.flatMap { workspace in + workspace.tabs.compactMap { tab in + tab.kind == .grok ? tab.sessionId : nil + } + }) + + func recoverGrokSessionId(for workspacePath: String, tabName: String) -> String? { + let resolvedPath = (workspacePath as NSString).resolvingSymlinksInPath + if grokRestoreCandidatesByPath[resolvedPath] == nil { + grokRestoreCandidatesByPath[resolvedPath] = ContextMonitor.shared + .listGrokSessions(forWorkspacePath: resolvedPath) + .map(\.sessionId) + } + + while var candidates = grokRestoreCandidatesByPath[resolvedPath], !candidates.isEmpty { + let sessionId = candidates.removeFirst() + grokRestoreCandidatesByPath[resolvedPath] = candidates + guard usedGrokSessionIds.insert(sessionId).inserted else { continue } + DiagnosticLog.shared.log("restore", + "recovered missing Grok session id for \(tabName)@\(resolvedPath): \(sessionId)") + return sessionId + } + + return nil + } + // Phase 1: Create the active workspace's active tab immediately so the user // sees a working terminal right away. Collect remaining tabs for Phase 2. var pending: [(workspace: WorkspaceItem, tab: WorkspaceTabState, originalIndex: Int)] = [] @@ -1703,6 +1934,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { workspace.name = ps.name workspace.defaultArgs = ps.defaultArgs workspace.defaultCodexArgs = ps.defaultCodexArgs + workspace.defaultGrokArgs = ps.defaultGrokArgs let selTab = min(max(ps.selectedTabIndex, 0), max(ps.tabs.count - 1, 0)) @@ -1711,6 +1943,9 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { if restoredTab.kind == .codex, restoredTab.sessionId == nil { restoredTab.sessionId = recoverCodexSessionId(for: ps.path, tabName: restoredTab.name) } + if restoredTab.kind == .grok, restoredTab.sessionId == nil { + restoredTab.sessionId = recoverGrokSessionId(for: ps.path, tabName: restoredTab.name) + } if i == selectedIdx && t == selTab { // Create the active tab's surface synchronously diff --git a/Sources/Window/SettingsWindow.swift b/Sources/Window/SettingsWindow.swift index 580faf7..f993a04 100644 --- a/Sources/Window/SettingsWindow.swift +++ b/Sources/Window/SettingsWindow.swift @@ -138,7 +138,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie let tabConfigField = NSTextField() tabConfigField.stringValue = UserDefaults.standard.string(forKey: "defaultTabConfig") ?? "claude, terminal" - tabConfigField.placeholderString = "claude, codex, terminal" + tabConfigField.placeholderString = "claude, codex, grok, terminal" tabConfigField.font = .monospacedSystemFont(ofSize: 12, weight: .regular) objc_setAssociatedObject(tabConfigField, &settingsKeyAssoc, "defaultTabConfig", .OBJC_ASSOCIATION_RETAIN) tabConfigField.delegate = self @@ -151,7 +151,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie grid.addRow(with: [tabConfigLabel, tabConfigField]) - let tabConfigHelp = NSTextField(labelWithString: "Comma-separated list: claude, codex, terminal") + let tabConfigHelp = NSTextField(labelWithString: "Comma-separated list: claude, codex, grok, terminal") tabConfigHelp.font = .systemFont(ofSize: 11) tabConfigHelp.textColor = .secondaryLabelColor grid.addRow(with: [NSGridCell.emptyContentView, tabConfigHelp]) @@ -213,6 +213,18 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie checkboxAction: #selector(codexPerSessionArgsToggled(_:)) ))) + stack.addArrangedSubview(makeAgentDefaultsSection(AgentDefaultsSection( + title: "Grok", + fieldLabel: "Default arguments:", + defaultsKey: "grokExtraArgs", + flagSource: .grok, + help: "Grok CLI arguments passed to every new Grok session, such as model, reasoning effort, and permission mode. Can be overridden per workspace.", + checkboxTitle: "Customize Grok arguments per session", + checkboxHelp: "Show a dialog to set arguments when creating a new Grok tab.", + checkboxDefaultsKey: "promptForGrokSessionArgs", + checkboxAction: #selector(grokPerSessionArgsToggled(_:)) + ))) + pane.addSubview(stack) NSLayoutConstraint.activate([ stack.topAnchor.constraint(equalTo: pane.topAnchor, constant: 20), @@ -299,6 +311,10 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie UserDefaults.standard.set(sender.state == .on, forKey: "promptForCodexSessionArgs") } + @objc private func grokPerSessionArgsToggled(_ sender: NSButton) { + UserDefaults.standard.set(sender.state == .on, forKey: "promptForGrokSessionArgs") + } + @objc private func vibrancyToggled(_ sender: NSButton) { let enabled = sender.state == .on UserDefaults.standard.set(enabled, forKey: "sidebarVibrancy") @@ -802,6 +818,13 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie (.codexCompletedUnseen, "Done"), ] + private static let grokBadgeEntries: [(state: TabItem.BadgeState, label: String)] = [ + (.grokIdle, "Idle"), + (.grokThinking, "Working"), + (.grokError, "Error"), + (.grokCompletedUnseen, "Done"), + ] + private static let terminalBadgeEntries: [(state: TabItem.BadgeState, label: String)] = [ (.terminalIdle, "Idle"), (.terminalActive, "Busy"), @@ -810,7 +833,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie ] /// Default animation settings per state. - static let defaultBadgeAnimated: Set = [.thinking, .codexThinking, .terminalActive] + static let defaultBadgeAnimated: Set = [.thinking, .codexThinking, .grokThinking, .terminalActive] static func isBadgeAnimated(_ state: TabItem.BadgeState) -> Bool { if let saved = UserDefaults.standard.object(forKey: "badgeAnimate.\(state.rawValue)") as? Bool { @@ -948,9 +971,10 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie let claudeTable = makeSectionTable("Claude", Self.claudeBadgeEntries) let codexTable = makeSectionTable("Codex", Self.codexBadgeEntries) + let grokTable = makeSectionTable("Grok", Self.grokBadgeEntries) let terminalTable = makeSectionTable("Terminal", Self.terminalBadgeEntries) - let hStack = NSStackView(views: [claudeTable, codexTable, terminalTable]) + let hStack = NSStackView(views: [claudeTable, codexTable, grokTable, terminalTable]) hStack.orientation = .horizontal hStack.alignment = .top hStack.spacing = 12 @@ -1040,7 +1064,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie } @objc private func resetBadgeColors() { - for entry in Self.claudeBadgeEntries + Self.codexBadgeEntries + Self.terminalBadgeEntries { + for entry in Self.claudeBadgeEntries + Self.codexBadgeEntries + Self.grokBadgeEntries + 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)") @@ -1211,7 +1235,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie versionLabel.textColor = .secondaryLabelColor stack.addArrangedSubview(versionLabel) - let descLabel = NSTextField(labelWithString: "Multi-session Claude Code and Codex terminal manager") + let descLabel = NSTextField(labelWithString: "Multi-session Claude Code, Codex, and Grok 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 f7498c4..ec5d29d 100644 --- a/Sources/Window/SidebarController.swift +++ b/Sources/Window/SidebarController.swift @@ -594,6 +594,11 @@ extension DeckardWindowController { defaultCodexArgsItem.representedObject = workspace menu.addItem(defaultCodexArgsItem) + let defaultGrokArgsItem = NSMenuItem(title: "Default Grok Arguments\u{2026}", action: #selector(defaultGrokArgsMenuAction(_:)), keyEquivalent: "") + defaultGrokArgsItem.target = self + defaultGrokArgsItem.representedObject = workspace + menu.addItem(defaultGrokArgsItem) + menu.addItem(.separator()) // Group options @@ -750,6 +755,31 @@ extension DeckardWindowController { } } + @objc func defaultGrokArgsMenuAction(_ sender: NSMenuItem) { + guard let workspace = sender.representedObject as? WorkspaceItem, + let window else { return } + + let alert = NSAlert() + alert.messageText = "Default Grok Arguments for \(workspace.name)" + alert.informativeText = "These arguments will be used for new Grok tabs in this workspace, 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: .grok + ) + field.stringValue = workspace.defaultGrokArgs ?? "" + alert.accessoryView = field + + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertFirstButtonReturn else { return } + let value = field.stringValue.trimmingCharacters(in: .whitespaces) + workspace.defaultGrokArgs = value.isEmpty ? nil : value + self?.saveState() + } + } + // MARK: - Sidebar Selection func updateSidebarSelection() { diff --git a/Sources/Window/SidebarViews.swift b/Sources/Window/SidebarViews.swift index 586387b..d867162 100644 --- a/Sources/Window/SidebarViews.swift +++ b/Sources/Window/SidebarViews.swift @@ -147,6 +147,10 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { case .codexThinking: return "Codex working..." case .codexError: return "Codex error" case .codexCompletedUnseen: return "Codex done (unvisited)" + case .grokIdle: return "Grok idle" + case .grokThinking: return "Grok working..." + case .grokError: return "Grok error" + case .grokCompletedUnseen: return "Grok done (unvisited)" case .terminalIdle: return "Idle" case .terminalActive: return activity?.description ?? "Running" case .terminalError: return "Error" @@ -165,6 +169,10 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource { .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), + .grokIdle: NSColor(red: 0.32, green: 0.45, blue: 0.68, alpha: 1.0), + .grokThinking: NSColor(red: 0.30, green: 0.60, blue: 0.95, alpha: 1.0), + .grokError: .systemRed, + .grokCompletedUnseen: NSColor(red: 0.35, green: 0.75, blue: 1.0, 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), @@ -819,12 +827,14 @@ class AddTabButton: NSView { override var mouseDownCanMoveWindow: Bool { false } private let claudeAction: () -> Void private let codexAction: () -> Void + private let grokAction: () -> Void private let terminalAction: () -> Void private let label: NSTextField - init(claudeAction: @escaping () -> Void, codexAction: @escaping () -> Void, terminalAction: @escaping () -> Void) { + init(claudeAction: @escaping () -> Void, codexAction: @escaping () -> Void, grokAction: @escaping () -> Void, terminalAction: @escaping () -> Void) { self.claudeAction = claudeAction self.codexAction = codexAction + self.grokAction = grokAction self.terminalAction = terminalAction label = NSTextField(labelWithString: " +") label.font = .systemFont(ofSize: 12, weight: .medium) @@ -833,6 +843,7 @@ class AddTabButton: NSView { translatesAutoresizingMaskIntoConstraints = false toolTip = shortcutTooltip("New Claude tab", for: .newClaudeTab) + "\nOption-click: " + shortcutTooltip("new Codex", for: .newCodexTab) + + "\nOption+Shift-click: " + shortcutTooltip("new Grok", for: .newGrokTab) + "\nShift-click: " + shortcutTooltip("new Terminal", for: .newTerminalTab) label.translatesAutoresizingMaskIntoConstraints = false addSubview(label) @@ -848,9 +859,12 @@ class AddTabButton: NSView { required init?(coder: NSCoder) { fatalError() } override func mouseDown(with event: NSEvent) { - if event.modifierFlags.contains(.option) { + let mods = event.modifierFlags + if mods.contains(.option) && mods.contains(.shift) { + grokAction() + } else if mods.contains(.option) { codexAction() - } else if event.modifierFlags.contains(.shift) { + } else if mods.contains(.shift) { terminalAction() } else { claudeAction() @@ -869,6 +883,11 @@ class AddTabButton: NSView { codexItem.target = self menu.addItem(codexItem) + let grokItem = NSMenuItem(title: "New Grok Tab", action: #selector(newGrokAction), keyEquivalent: "") + grokItem.setShortcut(for: .newGrokTab) + grokItem.target = self + menu.addItem(grokItem) + let terminalItem = NSMenuItem(title: "New Terminal Tab", action: #selector(newTerminalAction), keyEquivalent: "") terminalItem.setShortcut(for: .newTerminalTab) terminalItem.target = self @@ -879,6 +898,7 @@ class AddTabButton: NSView { @objc private func newClaudeAction() { claudeAction() } @objc private func newCodexAction() { codexAction() } + @objc private func newGrokAction() { grokAction() } @objc private func newTerminalAction() { terminalAction() } } diff --git a/Sources/Window/TabBarController.swift b/Sources/Window/TabBarController.swift index 41bd2c4..faa0e38 100644 --- a/Sources/Window/TabBarController.swift +++ b/Sources/Window/TabBarController.swift @@ -81,6 +81,9 @@ extension DeckardWindowController { tabView.onNewCodex = { [weak self] in self?.addTabToCurrentWorkspace(kind: .codex) } + tabView.onNewGrok = { [weak self] in + self?.addTabToCurrentWorkspace(kind: .grok) + } tabView.onNewTerminal = { [weak self] in self?.addTabToCurrentWorkspace(kind: .terminal) } @@ -103,6 +106,7 @@ extension DeckardWindowController { let addButton = AddTabButton( claudeAction: { [weak self] in self?.addTabToCurrentWorkspace(kind: .claude) }, codexAction: { [weak self] in self?.addTabToCurrentWorkspace(kind: .codex) }, + grokAction: { [weak self] in self?.addTabToCurrentWorkspace(kind: .grok) }, terminalAction: { [weak self] in self?.addTabToCurrentWorkspace(kind: .terminal) } ) tabBar.addArrangedSubview(addButton) diff --git a/Sources/Window/TabBarViews.swift b/Sources/Window/TabBarViews.swift index c1e894b..dab4e30 100644 --- a/Sources/Window/TabBarViews.swift +++ b/Sources/Window/TabBarViews.swift @@ -18,6 +18,7 @@ class HorizontalTabView: NSView, NSTextFieldDelegate, NSDraggingSource { var onClose: (() -> Void)? var onNewClaude: (() -> Void)? var onNewCodex: (() -> Void)? + var onNewGrok: (() -> Void)? var onNewTerminal: (() -> Void)? private var rawName: String @@ -190,6 +191,10 @@ class HorizontalTabView: NSView, NSTextFieldDelegate, NSDraggingSource { codexItem.setShortcut(for: .newCodexTab) menu.addItem(codexItem) + let grokItem = NSMenuItem(title: "New Grok Tab", action: #selector(newGrokAction), keyEquivalent: "") + grokItem.setShortcut(for: .newGrokTab) + menu.addItem(grokItem) + let termItem = NSMenuItem(title: "New Terminal Tab", action: #selector(newTerminalAction), keyEquivalent: "") termItem.setShortcut(for: .newTerminalTab) menu.addItem(termItem) @@ -205,6 +210,7 @@ class HorizontalTabView: NSView, NSTextFieldDelegate, NSDraggingSource { @objc private func newClaudeAction() { onNewClaude?() } @objc private func newCodexAction() { onNewCodex?() } + @objc private func newGrokAction() { onNewGrok?() } @objc private func newTerminalAction() { onNewTerminal?() } @objc private func closeTabAction() { onClose?() } diff --git a/Tests/ClaudeCLIFlagsTests.swift b/Tests/ClaudeCLIFlagsTests.swift index f4262b2..99f591b 100644 --- a/Tests/ClaudeCLIFlagsTests.swift +++ b/Tests/ClaudeCLIFlagsTests.swift @@ -249,3 +249,67 @@ final class CodexCLIFlagsTests: XCTestCase { XCTAssertFalse(longNames.contains("--version")) } } + +final class GrokCLIFlagsTests: XCTestCase { + + private let sampleHelp = """ + Options: + --always-approve + Auto-approve all tool executions + -c, --continue + Continue the most recent session for the current working directory + --cwd + Working directory + -m, --model + Model ID to use + --output-format + Output format for headless mode [default: plain] [possible values: plain, json, streaming-json] + --permission-mode + Permission mode [possible values: default, acceptEdits, auto, dontAsk, bypassPermissions, plan] + -r, --resume [] + Resume a session by ID, or the most recent if omitted + --reasoning-effort + Reasoning effort for reasoning models [aliases: --effort] + --fork-session + When resuming, create a new session ID instead of reusing the original + -h, --help + Print help + """ + + func testParsesGrokEnumerationFlags() { + let flags = GrokCLIFlags.parse(helpOutput: sampleHelp) + + let permissionMode = flags.first { $0.longName == "--permission-mode" } + guard case .enumeration(let values) = permissionMode?.valueType else { + XCTFail("Expected permission-mode enum") + return + } + XCTAssertEqual(values, ["default", "acceptEdits", "auto", "dontAsk", "bypassPermissions", "plan"]) + } + + func testParsesGrokFreeTextAndBooleanFlags() { + let flags = GrokCLIFlags.parse(helpOutput: sampleHelp) + + let model = flags.first { $0.longName == "--model" } + let alwaysApprove = flags.first { $0.longName == "--always-approve" } + + XCTAssertEqual(model?.shortName, "-m") + XCTAssertEqual(model?.valueType, .freeText) + XCTAssertEqual(model?.valuePlaceholder, "") + XCTAssertEqual(alwaysApprove?.valueType, .boolean) + } + + func testGrokBlocklistExcludesDeckardManagedFlags() { + let longNames = GrokCLIFlags.parse(helpOutput: sampleHelp).map(\.longName) + + XCTAssertFalse(longNames.contains("--resume")) + XCTAssertFalse(longNames.contains("--continue")) + XCTAssertFalse(longNames.contains("--fork-session")) + XCTAssertFalse(longNames.contains("--cwd")) + XCTAssertFalse(longNames.contains("--output-format")) + XCTAssertFalse(longNames.contains("--help")) + + XCTAssertTrue(longNames.contains("--model")) + XCTAssertTrue(longNames.contains("--reasoning-effort")) + } +} diff --git a/Tests/ContextMonitorTests.swift b/Tests/ContextMonitorTests.swift index 8bf45ed..5b9eb57 100644 --- a/Tests/ContextMonitorTests.swift +++ b/Tests/ContextMonitorTests.swift @@ -176,6 +176,90 @@ final class ContextMonitorTests: XCTestCase { XCTAssertEqual(activity?.isError, true) } + // MARK: - Grok activity parsing + + func testParseGrokActivityInfoReportsStartedTurnBusy() { + let content = """ + {"ts":"2026-07-10T15:01:02.100Z","type":"turn_started"} + {"ts":"2026-07-10T15:01:02.500Z","type":"first_token"} + """ + + let activity = ContextMonitor.shared.parseGrokActivityInfo(from: content) + + XCTAssertEqual(activity?.isBusy, true) + XCTAssertEqual(activity?.isError, false) + } + + func testParseGrokActivityInfoTreatsCompletedTurnAsIdle() { + let content = """ + {"ts":"2026-07-10T15:01:02.100Z","type":"turn_started"} + {"ts":"2026-07-10T15:22:33.343Z","type":"turn_ended","outcome":"completed"} + """ + + let activity = ContextMonitor.shared.parseGrokActivityInfo(from: content) + + XCTAssertEqual(activity?.isBusy, false) + XCTAssertEqual(activity?.isError, false) + XCTAssertNotNil(activity?.timestamp) + } + + func testParseGrokActivityInfoTreatsCancelledTurnAsIdle() { + let content = """ + {"ts":"2026-07-10T15:01:02.100Z","type":"turn_started"} + {"ts":"2026-07-10T15:02:00.000Z","type":"turn_ended","outcome":"cancelled"} + """ + + let activity = ContextMonitor.shared.parseGrokActivityInfo(from: content) + + XCTAssertEqual(activity?.isBusy, false) + XCTAssertEqual(activity?.isError, false) + } + + func testParseGrokActivityInfoTreatsErrorOutcomeAsError() { + let content = """ + {"ts":"2026-07-10T15:01:02.100Z","type":"turn_started"} + {"ts":"2026-07-10T15:02:00.000Z","type":"turn_ended","outcome":"error"} + """ + + let activity = ContextMonitor.shared.parseGrokActivityInfo(from: content) + + XCTAssertEqual(activity?.isBusy, false) + XCTAssertEqual(activity?.isError, true) + } + + func testParseGrokActivityInfoIgnoresUnrelatedEvents() { + let content = """ + {"ts":"2026-07-10T15:00:58.106Z","type":"mcp_config_resolved","servers":[]} + {"ts":"2026-07-10T15:00:59.000Z","type":"phase_changed","phase":"planning"} + """ + + XCTAssertNil(ContextMonitor.shared.parseGrokActivityInfo(from: content)) + } + + // MARK: - Grok usage parsing + + func testParseGrokUsageReadsContextFromSignals() throws { + let content = """ + {"turnCount":1,"contextWindowUsage":30,"contextTokensUsed":151838,"contextWindowTokens":500000,"primaryModelId":"grok-4.5"} + """ + + let usage = ContextMonitor.shared.parseGrokUsage(from: content) + + XCTAssertEqual(usage?.context?.contextUsed, 151_838) + XCTAssertEqual(usage?.context?.contextLimit, 500_000) + XCTAssertEqual(usage?.context?.model, "grok-4.5") + XCTAssertEqual(try XCTUnwrap(usage?.context?.percentage), 30.3676, accuracy: 0.01) + } + + func testParseGrokUsageRejectsEmptyOrZeroSignals() { + XCTAssertNil(ContextMonitor.shared.parseGrokUsage(from: "{}")) + XCTAssertNil(ContextMonitor.shared.parseGrokUsage( + from: #"{"contextTokensUsed":0,"contextWindowTokens":500000}"#)) + XCTAssertNil(ContextMonitor.shared.parseGrokUsage( + from: #"{"contextTokensUsed":1000,"contextWindowTokens":0}"#)) + XCTAssertNil(ContextMonitor.shared.parseGrokUsage(from: "not json")) + } + func testParseCodexUsageReadsQuotaAndTokenRateButDoesNotReportContext() throws { let now = try XCTUnwrap(codexDate("2026-04-28T12:42:05.267Z")) let content = """ diff --git a/Tests/SessionStateTests.swift b/Tests/SessionStateTests.swift index c25cb49..3459a4c 100644 --- a/Tests/SessionStateTests.swift +++ b/Tests/SessionStateTests.swift @@ -20,9 +20,11 @@ final class SessionStateTests: XCTestCase { WorkspaceTabState(id: "tab-1", name: "Claude", isClaude: true, sessionId: "sess-1"), WorkspaceTabState(id: "tab-2", name: "Codex", kind: .codex, sessionId: "codex-1"), WorkspaceTabState(id: "tab-3", name: "Terminal", isClaude: false, sessionId: nil), + WorkspaceTabState(id: "tab-4", name: "Grok", kind: .grok, sessionId: "grok-1"), ], defaultArgs: "--permission-mode acceptEdits", - defaultCodexArgs: "--ask-for-approval never --sandbox workspace-write" + defaultCodexArgs: "--ask-for-approval never --sandbox workspace-write", + defaultGrokArgs: "--reasoning-effort high" ) ] @@ -34,7 +36,7 @@ final class SessionStateTests: XCTestCase { XCTAssertEqual(decoded.selectedTabIndex, 3) XCTAssertEqual(decoded.defaultWorkingDirectory, "/Users/test/workspace") XCTAssertEqual(decoded.workspaces?.count, 1) - XCTAssertEqual(decoded.workspaces?[0].tabs.count, 3) + XCTAssertEqual(decoded.workspaces?[0].tabs.count, 4) XCTAssertEqual(decoded.workspaces?[0].tabs[0].isClaude, true) XCTAssertEqual(decoded.workspaces?[0].tabs[0].sessionId, "sess-1") XCTAssertEqual(decoded.workspaces?[0].tabs[1].kind, .codex) @@ -42,8 +44,12 @@ final class SessionStateTests: XCTestCase { XCTAssertEqual(decoded.workspaces?[0].tabs[1].sessionId, "codex-1") XCTAssertEqual(decoded.workspaces?[0].tabs[2].kind, .terminal) XCTAssertNil(decoded.workspaces?[0].tabs[2].sessionId) + XCTAssertEqual(decoded.workspaces?[0].tabs[3].kind, .grok) + XCTAssertEqual(decoded.workspaces?[0].tabs[3].isClaude, false) + XCTAssertEqual(decoded.workspaces?[0].tabs[3].sessionId, "grok-1") XCTAssertEqual(decoded.workspaces?[0].defaultArgs, "--permission-mode acceptEdits") XCTAssertEqual(decoded.workspaces?[0].defaultCodexArgs, "--ask-for-approval never --sandbox workspace-write") + XCTAssertEqual(decoded.workspaces?[0].defaultGrokArgs, "--reasoning-effort high") } func testEmptyStateRoundtrip() throws { @@ -164,6 +170,7 @@ final class SessionStateTests: XCTestCase { 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: .grok), "grok:shared-id") XCTAssertEqual(SessionManager.sessionCacheKey(sessionId: "shared-id", kind: .terminal), "terminal:shared-id") } diff --git a/Tests/WindowControllerLogicTests.swift b/Tests/WindowControllerLogicTests.swift index e1fabb2..56e7eed 100644 --- a/Tests/WindowControllerLogicTests.swift +++ b/Tests/WindowControllerLogicTests.swift @@ -80,6 +80,10 @@ final class WindowControllerLogicTests: XCTestCase { XCTAssertEqual(TabItem.BadgeState.codexThinking.rawValue, "codexThinking") XCTAssertEqual(TabItem.BadgeState.codexError.rawValue, "codexError") XCTAssertEqual(TabItem.BadgeState.codexCompletedUnseen.rawValue, "codexCompletedUnseen") + XCTAssertEqual(TabItem.BadgeState.grokIdle.rawValue, "grokIdle") + XCTAssertEqual(TabItem.BadgeState.grokThinking.rawValue, "grokThinking") + XCTAssertEqual(TabItem.BadgeState.grokError.rawValue, "grokError") + XCTAssertEqual(TabItem.BadgeState.grokCompletedUnseen.rawValue, "grokCompletedUnseen") XCTAssertEqual(TabItem.BadgeState.terminalIdle.rawValue, "terminalIdle") XCTAssertEqual(TabItem.BadgeState.terminalActive.rawValue, "terminalActive") XCTAssertEqual(TabItem.BadgeState.terminalError.rawValue, "terminalError") @@ -94,6 +98,8 @@ final class WindowControllerLogicTests: XCTestCase { XCTAssertEqual(TabItem.BadgeState(rawValue: "needsPermission"), .needsPermission) XCTAssertEqual(TabItem.BadgeState(rawValue: "codexThinking"), .codexThinking) XCTAssertEqual(TabItem.BadgeState(rawValue: "codexCompletedUnseen"), .codexCompletedUnseen) + XCTAssertEqual(TabItem.BadgeState(rawValue: "grokThinking"), .grokThinking) + XCTAssertEqual(TabItem.BadgeState(rawValue: "grokCompletedUnseen"), .grokCompletedUnseen) XCTAssertNil(TabItem.BadgeState(rawValue: "invalid")) } @@ -104,14 +110,15 @@ final class WindowControllerLogicTests: XCTestCase { .none, .idle, .thinking, .waitingForInput, .needsPermission, .error, .codexIdle, .codexThinking, .codexError, .codexCompletedUnseen, + .grokIdle, .grokThinking, .grokError, .grokCompletedUnseen, .terminalIdle, .terminalActive, .terminalError, .completedUnseen, .terminalCompletedUnseen, ] - XCTAssertEqual(allCases.count, 15) + XCTAssertEqual(allCases.count, 19) // Verify all have distinct raw values let rawValues = Set(allCases.map(\.rawValue)) - XCTAssertEqual(rawValues.count, 15) + XCTAssertEqual(rawValues.count, 19) } // MARK: - TabKind @@ -119,15 +126,24 @@ final class WindowControllerLogicTests: XCTestCase { func testTabKindDisplayNames() { XCTAssertEqual(TabKind.claude.displayName, "Claude") XCTAssertEqual(TabKind.codex.displayName, "Codex") + XCTAssertEqual(TabKind.grok.displayName, "Grok") XCTAssertEqual(TabKind.terminal.displayName, "Terminal") } func testTabKindAgentClassification() { XCTAssertTrue(TabKind.claude.isAgent) XCTAssertTrue(TabKind.codex.isAgent) + XCTAssertTrue(TabKind.grok.isAgent) XCTAssertFalse(TabKind.terminal.isAgent) } + func testTabKindForkAtPointSupport() { + XCTAssertTrue(TabKind.claude.supportsForkAtPoint) + XCTAssertTrue(TabKind.codex.supportsForkAtPoint) + XCTAssertFalse(TabKind.grok.supportsForkAtPoint) + XCTAssertFalse(TabKind.terminal.supportsForkAtPoint) + } + // MARK: - WorkspaceItem func testWorkspaceItemInit() { @@ -219,6 +235,17 @@ final class WindowControllerLogicTests: XCTestCase { } } + func testDefaultTabConfigParsesGrokTabs() { + withUserDefaultsValue("defaultTabConfig", value: "claude, grok") { + let config = DefaultTabConfig.current + + XCTAssertEqual(config.entries.count, 2) + XCTAssertEqual(config.entries[0].kind, .claude) + XCTAssertEqual(config.entries[1].kind, .grok) + XCTAssertEqual(config.entries[1].name, "Grok") + } + } + // MARK: - Badge animation defaults func testCodexThinkingBadgeIsAnimatedByDefault() { @@ -240,6 +267,25 @@ final class WindowControllerLogicTests: XCTestCase { } } + func testGrokThinkingBadgeIsAnimatedByDefault() { + withUserDefaultsValue("badgeAnimate.grokThinking", value: nil) { + XCTAssertTrue(SettingsWindowController.isBadgeAnimated(.grokThinking)) + } + } + + func testGrokNonWorkingBadgesAreNotAnimatedByDefault() { + let keys = [ + "badgeAnimate.grokIdle", + "badgeAnimate.grokError", + "badgeAnimate.grokCompletedUnseen", + ] + withRemovedUserDefaults(keys) { + XCTAssertFalse(SettingsWindowController.isBadgeAnimated(.grokIdle)) + XCTAssertFalse(SettingsWindowController.isBadgeAnimated(.grokError)) + XCTAssertFalse(SettingsWindowController.isBadgeAnimated(.grokCompletedUnseen)) + } + } + // MARK: - Shortcut policy func testShortcutPolicyRejectsReporterOptionArrowShortcuts() { From 1c5d2f6960ded4b6f9f11522a1d2012728493898 Mon Sep 17 00:00:00 2001 From: Gilles Dubuc Date: Sun, 12 Jul 2026 02:58:43 +0800 Subject: [PATCH 2/2] fix: lay out badge tables in two rows to fit fixed settings width Co-Authored-By: Claude Fable 5 --- Sources/Window/SettingsWindow.swift | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/Sources/Window/SettingsWindow.swift b/Sources/Window/SettingsWindow.swift index f993a04..bea017f 100644 --- a/Sources/Window/SettingsWindow.swift +++ b/Sources/Window/SettingsWindow.swift @@ -974,9 +974,20 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie let grokTable = makeSectionTable("Grok", Self.grokBadgeEntries) let terminalTable = makeSectionTable("Terminal", Self.terminalBadgeEntries) - let hStack = NSStackView(views: [claudeTable, codexTable, grokTable, terminalTable]) - hStack.orientation = .horizontal - hStack.alignment = .top + // Two rows of tables — four across would exceed the fixed pane width. + let topRow = NSStackView(views: [claudeTable, codexTable]) + topRow.orientation = .horizontal + topRow.alignment = .top + topRow.spacing = 12 + + let bottomRow = NSStackView(views: [grokTable, terminalTable]) + bottomRow.orientation = .horizontal + bottomRow.alignment = .top + bottomRow.spacing = 12 + + let hStack = NSStackView(views: [topRow, bottomRow]) + hStack.orientation = .vertical + hStack.alignment = .leading hStack.spacing = 12 // Wrap in a vertical stack with the reset button