Skip to content

Commit 8a7420b

Browse files
committed
feat: add kanban CLI and centralize shortcut display strings
- Install `kanban` CLI to ~/.local/bin on app launch - `kanban` opens the app, `kanban .` or `kanban <path>` opens a project - Uses file-based IPC to avoid URL scheme duplicate window issues - Creates project automatically if it doesn't exist - Add `kanbancode://open?path=...` deep link for cold launch - Add `displayString` computed property to AppShortcut enum - Derives display (e.g. "⌘N") from key + modifiers automatically - All CommandItem shortcuts now reference AppShortcut.displayString - Add `newTask` and `openSettings` cases to AppShortcut - Move CommandItem to its own file - Show "⌘N" shortcut hint on New Task buttons - Fix "No sessions yet" blinking caused by setLoading on every reconcile
1 parent df8e920 commit 8a7420b

8 files changed

Lines changed: 140 additions & 26 deletions

File tree

Sources/KanbanCode/App.swift

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, UNUs
8989
NSApp.applicationIconImage = icon
9090
}
9191

92+
// Install `kanban` CLI to ~/.local/bin
93+
Self.installCLI()
94+
9295
// Set up notifications: delegate must be set BEFORE requesting authorization
9396
let center = UNUserNotificationCenter.current()
9497
center.delegate = self
@@ -101,6 +104,51 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, UNUs
101104
}
102105
}
103106

107+
/// Install a `kanban` shell script to ~/.local/bin so users can run
108+
/// `kanban`, `kanban .`, or `kanban /path/to/project` from any terminal.
109+
private static func installCLI() {
110+
let binDir = FileManager.default.homeDirectoryForCurrentUser
111+
.appendingPathComponent(".local/bin")
112+
let scriptPath = binDir.appendingPathComponent("kanban")
113+
// The CLI writes the path to a file, then activates the app.
114+
// The app picks up the file in applicationDidBecomeActive.
115+
// This avoids URL scheme issues that create duplicate windows.
116+
let script = """
117+
#!/bin/sh
118+
# Installed by Kanban Code — opens the app and selects a project.
119+
# Usage: kanban [path] (defaults to current directory)
120+
if [ -n "$1" ]; then
121+
resolved="$(cd "$1" 2>/dev/null && pwd -P || echo "$1")"
122+
mkdir -p ~/.kanban-code
123+
echo "$resolved" > ~/.kanban-code/open-project
124+
fi
125+
open -a "KanbanCode"
126+
"""
127+
do {
128+
try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true)
129+
try script.write(to: scriptPath, atomically: true, encoding: .utf8)
130+
try FileManager.default.setAttributes(
131+
[.posixPermissions: 0o755], ofItemAtPath: scriptPath.path
132+
)
133+
} catch {
134+
print("[Kanban Code] Failed to install CLI: \(error)")
135+
}
136+
}
137+
138+
/// Check for a pending project open request from the CLI.
139+
func applicationDidBecomeActive(_ notification: Notification) {
140+
let file = FileManager.default.homeDirectoryForCurrentUser
141+
.appendingPathComponent(".kanban-code/open-project")
142+
guard let path = try? String(contentsOf: file, encoding: .utf8)
143+
.trimmingCharacters(in: .whitespacesAndNewlines),
144+
!path.isEmpty else { return }
145+
try? FileManager.default.removeItem(at: file)
146+
NotificationCenter.default.post(
147+
name: .kanbanCodeOpenProject, object: nil,
148+
userInfo: ["path": path]
149+
)
150+
}
151+
104152
/// Prevent Cmd+W from closing the single window — close terminal tab instead.
105153
func windowShouldClose(_ sender: NSWindow) -> Bool {
106154
NotificationCenter.default.post(name: .kanbanCloseTerminalTab, object: nil)
@@ -150,7 +198,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, UNUs
150198
process.waitUntilExit()
151199
}
152200

153-
// Handle kanbancode:// deep links (from Pushover tap, browser, etc.)
201+
// Handle kanbancode:// deep links (from Pushover tap, browser, CLI, etc.)
154202
func application(_ application: NSApplication, open urls: [URL]) {
155203
for url in urls {
156204
guard url.scheme == "kanbancode" else { continue }
@@ -162,6 +210,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, UNUs
162210
userInfo: ["cardId": cardId]
163211
)
164212
}
213+
// kanbancode://open?path=/some/project
214+
if url.host == "open",
215+
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
216+
let path = components.queryItems?.first(where: { $0.name == "path" })?.value,
217+
!path.isEmpty {
218+
NotificationCenter.default.post(
219+
name: .kanbanCodeOpenProject, object: nil,
220+
userInfo: ["path": path]
221+
)
222+
}
165223
}
166224
NSApp.activate(ignoringOtherApps: true)
167225
if let window = NSApp.windows.first(where: { $0.canBecomeMain }) {
@@ -236,4 +294,5 @@ extension Notification.Name {
236294
static let kanbanCloseTerminalTab = Notification.Name("kanbanCloseTerminalTab")
237295
static let chatCardExpanded = Notification.Name("chatCardExpanded")
238296
static let kanbanCodeAddLink = Notification.Name("kanbanCodeAddLink")
297+
static let kanbanCodeOpenProject = Notification.Name("kanbanCodeOpenProject")
239298
}

Sources/KanbanCode/BoardView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ struct BoardView: View {
145145
.foregroundStyle(.tertiary)
146146

147147
Button(action: onNewTask) {
148-
Label("New Task", systemImage: "plus")
148+
Label("New Task \(AppShortcut.newTask.displayString)", systemImage: "plus")
149149
}
150150
.buttonStyle(.borderedProminent)
151151
.controlSize(.small)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import SwiftUI
2+
3+
struct CommandItem: Identifiable {
4+
let id: String
5+
let title: String
6+
let icon: String
7+
let shortcut: String?
8+
let action: () -> Void
9+
10+
init(_ title: String, icon: String, shortcut: String? = nil, action: @escaping () -> Void) {
11+
self.id = "cmd:\(title)"
12+
self.title = title
13+
self.icon = icon
14+
self.shortcut = shortcut
15+
self.action = action
16+
}
17+
}

Sources/KanbanCode/ContentView.swift

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,6 +1033,11 @@ struct ContentView: View {
10331033
store.dispatch(.selectCard(cardId: cardId))
10341034
}
10351035
}
1036+
.onReceive(NotificationCenter.default.publisher(for: .kanbanCodeOpenProject)) { notification in
1037+
if let path = notification.userInfo?["path"] as? String {
1038+
openOrCreateProject(path: path)
1039+
}
1040+
}
10361041
.onReceive(NotificationCenter.default.publisher(for: .kanbanCodeAddLink)) { notification in
10371042
if let cardId = notification.userInfo?["cardId"] as? String {
10381043
showAddLinkCardId = cardId
@@ -1575,10 +1580,10 @@ struct ContentView: View {
15751580

15761581
private var paletteCommands: [CommandItem] {
15771582
var cmds: [CommandItem] = [
1578-
CommandItem("Open Settings", icon: "gear", shortcut: "⌘,") {
1583+
CommandItem("Open Settings", icon: "gear", shortcut: AppShortcut.openSettings.displayString) {
15791584
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
15801585
},
1581-
CommandItem("Toggle View Mode", icon: isExpandedDetail ? "square.split.2x1" : "list.bullet", shortcut: "⌘↩") { [self] in
1586+
CommandItem("Toggle View Mode", icon: isExpandedDetail ? "square.split.2x1" : "list.bullet", shortcut: AppShortcut.toggleExpanded.displayString) { [self] in
15821587
if isExpandedDetail {
15831588
isExpandedDetail = false
15841589
boardViewModeRaw = BoardViewMode.kanban.rawValue
@@ -1588,19 +1593,20 @@ struct ContentView: View {
15881593
boardViewModeRaw = BoardViewMode.list.rawValue
15891594
}
15901595
},
1591-
CommandItem("New Task", icon: "plus", shortcut: "⌘N") { [self] in
1596+
CommandItem("New Task", icon: "plus", shortcut: AppShortcut.newTask.displayString) { [self] in
15921597
presentNewTask()
15931598
},
15941599
]
15951600

15961601
// Project switching
15971602
let visibleProjects = store.state.configuredProjects.filter(\.visible)
15981603
if !visibleProjects.isEmpty {
1599-
cmds.append(CommandItem("Show All Projects", icon: "folder", shortcut: "⌘1") { [self] in
1604+
cmds.append(CommandItem("Show All Projects", icon: "folder", shortcut: AppShortcut.project1.displayString) { [self] in
16001605
setSelectedProject(nil)
16011606
})
16021607
for (i, project) in visibleProjects.enumerated() {
1603-
let shortcut = i < 8 ? "\(i + 2)" : nil
1608+
let projectShortcuts: [AppShortcut] = [.project2, .project3, .project4, .project5, .project6, .project7, .project8, .project9]
1609+
let shortcut = i < projectShortcuts.count ? projectShortcuts[i].displayString : nil
16041610
let path = project.path
16051611
cmds.append(CommandItem("Switch to \(project.name)", icon: "folder", shortcut: shortcut) { [self] in
16061612
setSelectedProject(path)
@@ -2033,6 +2039,24 @@ struct ContentView: View {
20332039
}
20342040
}
20352041

2042+
/// Open a project by path — select it if it exists, otherwise create and select it.
2043+
/// Called from the `kanban` CLI via file-based IPC.
2044+
private func openOrCreateProject(path: String) {
2045+
// Check if project already exists (match by path or repoRoot)
2046+
if store.state.configuredProjects.contains(where: { $0.path == path || $0.repoRoot == path }) {
2047+
setSelectedProject(path)
2048+
return
2049+
}
2050+
// Project doesn't exist — create it, select first to avoid empty flash
2051+
let project = Project(path: path)
2052+
setSelectedProject(path)
2053+
Task {
2054+
try? await settingsStore.addProject(project)
2055+
await store.loadSettingsAndCache()
2056+
await store.reconcile()
2057+
}
2058+
}
2059+
20362060
// MARK: - Add from Path Sheet
20372061

20382062
private var addFromPathSheet: some View {

Sources/KanbanCode/KeyboardShortcuts.swift

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ enum AppShortcut: CaseIterable {
3030
case openPaletteP // Cmd+P
3131
case openCommandMode // Cmd+Shift+P
3232

33+
// Global actions
34+
case newTask // Cmd+N
35+
case openSettings // Cmd+,
36+
3337
// Detail panel
3438
case toggleExpanded // Cmd+Enter (only when palette closed)
3539
case toggleSidebar // Cmd+B (only in expanded mode, palette closed)
@@ -49,6 +53,7 @@ enum AppShortcut: CaseIterable {
4953

5054
static var allCases: [AppShortcut] {
5155
[.openPaletteK, .openPaletteP, .openCommandMode,
56+
.newTask, .openSettings,
5257
.toggleExpanded, .toggleSidebar, .newTerminal, .deepSearch,
5358
.deselect, .deleteCard, .deleteCardForward,
5459
.project1, .project2, .project3, .project4, .project5,
@@ -60,6 +65,8 @@ enum AppShortcut: CaseIterable {
6065
case .openPaletteK: return "k"
6166
case .openPaletteP: return "p"
6267
case .openCommandMode: return "p"
68+
case .newTask: return "n"
69+
case .openSettings: return ","
6370
case .toggleExpanded, .deepSearch: return .return
6471
case .toggleSidebar: return "b"
6572
case .newTerminal: return "t"
@@ -82,6 +89,7 @@ enum AppShortcut: CaseIterable {
8289
switch self {
8390
case .openPaletteK, .openPaletteP: return .command
8491
case .openCommandMode: return [.command, .shift]
92+
case .newTask, .openSettings: return .command
8593
case .toggleExpanded, .deepSearch: return .command
8694
case .toggleSidebar: return .command
8795
case .newTerminal: return .command
@@ -91,11 +99,32 @@ enum AppShortcut: CaseIterable {
9199
}
92100
}
93101

102+
/// Human-readable shortcut string derived from key + modifiers (e.g. "⌘N").
103+
var displayString: String {
104+
var parts = ""
105+
if modifiers.contains(.control) { parts += "" }
106+
if modifiers.contains(.option) { parts += "" }
107+
if modifiers.contains(.shift) { parts += "" }
108+
if modifiers.contains(.command) { parts += "" }
109+
110+
let keyStr: String
111+
switch key {
112+
case .return: keyStr = ""
113+
case .escape: keyStr = ""
114+
case .delete: keyStr = ""
115+
case .deleteForward: keyStr = ""
116+
case .space: keyStr = ""
117+
default: keyStr = String(key.character).uppercased()
118+
}
119+
return parts + keyStr
120+
}
121+
94122
/// Whether this shortcut should be active given the current context.
95123
func isActive(in ctx: AppShortcutContext) -> Bool {
96124
switch self {
97125
// Palette open/close works everywhere
98-
case .openPaletteK, .openPaletteP, .openCommandMode:
126+
case .openPaletteK, .openPaletteP, .openCommandMode,
127+
.newTask, .openSettings:
99128
return true
100129

101130
// Toggle between kanban and expanded+sidebar mode

Sources/KanbanCode/ListBoardView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ struct ListBoardView: View {
169169
.foregroundStyle(.tertiary)
170170

171171
Button(action: onNewTask) {
172-
Label("New Task", systemImage: "plus")
172+
Label("New Task \(AppShortcut.newTask.displayString)", systemImage: "plus")
173173
}
174174
.buttonStyle(.borderedProminent)
175175
.controlSize(.small)

Sources/KanbanCode/SearchOverlay.swift

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,6 @@
11
import SwiftUI
22
import KanbanCodeCore
33

4-
struct CommandItem: Identifiable {
5-
let id: String
6-
let title: String
7-
let icon: String
8-
let shortcut: String?
9-
let action: () -> Void
10-
11-
init(_ title: String, icon: String, shortcut: String? = nil, action: @escaping () -> Void) {
12-
self.id = "cmd:\(title)"
13-
self.title = title
14-
self.icon = icon
15-
self.shortcut = shortcut
16-
self.action = action
17-
}
18-
}
19-
204
struct SearchOverlay: View {
215
@Binding var isPresented: Bool
226
let cards: [KanbanCodeCard]

Sources/KanbanCodeCore/UseCases/BoardStore.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1462,7 +1462,8 @@ public final class BoardStore: @unchecked Sendable {
14621462
isReconciling = true
14631463
defer { isReconciling = false }
14641464

1465-
dispatch(.setLoading(true))
1465+
// Only show loading indicator on first reconcile, not periodic refreshes
1466+
if state.links.isEmpty { dispatch(.setLoading(true)) }
14661467
let reconcileStart = ContinuousClock.now
14671468

14681469
do {

0 commit comments

Comments
 (0)