diff --git a/App/AppModel.swift b/App/AppModel.swift index cd7d3a6..9d628e2 100644 --- a/App/AppModel.swift +++ b/App/AppModel.swift @@ -738,11 +738,15 @@ final class AppModel { } } - /// The folders agents on a machine work in, the most used first. Worktrees are left out: they - /// belong to the session that made them. + /// The folders agents on a machine work in, the most used first, then those of shells still + /// waiting for an agent. Worktrees are left out: they belong to the session that made them. func folders(on machineID: String) -> [FolderUse] { - FolderUse.ranked(cluster.agents.filter { $0.id.machineID == machineID }) { row in - cluster.checkout(machineID: machineID, workspaceID: row.agent.workspaceID)?.isLinkedWorktree == true + func isWorktree(_ workspaceID: String) -> Bool { + cluster.checkout(machineID: machineID, workspaceID: workspaceID)?.isLinkedWorktree == true + } + let shells = allShells.filter { $0.machine.id == machineID && !isWorktree($0.pane.workspaceID) } + return FolderUse.ranked(cluster.agents.filter { $0.id.machineID == machineID }, shells: shells.map(\.pane.directory)) { row in + isWorktree(row.agent.workspaceID) } } diff --git a/App/NewSessionSheet.swift b/App/NewSessionSheet.swift index 2a54f84..fa385bc 100644 --- a/App/NewSessionSheet.swift +++ b/App/NewSessionSheet.swift @@ -173,7 +173,17 @@ struct NewSessionSheet: View { } } .buttonStyle(ConsoleButtonStyle(tint: isChosen ? Theme.phosphor : Theme.dim)) - .help("\(folder.path)\n\(folder.agents == 1 ? "1 agent works" : "\(folder.agents) agents work") here") + .help(([folder.path] + Self.uses(of: folder)).joined(separator: "\n")) + } + + /// `2 agents work here`, `1 shell without an agent is open here`. + static func uses(of folder: FolderUse) -> [String] { + var uses: [String] = [] + if folder.agents > 0 { uses.append(folder.agents == 1 ? "1 agent works here" : "\(folder.agents) agents work here") } + if folder.shells > 0 { + uses.append(folder.shells == 1 ? "1 shell without an agent is open here" : "\(folder.shells) shells without an agent are open here") + } + return uses } /// A folder's name, with its parent's when another folder has the same name. diff --git a/App/ShepherdrApp.swift b/App/ShepherdrApp.swift index 4d6e95a..129aa22 100644 --- a/App/ShepherdrApp.swift +++ b/App/ShepherdrApp.swift @@ -28,6 +28,11 @@ struct ShepherdrApp: App { Button("New Session…") { model.startNewSession() } .keyboardShortcut("n", modifiers: .command) .disabled(model.onlineMachines.isEmpty) + Button("New Session in Same Folder…") { + if let id = model.selectedID { model.startNewSession(besides: id) } + } + .keyboardShortcut("n", modifiers: [.command, .shift]) + .disabled(!(model.selectedID.map(model.canStartNewSession(besides:)) ?? false)) Divider() Button("Refresh Sessions") { Task { await model.cluster.refresh() } } .keyboardShortcut("r", modifiers: .command) diff --git a/Sources/ShepherdrCore/Domain/SessionFilter.swift b/Sources/ShepherdrCore/Domain/SessionFilter.swift index 148bb0e..6f71039 100644 --- a/Sources/ShepherdrCore/Domain/SessionFilter.swift +++ b/Sources/ShepherdrCore/Domain/SessionFilter.swift @@ -48,29 +48,37 @@ public struct SessionFilter: Equatable, Sendable { } } -/// A folder agents work in, and how many of them do. +/// A folder agents work in, and how many of them do; and how many shells, still without an +/// agent, are open there. public struct FolderUse: Equatable, Sendable { public let path: String public let agents: Int + public let shells: Int - public init(path: String, agents: Int) { + public init(path: String, agents: Int, shells: Int = 0) { self.path = path self.agents = agents + self.shells = shells } - /// The folders of these sessions' agents, the most used first, leaving out those `excluded` - /// says to, such as worktrees. - public static func ranked(_ rows: [AgentRow], excluding excluded: (AgentRow) -> Bool = { _ in false }, + /// The folders of these sessions' agents, the most used first, then those only shells are open + /// in, leaving out agents `excluded` says to, such as worktrees. + public static func ranked(_ rows: [AgentRow], shells: [String?] = [], excluding excluded: (AgentRow) -> Bool = { _ in false }, limit: Int = 8) -> [FolderUse] { - var counts: [String: Int] = [:] - for row in rows where !excluded(row) { - guard var path = row.agent.directory?.trimmingCharacters(in: .whitespacesAndNewlines), path.hasPrefix("/") else { continue } + func folder(_ directory: String?) -> String? { + guard var path = directory?.trimmingCharacters(in: .whitespacesAndNewlines), path.hasPrefix("/") else { return nil } while path.count > 1, path.hasSuffix("/") { path.removeLast() } - counts[path, default: 0] += 1 + return path + } + var counts: [String: (agents: Int, shells: Int)] = [:] + for row in rows where !excluded(row) { + if let path = folder(row.agent.directory) { counts[path, default: (0, 0)].agents += 1 } } - return counts.map { FolderUse(path: $0.key, agents: $0.value) } + for path in shells.compactMap(folder) { counts[path, default: (0, 0)].shells += 1 } + return counts.map { FolderUse(path: $0.key, agents: $0.value.agents, shells: $0.value.shells) } .sorted { if $0.agents != $1.agents { return $0.agents > $1.agents } + if $0.shells != $1.shells { return $0.shells > $1.shells } let names = ($0.path as NSString).lastPathComponent.localizedStandardCompare(($1.path as NSString).lastPathComponent) return names == .orderedSame ? $0.path < $1.path : names == .orderedAscending } diff --git a/Tests/ShepherdrCoreTests/OverviewTests.swift b/Tests/ShepherdrCoreTests/OverviewTests.swift index a1d3dea..e7296f1 100644 --- a/Tests/ShepherdrCoreTests/OverviewTests.swift +++ b/Tests/ShepherdrCoreTests/OverviewTests.swift @@ -58,6 +58,12 @@ struct OverviewTests { #expect(folders == [FolderUse(path: "/p/herdr", agents: 2), FolderUse(path: "/p/api", agents: 1), FolderUse(path: "/p/shepherdr", agents: 1)]) #expect(FolderUse.ranked(rows, limit: 1).map(\.path) == ["/p/herdr"]) + // Shells still without an agent add their folders after the agents' ones. + let withShells = FolderUse.ranked(rows, shells: ["/p/api/", "/p/notes", "/p/notes", "/p/web", nil, "~"], + excluding: { $0.workspace == "d" }) + #expect(withShells == [FolderUse(path: "/p/herdr", agents: 2), FolderUse(path: "/p/api", agents: 1, shells: 1), + FolderUse(path: "/p/shepherdr", agents: 1), FolderUse(path: "/p/notes", agents: 0, shells: 2), + FolderUse(path: "/p/web", agents: 0, shells: 1)]) } @Test func usageIsReadFromTheScriptsLine() { diff --git a/docs/GUIDE.md b/docs/GUIDE.md index a210ae5..1dc1c65 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -19,7 +19,7 @@ The sidebar is your prioritized queue of sessions. Pick one and its live termina - **Picks up where you left off:** after a restart, the session you had open comes back, and every session keeps its browser tabs, prompt editor and resources. - **Notifications:** a macOS notification tells you when an agent finishes or needs you, with what it last said, and when a pull request's checks finish. Click it to open the session. - **Sessions on hold:** mark a session as waiting for others, such as a planner waiting for the subagents it launched. It shows an hourglass, and the sessions it waits for are listed under it, in a tree you can collapse. -- **Create and close sessions:** **⌘N** creates a Herdr workspace with a shell in a folder; start Claude Code, Codex or any other supported agent in it and it joins the queue. Create one in a group with its **+**, or next to a session with **New Session in Same Folder…**. **Close Session…** in a session's context menu ends its pane after confirmation. **Rename…** renames its Herdr workspace. +- **Create and close sessions:** **⌘N** creates a Herdr workspace with a shell in a folder; start Claude Code, Codex or any other supported agent in it and it joins the queue. Create one in a group with its **+**, or next to a session with **New Session in Same Folder…** (**⇧⌘N**). **Close Session…** in a session's context menu ends its pane after confirmation. **Rename…** renames its Herdr workspace. - **Overview:** how many sessions need you, are working, done or idle, and a card per machine with its sessions and its processor, memory and disk use. Click states and machines to list the sessions in any combination of them. Plain shell panes are listed separately. - **Lid status:** the foot of the sidebar tells you whether it's safe to close the lid. While agents work on this Mac it warns you not to, since sleep would pause them; remote sessions don't count, they keep going. - **Machines** in **Settings → Machines** (⌘,): add SSH machines, disable them to stop polling, or remove them from Herdr's catalog. @@ -109,11 +109,11 @@ You can reorder while filtering the queue (**⌘F**): movement is relative to th **⌘N** (or **+** in the sidebar) opens **New Session**: choose a folder (on the selected machine) and an optional name. Shepherdr runs `herdr workspace create --cwd … --label …` and opens its shell. Start an agent there as you would in any terminal: Herdr detects it, and the session joins the queue within one refresh. -Below the folder, the folders agents on the selected machine work in are one click away, the most used first. Git worktrees are left out: they belong to the session that made them. +Below the folder, the folders agents on the selected machine work in are one click away, the most used first, followed by those of shells still waiting for an agent. Git worktrees are left out: they belong to the session that made them. Shepherdr checks the folder on its machine first, over SSH for other machines, where `~` means that machine's home and a relative path starts there. If it doesn't exist, New Session offers to start a new project there: **NEW PROJECT** makes the folder, runs `git init` in it and opens the session. Herdr itself would open a missing folder's workspace in the home folder without a word. -On this Mac, **CHOOSE…** always starts in your projects folder, `~/projects` unless you change it in **Settings → General**, and a folder name alone means a folder inside it. **New Session in Same Folder…** in a session's or shell's context menu prefills its folder and machine and places the new session right after it, in the same group. A group header's **+** places it in that group. +On this Mac, **CHOOSE…** always starts in your projects folder, `~/projects` unless you change it in **Settings → General**, and a folder name alone means a folder inside it. **New Session in Same Folder…** in a session's or shell's context menu, or **⇧⌘N** for the one on screen, prefills its folder and machine and places the new session right after it, in the same group. A group header's **+** places it in that group. **Close Session…** in a session's context menu asks for confirmation, then runs `herdr pane close`. It is deliberately only in the context menu, away from everyday controls. This ends the agent process. When it is the workspace's last pane, Herdr closes the workspace too.