Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Sources/KanbanCode/CardDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1537,8 +1537,8 @@ struct CardDetailView: View {
guard let path = card.link.sessionLink?.sessionPath ?? card.session?.jsonlPath else { return }
if turns.isEmpty { isLoadingHistory = true }
do {
if card.link.effectiveAssistant == .gemini {
// Gemini uses JSON format — load all turns via session store
if card.link.effectiveAssistant != .claude {
// Non-Claude assistants load the full transcript via their session store.
let allTurns = try await sessionStore.readTranscript(sessionPath: path)
turns = allTurns
hasMoreTurns = false
Expand Down
5 changes: 5 additions & 0 deletions Sources/KanbanCode/CardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ struct AssistantIcon: View {
SessionIcon()
case .gemini:
GeminiSparkle()
case .mastracode:
Image(systemName: "point.3.connected.trianglepath.dotted")
.font(.system(size: 14, weight: .semibold))
}
}

Expand All @@ -317,6 +320,8 @@ struct AssistantIcon: View {
return SessionIcon.resizedForMenu(src, to: size)
case .gemini:
return geminiMenuImage(size: size)
case .mastracode:
return NSImage(systemSymbolName: "point.3.connected.trianglepath.dotted", accessibilityDescription: nil)
}
}

Expand Down
78 changes: 61 additions & 17 deletions Sources/KanbanCode/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ struct ContentView: View {
let geminiDiscovery = GeminiSessionDiscovery()
let geminiDetector = GeminiActivityDetector()
let geminiStore = GeminiSessionStore()
let mastraDiscovery = MastracodeSessionDiscovery()
let mastraDetector = MastracodeActivityDetector()
let mastraStore = MastracodeSessionStore()

let enabledAssistants = Self.loadEnabledAssistants()
let registry = CodingAssistantRegistry()
Expand All @@ -118,6 +121,9 @@ struct ContentView: View {
if enabledAssistants.contains(.gemini) {
registry.register(.gemini, discovery: geminiDiscovery, detector: geminiDetector, store: geminiStore)
}
if enabledAssistants.contains(.mastracode) {
registry.register(.mastracode, discovery: mastraDiscovery, detector: mastraDetector, store: mastraStore)
}

let discovery = CompositeSessionDiscovery(registry: registry)
let activityDetector = CompositeActivityDetector(registry: registry, defaultDetector: claudeDetector)
Expand Down Expand Up @@ -215,6 +221,8 @@ struct ContentView: View {
assistantRegistry.register(.claude, discovery: ClaudeCodeSessionDiscovery(), detector: ClaudeCodeActivityDetector(), store: ClaudeCodeSessionStore())
case .gemini:
assistantRegistry.register(.gemini, discovery: GeminiSessionDiscovery(), detector: GeminiActivityDetector(), store: GeminiSessionStore())
case .mastracode:
assistantRegistry.register(.mastracode, discovery: MastracodeSessionDiscovery(), detector: MastracodeActivityDetector(), store: MastracodeSessionStore())
}
}
} else {
Expand Down Expand Up @@ -1974,9 +1982,9 @@ struct ContentView: View {
try? RemoteShellManager.deploy()
shellOverride = RemoteShellManager.shellOverridePath()
var env = RemoteShellManager.setupEnvironment(remote: remote, projectPath: projectPath)
// Gemini CLI uses `bash -c` directly (not $SHELL), so prepend
// Node-based CLIs use `bash -c` directly (not $SHELL), so prepend
// our remote dir to PATH so it finds our bash wrapper first.
if assistant == .gemini {
if assistant.needsRemotePathShim {
let remoteDir = RemoteShellManager.remoteDirPath()
env["PATH"] = "\(remoteDir):$PATH"
}
Expand All @@ -2002,11 +2010,19 @@ struct ContentView: View {
}

// Snapshot existing session files for detection
let sessionFileExt = assistant == .gemini ? ".json" : ".jsonl"
let sessionFileExt = assistant.sessionFileExtension
let configDir = (NSHomeDirectory() as NSString).appendingPathComponent(assistant.configDirName)
let claudeProjectsDir = (configDir as NSString).appendingPathComponent("projects")
let encodedProject = projectPath.replacingOccurrences(of: "/", with: "-")
let sessionDir = (claudeProjectsDir as NSString).appendingPathComponent(encodedProject)
let existingMastraSessionIds: Set<String>
if assistant == .mastracode {
let discovery = MastracodeSessionDiscovery()
let sessions = (try? await discovery.discoverSessions()) ?? []
existingMastraSessionIds = Set(sessions.map(\.id))
} else {
existingMastraSessionIds = []
}

// When worktree is enabled, also snapshot worktree-related directories
// (worktrees create sessions in dirs like <encodedProject>-.claude-worktrees-<name>)
Expand All @@ -2018,20 +2034,24 @@ struct ContentView: View {
dirsToSnapshot = slugDirs.map { slug in
(tmpDir as NSString).appendingPathComponent(slug).appending("/chats")
}
} else if worktreeName != nil {
} else if assistant == .claude, worktreeName != nil {
let allDirs = (try? FileManager.default.contentsOfDirectory(atPath: claudeProjectsDir)) ?? []
dirsToSnapshot = [sessionDir] + allDirs
.filter { $0.hasPrefix(encodedProject) && $0 != encodedProject }
.map { (claudeProjectsDir as NSString).appendingPathComponent($0) }
} else if assistant == .mastracode {
dirsToSnapshot = []
} else {
dirsToSnapshot = [sessionDir]
}
var existingFilesByDir: [String: Set<String>] = [:]
for dir in dirsToSnapshot {
existingFilesByDir[dir] = Set(
((try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? [])
.filter { $0.hasSuffix(sessionFileExt) }
)
if let sessionFileExt {
for dir in dirsToSnapshot {
existingFilesByDir[dir] = Set(
((try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? [])
.filter { $0.hasSuffix(sessionFileExt) }
)
}
}

let tmuxName = try await launcher.launch(
Expand Down Expand Up @@ -2069,7 +2089,7 @@ struct ContentView: View {
}

if !prompt.isEmpty {
if assistant == .gemini {
if assistant.usesPastedPrompt {
try await self.tmuxAdapter.pastePrompt(to: tmuxName, text: prompt)
} else {
try await self.tmuxAdapter.sendPrompt(to: tmuxName, text: prompt)
Expand All @@ -2078,12 +2098,30 @@ struct ContentView: View {
}

// Detect new session by polling for new session file
// Worktree launches and Gemini need more attempts (slower startup)
let maxAttempts = (worktreeName != nil || assistant == .gemini) ? 12 : 6
// Worktree launches and non-Claude assistants need more attempts (slower startup)
let maxAttempts = (worktreeName != nil || assistant != .claude) ? 12 : 6
var sessionLink: SessionLink?
for attempt in 0..<maxAttempts {
try? await Task.sleep(for: .milliseconds(500))

if assistant == .mastracode {
let discovery = MastracodeSessionDiscovery()
let sessions = (try? await discovery.discoverSessions()) ?? []
let newSession = sessions.first {
!existingMastraSessionIds.contains($0.id)
&& ($0.projectPath == projectPath || $0.projectPath == nil)
} ?? sessions.first { !existingMastraSessionIds.contains($0.id) }

if let newSession, let sessionPath = newSession.jsonlPath {
KanbanCodeLog.info("launch", "Detected Mastra thread after \(attempt + 1) attempts: \(newSession.id.prefix(8))")
sessionLink = SessionLink(sessionId: newSession.id, sessionPath: sessionPath)
break
}
continue
}

guard let sessionFileExt else { continue }

// Build list of dirs to scan (re-list for worktree — dir may appear mid-poll)
let dirsToScan: [String]
if assistant == .gemini {
Expand Down Expand Up @@ -2459,9 +2497,15 @@ struct ContentView: View {
let newSessionId = try await cardStore.forkSession(
sessionPath: sessionPath, targetDirectory: targetDir
)
let dir = targetDir ?? (sessionPath as NSString).deletingLastPathComponent
let ext = card.link.effectiveAssistant == .gemini ? "json" : "jsonl"
let newPath = (dir as NSString).appendingPathComponent("\(newSessionId).\(ext)")
let newPath: String
if card.link.effectiveAssistant == .mastracode,
let resolved = MastracodeSessionPath.decode(sessionPath) {
newPath = MastracodeSessionPath.encode(databasePath: resolved.databasePath, threadId: newSessionId)
} else {
let dir = targetDir ?? (sessionPath as NSString).deletingLastPathComponent
let ext = card.link.effectiveAssistant.sessionFileExtension == ".json" ? "json" : "jsonl"
newPath = (dir as NSString).appendingPathComponent("\(newSessionId).\(ext)")
}
var newLink = Link(
name: (card.link.name ?? card.link.displayTitle) + " (fork)",
projectPath: forkProjectPath,
Expand Down Expand Up @@ -2539,9 +2583,9 @@ struct ContentView: View {
try? RemoteShellManager.deploy()
shellOverride = RemoteShellManager.shellOverridePath()
var env = RemoteShellManager.setupEnvironment(remote: remote, projectPath: projectPath)
// Gemini CLI uses `bash -c` directly (not $SHELL), so prepend
// Node-based CLIs use `bash -c` directly (not $SHELL), so prepend
// our remote dir to PATH so it finds our bash wrapper first.
if assistant == .gemini {
if assistant.needsRemotePathShim {
let remoteDir = RemoteShellManager.remoteDirPath()
env["PATH"] = "\(remoteDir):$PATH"
}
Expand Down
6 changes: 4 additions & 2 deletions Sources/KanbanCode/LaunchConfirmationDialog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -243,15 +243,17 @@ struct LaunchConfirmationDialog: View {

if effectiveRunRemotely {
parts.append("SHELL=~/.kanban-code/remote/zsh")
if assistant == .gemini {
if assistant.needsRemotePathShim {
parts.append("PATH=~/.kanban-code/remote:$PATH")
}
}

if isResume, let sid = sessionId {
var resumeCmd = assistant.cliCommand
if dangerouslySkipPermissions { resumeCmd += " \(assistant.autoApproveFlag)" }
resumeCmd += " \(assistant.resumeFlag) \(sid)"
if let resumeFlag = assistant.resumeFlag {
resumeCmd += " \(resumeFlag) \(sid)"
}
parts.append("cd \(projectPath) && \(resumeCmd)")
} else {
var cmd = assistant.cliCommand
Expand Down
2 changes: 1 addition & 1 deletion Sources/KanbanCode/NewTaskDialog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ struct NewTaskDialog: View {

if runRemotely && hasRemoteConfig {
parts.append("SHELL=~/.kanban-code/remote/zsh")
if selectedAssistant == .gemini {
if selectedAssistant.needsRemotePathShim {
parts.append("PATH=~/.kanban-code/remote:$PATH")
}
}
Expand Down
32 changes: 5 additions & 27 deletions Sources/KanbanCode/OnboardingWizard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,7 @@ struct OnboardingWizard: View {
var result: [OnboardingStep] = [.welcome, .assistants]
// Add a hooks step for each enabled + available assistant
for assistant in CodingAssistant.allCases {
let available: Bool
switch assistant {
case .claude: available = status?.claudeAvailable ?? false
case .gemini: available = status?.geminiAvailable ?? false
}
let available = status?.isAvailable(assistant) ?? false
if available && enabledAssistants.contains(assistant) {
result.append(.hooks(assistant))
}
Expand Down Expand Up @@ -164,12 +160,7 @@ struct OnboardingWizard: View {
)

ForEach(CodingAssistant.allCases, id: \.self) { assistant in
let available: Bool = {
switch assistant {
case .claude: status?.claudeAvailable ?? false
case .gemini: status?.geminiAvailable ?? false
}
}()
let available = status?.isAvailable(assistant) ?? false

HStack {
Toggle(assistant.displayName, isOn: Binding(
Expand Down Expand Up @@ -201,10 +192,7 @@ struct OnboardingWizard: View {

let anyMissing = CodingAssistant.allCases.contains { assistant in
guard enabledAssistants.contains(assistant) else { return false }
switch assistant {
case .claude: return !(status?.claudeAvailable ?? false)
case .gemini: return !(status?.geminiAvailable ?? false)
}
return !(status?.isAvailable(assistant) ?? false)
}

if anyMissing {
Expand All @@ -214,12 +202,7 @@ struct OnboardingWizard: View {
.foregroundStyle(.secondary)

ForEach(CodingAssistant.allCases.filter { enabledAssistants.contains($0) }, id: \.self) { assistant in
let available: Bool = {
switch assistant {
case .claude: status?.claudeAvailable ?? false
case .gemini: status?.geminiAvailable ?? false
}
}()
let available = status?.isAvailable(assistant) ?? false

if !available {
let command = assistant.installCommand
Expand Down Expand Up @@ -545,12 +528,7 @@ struct OnboardingWizard: View {

Group {
ForEach(CodingAssistant.allCases, id: \.self) { assistant in
let available: Bool = {
switch assistant {
case .claude: status?.claudeAvailable ?? false
case .gemini: status?.geminiAvailable ?? false
}
}()
let available = status?.isAvailable(assistant) ?? false
summaryRow(assistant.displayName, status: available)
if available {
summaryRow(" \(assistant.displayName) Hooks", status: status?.assistantHooks[assistant] ?? false)
Expand Down
Loading