diff --git a/Sources/Session/SessionState.swift b/Sources/Session/SessionState.swift index 5e993f7..c0ff2ff 100644 --- a/Sources/Session/SessionState.swift +++ b/Sources/Session/SessionState.swift @@ -36,6 +36,7 @@ struct ProjectState: Codable { var name: String var selectedTabIndex: Int var tabs: [ProjectTabState] + var defaultArgs: String? } struct ProjectTabState: Codable { diff --git a/Sources/Window/DeckardWindowController.swift b/Sources/Window/DeckardWindowController.swift index 6fca1ca..2887791 100644 --- a/Sources/Window/DeckardWindowController.swift +++ b/Sources/Window/DeckardWindowController.swift @@ -69,6 +69,7 @@ class ProjectItem { var name: String // basename of path var tabs: [TabItem] = [] var selectedTabIndex: Int = 0 + var defaultArgs: String? init(path: String) { self.id = UUID() @@ -660,7 +661,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { let initialInput: String? if isClaude { - let resolvedArgs = extraArgs ?? UserDefaults.standard.string(forKey: "claudeExtraArgs") ?? "" + let resolvedArgs = extraArgs ?? project.defaultArgs ?? UserDefaults.standard.string(forKey: "claudeExtraArgs") ?? "" let extraArgsSuffix = resolvedArgs.isEmpty ? "" : " \(resolvedArgs)" var claudeArgs = extraArgsSuffix if let sessionIdToResume { @@ -714,7 +715,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { let project = projects[selectedProjectIndex] if isClaude && UserDefaults.standard.bool(forKey: "promptForSessionArgs") { - promptForClaudeArgs { [weak self] args in + promptForClaudeArgs(for: project) { [weak self] args in guard let self else { return } guard let args else { // User cancelled @@ -746,7 +747,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { } } - private func promptForClaudeArgs(completion: @escaping (String?) -> Void) { + private func promptForClaudeArgs(for project: ProjectItem, completion: @escaping (String?) -> Void) { let alert = NSAlert() alert.messageText = "Claude Code Arguments" alert.informativeText = "Arguments passed to this session:" @@ -754,7 +755,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { alert.addButton(withTitle: "Cancel") let field = ClaudeArgsField(frame: NSRect(x: 0, y: 0, width: 400, height: 60)) - field.stringValue = UserDefaults.standard.string(forKey: "claudeExtraArgs") ?? "" + field.stringValue = project.defaultArgs ?? UserDefaults.standard.string(forKey: "claudeExtraArgs") ?? "" alert.accessoryView = field guard let window else { @@ -1228,7 +1229,8 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { sessionId: tab.sessionId, tmuxSessionName: tab.surface.tmuxSessionName ) - } + }, + defaultArgs: project.defaultArgs ) } @@ -1289,6 +1291,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate { for (i, ps) in projectStates.enumerated() { let project = ProjectItem(path: ps.path) project.name = ps.name + project.defaultArgs = ps.defaultArgs let selTab = min(max(ps.selectedTabIndex, 0), max(ps.tabs.count - 1, 0)) diff --git a/Sources/Window/SettingsWindow.swift b/Sources/Window/SettingsWindow.swift index c410590..8425691 100644 --- a/Sources/Window/SettingsWindow.swift +++ b/Sources/Window/SettingsWindow.swift @@ -116,13 +116,13 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie let grid = NSGridView(numberOfColumns: 2, rows: 0) grid.translatesAutoresizingMaskIntoConstraints = false grid.column(at: 0).xPlacement = .trailing - grid.column(at: 0).width = 120 + grid.column(at: 0).width = 175 grid.column(at: 1).xPlacement = .fill grid.rowSpacing = 6 grid.columnSpacing = 8 // Extra arguments - let extraArgsLabel = NSTextField(labelWithString: "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)) @@ -135,7 +135,7 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie grid.addRow(with: [extraArgsLabel, extraArgsField]) - let extraArgsHelp = NSTextField(labelWithString: "Arguments passed to every new Claude Code session.") + 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]) diff --git a/Sources/Window/SidebarController.swift b/Sources/Window/SidebarController.swift index daf91bb..1f95b1d 100644 --- a/Sources/Window/SidebarController.swift +++ b/Sources/Window/SidebarController.swift @@ -584,6 +584,11 @@ extension DeckardWindowController { exploreItem.representedObject = project menu.addItem(exploreItem) + let defaultArgsItem = NSMenuItem(title: "Default Claude Arguments\u{2026}", action: #selector(defaultArgsMenuAction(_:)), keyEquivalent: "") + defaultArgsItem.target = self + defaultArgsItem.representedObject = project + menu.addItem(defaultArgsItem) + menu.addItem(.separator()) // Folder options @@ -693,6 +698,28 @@ extension DeckardWindowController { objc_setAssociatedObject(explorer.window!, "explorerController", explorer, .OBJC_ASSOCIATION_RETAIN) } + @objc func defaultArgsMenuAction(_ sender: NSMenuItem) { + guard let project = sender.representedObject as? ProjectItem, + let window else { return } + + let alert = NSAlert() + alert.messageText = "Default Arguments for \(project.name)" + alert.informativeText = "These arguments will be used for new Claude 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)) + field.stringValue = project.defaultArgs ?? "" + alert.accessoryView = field + + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertFirstButtonReturn else { return } + let value = field.stringValue.trimmingCharacters(in: .whitespaces) + project.defaultArgs = value.isEmpty ? nil : value + self?.saveState() + } + } + // MARK: - Sidebar Selection func updateSidebarSelection() { diff --git a/docs/superpowers/plans/2026-04-07-project-default-args.md b/docs/superpowers/plans/2026-04-07-project-default-args.md new file mode 100644 index 0000000..2bb42bf --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-project-default-args.md @@ -0,0 +1,261 @@ +# Project-Level Default Arguments Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add per-project default arguments for Claude Code sessions with a three-tier resolution hierarchy (per-session dialog > project defaults > global defaults). + +**Architecture:** Add `defaultArgs: String?` to the runtime `ProjectItem` class and the persistence `ProjectState` struct, insert a project tier into the argument resolution chain, and expose a "Default Arguments..." context menu item on sidebar projects that opens a sheet with `ClaudeArgsField`. + +**Tech Stack:** Swift, AppKit (NSMenu, NSAlert, ClaudeArgsField) + +--- + +### Task 1: Add `defaultArgs` to Data Models + +**Files:** +- Modify: `Sources/Window/DeckardWindowController.swift:66-78` (ProjectItem class) +- Modify: `Sources/Session/SessionState.swift:33-39` (ProjectState struct) + +- [ ] **Step 1: Add `defaultArgs` property to `ProjectItem`** + +In `Sources/Window/DeckardWindowController.swift`, add the property to the `ProjectItem` class: + +```swift +class ProjectItem { + let id: UUID + var path: String + var name: String // basename of path + var tabs: [TabItem] = [] + var selectedTabIndex: Int = 0 + var defaultArgs: String? + + init(path: String) { + self.id = UUID() + self.path = (path as NSString).resolvingSymlinksInPath + self.name = (self.path as NSString).lastPathComponent + } +} +``` + +- [ ] **Step 2: Add `defaultArgs` field to `ProjectState`** + +In `Sources/Session/SessionState.swift`, add the field to the `ProjectState` struct: + +```swift +struct ProjectState: Codable { + var id: String + var path: String + var name: String + var selectedTabIndex: Int + var tabs: [ProjectTabState] + var defaultArgs: String? +} +``` + +Since the field is `Optional` and `Codable`, existing `state.json` files without it will decode with `nil` automatically. No migration needed. + +- [ ] **Step 3: Build to verify compilation** + +Run: `xcodebuild -project Deckard.xcodeproj -scheme Deckard -configuration Debug build 2>&1 | tail -5` +Expected: `** BUILD SUCCEEDED **` + +- [ ] **Step 4: Commit** + +```bash +git add Sources/Window/DeckardWindowController.swift Sources/Session/SessionState.swift +git commit -m "feat: add defaultArgs property to ProjectItem and ProjectState" +``` + +--- + +### Task 2: Update Persistence (capture + restore) + +**Files:** +- Modify: `Sources/Window/DeckardWindowController.swift:1217-1233` (captureState) +- Modify: `Sources/Window/DeckardWindowController.swift:1289-1308` (restoreOrCreateInitial) + +- [ ] **Step 1: Update `captureState()` to persist `defaultArgs`** + +In `Sources/Window/DeckardWindowController.swift`, in the `captureState()` method, add `defaultArgs` to the `ProjectState` initializer (around line 1218): + +```swift +state.projects = projects.map { project in + ProjectState( + id: project.id.uuidString, + path: project.path, + name: project.name, + selectedTabIndex: project.selectedTabIndex, + tabs: project.tabs.map { tab in + ProjectTabState( + id: tab.id.uuidString, + name: tab.name, + isClaude: tab.isClaude, + sessionId: tab.sessionId, + tmuxSessionName: tab.surface.tmuxSessionName + ) + }, + defaultArgs: project.defaultArgs + ) +} +``` + +- [ ] **Step 2: Update `restoreOrCreateInitial()` to restore `defaultArgs`** + +In `Sources/Window/DeckardWindowController.swift`, in the restore loop (around line 1290), set `defaultArgs` after creating the `ProjectItem`: + +```swift +let project = ProjectItem(path: ps.path) +project.name = ps.name +project.defaultArgs = ps.defaultArgs +``` + +- [ ] **Step 3: Build to verify compilation** + +Run: `xcodebuild -project Deckard.xcodeproj -scheme Deckard -configuration Debug build 2>&1 | tail -5` +Expected: `** BUILD SUCCEEDED **` + +- [ ] **Step 4: Commit** + +```bash +git add Sources/Window/DeckardWindowController.swift +git commit -m "feat: persist and restore project defaultArgs in state.json" +``` + +--- + +### Task 3: Update Argument Resolution and Dialog Pre-Fill + +**Files:** +- Modify: `Sources/Window/DeckardWindowController.swift:663` (resolution in createTabInProject) +- Modify: `Sources/Window/DeckardWindowController.swift:716-734` (addTabToCurrentProject — pass project to dialog) +- Modify: `Sources/Window/DeckardWindowController.swift:749-772` (promptForClaudeArgs — accept project defaults) + +- [ ] **Step 1: Update argument resolution in `createTabInProject()`** + +In `Sources/Window/DeckardWindowController.swift`, change line 663 from: + +```swift +let resolvedArgs = extraArgs ?? UserDefaults.standard.string(forKey: "claudeExtraArgs") ?? "" +``` + +to: + +```swift +let resolvedArgs = extraArgs ?? project.defaultArgs ?? UserDefaults.standard.string(forKey: "claudeExtraArgs") ?? "" +``` + +- [ ] **Step 2: Update `promptForClaudeArgs()` to accept a project parameter for pre-fill** + +Change the method signature and pre-fill logic: + +```swift +private func promptForClaudeArgs(for project: ProjectItem, completion: @escaping (String?) -> Void) { + let alert = NSAlert() + alert.messageText = "Claude Code 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)) + field.stringValue = project.defaultArgs ?? UserDefaults.standard.string(forKey: "claudeExtraArgs") ?? "" + 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) + } + } +} +``` + +- [ ] **Step 3: Update the call site in `addTabToCurrentProject()` to pass the project** + +Change line 717 from: + +```swift +promptForClaudeArgs { [weak self] args in +``` + +to: + +```swift +promptForClaudeArgs(for: project) { [weak self] args in +``` + +- [ ] **Step 4: Build to verify compilation** + +Run: `xcodebuild -project Deckard.xcodeproj -scheme Deckard -configuration Debug build 2>&1 | tail -5` +Expected: `** BUILD SUCCEEDED **` + +- [ ] **Step 5: Commit** + +```bash +git add Sources/Window/DeckardWindowController.swift +git commit -m "feat: three-tier argument resolution with project-level defaults" +``` + +--- + +### Task 4: Add Context Menu Item and Sheet + +**Files:** +- Modify: `Sources/Window/SidebarController.swift:578-627` (buildProjectContextMenu) +- Modify: `Sources/Window/SidebarController.swift` (new action method) + +- [ ] **Step 1: Add "Default Arguments..." menu item to `buildProjectContextMenu()`** + +In `Sources/Window/SidebarController.swift`, insert the new item after the "Explore Sessions" item (after line 585, before the separator on line 587): + +```swift +let defaultArgsItem = NSMenuItem(title: "Default Arguments\u{2026}", action: #selector(defaultArgsMenuAction(_:)), keyEquivalent: "") +defaultArgsItem.target = self +defaultArgsItem.representedObject = project +menu.addItem(defaultArgsItem) +``` + +- [ ] **Step 2: Add the action method that presents the sheet** + +Add below the `exploreSessionsMenuAction` method (after line 665 area): + +```swift +@objc func defaultArgsMenuAction(_ sender: NSMenuItem) { + guard let project = sender.representedObject as? ProjectItem, + let window else { return } + + let alert = NSAlert() + alert.messageText = "Default Arguments for \(project.name)" + alert.informativeText = "These arguments will be used for new Claude 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)) + field.stringValue = project.defaultArgs ?? "" + alert.accessoryView = field + + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertFirstButtonReturn else { return } + let value = field.stringValue.trimmingCharacters(in: .whitespaces) + project.defaultArgs = value.isEmpty ? nil : value + self?.saveState() + } +} +``` + +- [ ] **Step 3: Build to verify compilation** + +Run: `xcodebuild -project Deckard.xcodeproj -scheme Deckard -configuration Debug build 2>&1 | tail -5` +Expected: `** BUILD SUCCEEDED **` + +- [ ] **Step 4: Commit** + +```bash +git add Sources/Window/SidebarController.swift +git commit -m "feat: add 'Default Arguments' context menu for projects" +``` diff --git a/docs/superpowers/specs/2026-04-07-project-default-args-design.md b/docs/superpowers/specs/2026-04-07-project-default-args-design.md new file mode 100644 index 0000000..c899630 --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-project-default-args-design.md @@ -0,0 +1,94 @@ +# Project-Level Default Arguments + +**Date:** 2026-04-07 +**Issue:** [#46 comment](https://github.com/gi11es/deckard/issues/46#issuecomment-4152705055) + +## Overview + +Add per-project default arguments for Claude Code sessions. The resolution hierarchy is: + +``` +per-session dialog > project defaults > global defaults +``` + +Each tier fully replaces the one below it (no merging). + +## Data Model + +Add `defaultArgs: String?` to both runtime and persistence models: + +- **`ProjectItem`** (runtime class): `var defaultArgs: String?` +- **`ProjectState`** (persistence struct): `var defaultArgs: String?` + +Semantics: +- `nil` — no project override; fall back to global defaults +- Non-empty string — use exactly these args, ignoring global defaults + +Empty string in the UI is treated as "clear the override" and stored as `nil`. + +Since the field is optional and `Codable`, existing `state.json` files without it decode with `nil` automatically. No migration needed. + +## Argument Resolution + +Current logic in `createTabInProject()`: + +```swift +let resolvedArgs = extraArgs ?? UserDefaults.standard.string(forKey: "claudeExtraArgs") ?? "" +``` + +New logic: + +```swift +let resolvedArgs = extraArgs ?? project.defaultArgs ?? UserDefaults.standard.string(forKey: "claudeExtraArgs") ?? "" +``` + +Where `extraArgs` is only present when the per-session prompt is enabled and the user accepted. + +## Per-Session Dialog Pre-Fill + +When the per-session dialog is shown, it pre-fills with: + +```swift +project.defaultArgs ?? UserDefaults.standard.string(forKey: "claudeExtraArgs") ?? "" +``` + +Instead of always using global defaults. This way the dialog reflects the effective defaults for that project. + +## UI: Context Menu + Sheet + +### Context Menu + +Add a "Default Arguments..." item to `buildProjectContextMenu()` in `SidebarController.swift`. Placement: after "Explore Sessions", before the folder-management section. + +The menu item receives the `ProjectItem` as `representedObject`. + +### Sheet + +The menu action presents an `NSAlert` as a sheet (same pattern as the existing per-session dialog): + +- **Title:** "Default Arguments for [project name]" +- **Informative text:** "These arguments will be used for new Claude tabs in this project, overriding global defaults. Leave empty to clear." +- **Accessory view:** `ClaudeArgsField`, pre-filled with `project.defaultArgs ?? ""` +- **Buttons:** "Save" and "Cancel" + +On Save: +- If field is empty → set `project.defaultArgs = nil` (clear override) +- If field is non-empty → set `project.defaultArgs = field.stringValue` +- Mark state dirty for autosave + +On Cancel: no changes. + +## Persistence + +`captureState()` copies `project.defaultArgs` into the `ProjectState`. +`restoreOrCreateInitial()` restores `defaultArgs` from `ProjectState` back onto the `ProjectItem`. + +## Scope + +This feature only affects **new Claude tabs**. Existing tabs (including restored tabs on relaunch) are not retroactively affected — they already had their args baked in at creation time. + +## Files Changed + +1. **`DeckardWindowController.swift`** — add `defaultArgs` to `ProjectItem`, update `createTabInProject()` resolution and dialog pre-fill +2. **`SessionState.swift`** — add `defaultArgs` to `ProjectState`, update `captureState()` and `restoreOrCreateInitial()` +3. **`SidebarController.swift`** — add context menu item and sheet action