Skip to content
Merged
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
12 changes: 8 additions & 4 deletions App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
12 changes: 11 additions & 1 deletion App/NewSessionSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions App/ShepherdrApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 18 additions & 10 deletions Sources/ShepherdrCore/Domain/SessionFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
6 changes: 6 additions & 0 deletions Tests/ShepherdrCoreTests/OverviewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
6 changes: 3 additions & 3 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
Loading