Skip to content

Commit 64dd588

Browse files
authored
feat: add Session Explorer window with timeline, fork, bookmarks, and AI summaries (#55)
Replaces the right-click "Resume Session" dropdown with a dedicated Session Explorer window (Cmd+Shift+E). - Two-column layout: session list with search, star bookmarks, and favorites filter on the left; conversation timeline with per-message fork on the right - Resume or fork sessions from the tip, or fork at any point in the conversation by creating a truncated JSONL copy - AI-powered summarization via Haiku (opt-in): generates session summary and per-turn action descriptions in a single call, cached to disk, incrementally updated for continued sessions - Keyboard shortcuts: Cmd+Shift+E (Explore Sessions), Cmd+Opt+N (New Folder), Cmd+Opt+U (Move Out of Folder), all configurable in Settings - Resumed/forked tabs named after the saved session name Closes #52
1 parent 100ddd4 commit 64dd588

13 files changed

+1596
-69
lines changed

Deckard.xcodeproj/project.pbxproj

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@
5959
QA200002QA200002QA200002 /* QuotaMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = QA200001QA200001QA200001 /* QuotaMonitor.swift */; };
6060
QA200004QA200004QA200004 /* QuotaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = QA200003QA200003QA200003 /* QuotaView.swift */; };
6161
QA200006QA200006QA200006 /* QuotaMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = QA200005QA200005QA200005 /* QuotaMonitorTests.swift */; };
62+
SE000001SE000001SE000001 /* SessionExplorerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = SE000002SE000002SE000002 /* SessionExplorerModels.swift */; };
63+
SE000003SE000003SE000003 /* BookmarkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = SE000004SE000004SE000004 /* BookmarkManager.swift */; };
64+
SE000005SE000005SE000005 /* SummaryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = SE000006SE000006SE000006 /* SummaryManager.swift */; };
65+
SE000007SE000007SE000007 /* SessionExplorerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = SE000008SE000008SE000008 /* SessionExplorerWindowController.swift */; };
66+
SE000009SE000009SE000009 /* SessionExplorerTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = SE00000ASE00000ASE00000A /* SessionExplorerTimelineView.swift */; };
6267
/* End PBXBuildFile section */
6368

6469
/* Begin PBXFileReference section */
@@ -114,6 +119,11 @@
114119
QA200001QA200001QA200001 /* QuotaMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotaMonitor.swift; sourceTree = "<group>"; };
115120
QA200003QA200003QA200003 /* QuotaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotaView.swift; sourceTree = "<group>"; };
116121
QA200005QA200005QA200005 /* QuotaMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotaMonitorTests.swift; sourceTree = "<group>"; };
122+
SE000002SE000002SE000002 /* SessionExplorerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionExplorerModels.swift; sourceTree = "<group>"; };
123+
SE000004SE000004SE000004 /* BookmarkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkManager.swift; sourceTree = "<group>"; };
124+
SE000006SE000006SE000006 /* SummaryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SummaryManager.swift; sourceTree = "<group>"; };
125+
SE000008SE000008SE000008 /* SessionExplorerWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionExplorerWindowController.swift; sourceTree = "<group>"; };
126+
SE00000ASE00000ASE00000A /* SessionExplorerTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionExplorerTimelineView.swift; sourceTree = "<group>"; };
117127
/* End PBXFileReference section */
118128

119129
/* Begin PBXFrameworksBuildPhase section */
@@ -211,6 +221,11 @@
211221
isa = PBXGroup;
212222
children = (
213223
7BE2AA0719D32ACCD1549184 /* SessionState.swift */,
224+
SE000002SE000002SE000002 /* SessionExplorerModels.swift */,
225+
SE000004SE000004SE000004 /* BookmarkManager.swift */,
226+
SE000006SE000006SE000006 /* SummaryManager.swift */,
227+
SE000008SE000008SE000008 /* SessionExplorerWindowController.swift */,
228+
SE00000ASE00000ASE00000A /* SessionExplorerTimelineView.swift */,
214229
);
215230
path = Session;
216231
sourceTree = "<group>";
@@ -421,6 +436,11 @@
421436
DD440001DD440001DD440001 /* CrashReporter.swift in Sources */,
422437
QA200002QA200002QA200002 /* QuotaMonitor.swift in Sources */,
423438
QA200004QA200004QA200004 /* QuotaView.swift in Sources */,
439+
SE000001SE000001SE000001 /* SessionExplorerModels.swift in Sources */,
440+
SE000003SE000003SE000003 /* BookmarkManager.swift in Sources */,
441+
SE000005SE000005SE000005 /* SummaryManager.swift in Sources */,
442+
SE000007SE000007SE000007 /* SessionExplorerWindowController.swift in Sources */,
443+
SE000009SE000009SE000009 /* SessionExplorerTimelineView.swift in Sources */,
424444
);
425445
runOnlyForDeploymentPostprocessing = 0;
426446
};

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Run multiple sessions side by side in a single window with tabs, projects, and s
2323
- **Project sidebar**: Each open directory gets its own set of tabs, persisted across restarts. Group related projects into collapsible sidebar folders for organization (e.g., by client).
2424
- **Context & quota tracking**: A progress bar shows context window usage. A sparkline visualizes token rate over time, and rate limit indicators show 5-hour and 7-day quota consumption.
2525
- **Session state detection**: Tab badges show whether Claude is thinking, waiting for input, needs tool permission, or has errored. Terminal tabs show real-time CPU and disk activity for the foreground process.
26+
- **Session explorer**: Right-click a project and choose "Explore Sessions" (or press Cmd+Shift+E) to browse all past Claude sessions. View the full conversation timeline, resume or fork from any point, and bookmark favorite sessions with a star toggle. Optionally summarize sessions and per-turn actions with Haiku — summaries are cached and incrementally updated when sessions are continued.
2627
- **Session persistence**: Claude sessions resume via `--resume`. Tab structure and working directories are preserved across restarts.
2728
- **486 color themes**: Ships with 486 built-in themes (Ghostty format) and loads custom themes from `~/.config/ghostty/themes`. Search and preview in Settings.
2829
- **Customizable shortcuts**: All keyboard shortcuts are rebindable in Settings > Shortcuts.

Sources/App/AppDelegate.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,19 @@ class AppDelegate: NSObject, NSApplicationDelegate {
198198
fileMenu.addItem(closeItem)
199199

200200
let newFolderItem = NSMenuItem(title: "New Sidebar Folder", action: #selector(createNewSidebarFolder), keyEquivalent: "")
201+
newFolderItem.setShortcut(for: .newSidebarFolder)
201202
newFolderItem.target = self
202203
fileMenu.addItem(newFolderItem)
203204

205+
let moveOutItem = NSMenuItem(title: "Move Out of Folder", action: #selector(moveCurrentProjectOutOfFolder), keyEquivalent: "")
206+
moveOutItem.setShortcut(for: .moveOutOfFolder)
207+
moveOutItem.target = self
208+
fileMenu.addItem(moveOutItem)
209+
210+
let exploreSessionsItem = NSMenuItem(title: "Explore Sessions", action: #selector(exploreSessions), keyEquivalent: "")
211+
exploreSessionsItem.setShortcut(for: .exploreSessions)
212+
fileMenu.addItem(exploreSessionsItem)
213+
204214
let closeProjectItem = NSMenuItem(title: "Close Folder", action: #selector(closeCurrentProject), keyEquivalent: "")
205215
closeProjectItem.setShortcut(for: .closeFolder)
206216
fileMenu.addItem(closeProjectItem)
@@ -298,6 +308,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
298308
windowController?.closeCurrentTab()
299309
}
300310

311+
@objc private func exploreSessions() {
312+
windowController?.exploreCurrentProjectSessions()
313+
}
314+
315+
@objc private func moveCurrentProjectOutOfFolder() {
316+
windowController?.moveCurrentProjectOutOfFolder()
317+
}
318+
301319
@objc private func closeCurrentProject() {
302320
if let keyWindow = NSApp.keyWindow,
303321
keyWindow != windowController?.window {

Sources/App/ShortcutNames.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ extension KeyboardShortcuts.Name {
1111
static let nextProject = Self("nextProject", default: .init(.rightBracket, modifiers: [.command, .option]))
1212
static let previousProject = Self("previousProject", default: .init(.leftBracket, modifiers: [.command, .option]))
1313
static let toggleSidebar = Self("toggleSidebar", default: .init(.s, modifiers: [.command, .control]))
14+
static let exploreSessions = Self("exploreSessions", default: .init(.e, modifiers: [.command, .shift]))
15+
static let newSidebarFolder = Self("newSidebarFolder", default: .init(.n, modifiers: [.command, .option]))
16+
static let moveOutOfFolder = Self("moveOutOfFolder", default: .init(.u, modifiers: [.command, .option]))
1417
static let settings = Self("settings", default: .init(.comma, modifiers: .command))
1518
static let tab1 = Self("tab1", default: .init(.one, modifiers: .command))
1619
static let tab2 = Self("tab2", default: .init(.two, modifiers: .command))
@@ -41,6 +44,9 @@ let configurableShortcuts: [ShortcutEntry] = [
4144
ShortcutEntry(name: .nextProject, label: "Next Project"),
4245
ShortcutEntry(name: .previousProject, label: "Previous Project"),
4346
ShortcutEntry(name: .toggleSidebar, label: "Toggle Sidebar"),
47+
ShortcutEntry(name: .exploreSessions, label: "Explore Sessions"),
48+
ShortcutEntry(name: .newSidebarFolder, label: "New Sidebar Folder"),
49+
ShortcutEntry(name: .moveOutOfFolder, label: "Move Out of Folder"),
4450
ShortcutEntry(name: .settings, label: "Settings"),
4551
ShortcutEntry(name: .tab1, label: "Project 1"),
4652
ShortcutEntry(name: .tab2, label: "Project 2"),

Sources/Detection/ContextMonitor.swift

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class ContextMonitor {
3838
let sessionId: String
3939
let modificationDate: Date
4040
let firstUserMessage: String
41+
let messageCount: Int
4142
}
4243

4344
/// Lists all Claude sessions for a project, sorted by most recent first.
@@ -90,14 +91,191 @@ class ContextMonitor {
9091
results.append(SessionInfo(
9192
sessionId: sessionId,
9293
modificationDate: modDate,
93-
firstUserMessage: firstMessage
94+
firstUserMessage: firstMessage,
95+
messageCount: 0
9496
))
9597
}
9698

9799
results.sort { $0.modificationDate > $1.modificationDate }
98100
return results
99101
}
100102

103+
/// Parses a session JSONL file and returns an ordered list of user turns.
104+
/// Deduplicates by promptId — only the first occurrence with non-empty content is kept.
105+
func parseTimeline(sessionId: String, projectPath: String) -> [TimelineEntry] {
106+
let encoded = projectPath.claudeProjectDirName
107+
let jsonlPath = NSHomeDirectory() + "/.claude/projects/\(encoded)/\(sessionId).jsonl"
108+
109+
guard let data = try? Data(contentsOf: URL(fileURLWithPath: jsonlPath)),
110+
let content = String(data: data, encoding: .utf8) else { return [] }
111+
112+
var entries: [TimelineEntry] = []
113+
var seenPromptIds = Set<String>()
114+
let iso8601 = ISO8601DateFormatter()
115+
iso8601.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
116+
117+
for line in content.components(separatedBy: "\n") where !line.isEmpty {
118+
guard let lineData = line.data(using: .utf8),
119+
let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any],
120+
let type = json["type"] as? String, type == "user",
121+
let promptId = json["promptId"] as? String,
122+
!seenPromptIds.contains(promptId) else { continue }
123+
124+
let msg = json["message"] as? [String: Any]
125+
var text = ""
126+
if let content = msg?["content"] as? String {
127+
text = content
128+
} else if let contentArr = msg?["content"] as? [[String: Any]] {
129+
text = contentArr.first(where: { $0["type"] as? String == "text" })?["text"] as? String ?? ""
130+
}
131+
132+
// Skip empty continuation messages (same promptId, no content)
133+
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
134+
seenPromptIds.insert(promptId)
135+
continue
136+
}
137+
138+
seenPromptIds.insert(promptId)
139+
140+
let timestamp: Date?
141+
if let ts = json["timestamp"] as? String {
142+
timestamp = iso8601.date(from: ts)
143+
} else {
144+
timestamp = nil
145+
}
146+
147+
entries.append(TimelineEntry(
148+
index: entries.count,
149+
promptId: promptId,
150+
message: text.trimmingCharacters(in: .whitespacesAndNewlines),
151+
timestamp: timestamp,
152+
actionSummary: nil
153+
))
154+
}
155+
156+
return entries
157+
}
158+
159+
/// Extracts a raw description of tool uses for each user turn in a session.
160+
/// Returns a dictionary mapping turn index to a list of action descriptions.
161+
func parseActions(sessionId: String, projectPath: String) -> [Int: [String]] {
162+
let encoded = projectPath.claudeProjectDirName
163+
let jsonlPath = NSHomeDirectory() + "/.claude/projects/\(encoded)/\(sessionId).jsonl"
164+
165+
guard let data = try? Data(contentsOf: URL(fileURLWithPath: jsonlPath)),
166+
let content = String(data: data, encoding: .utf8) else { return [:] }
167+
168+
var result: [Int: [String]] = [:]
169+
var currentTurnIndex = -1
170+
var seenPromptIds = Set<String>()
171+
172+
for line in content.components(separatedBy: "\n") where !line.isEmpty {
173+
guard let lineData = line.data(using: .utf8),
174+
let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any],
175+
let type = json["type"] as? String else { continue }
176+
177+
if type == "user", let promptId = json["promptId"] as? String,
178+
!seenPromptIds.contains(promptId) {
179+
let msg = json["message"] as? [String: Any]
180+
var text = ""
181+
if let c = msg?["content"] as? String {
182+
text = c
183+
} else if let arr = msg?["content"] as? [[String: Any]] {
184+
text = arr.first(where: { $0["type"] as? String == "text" })?["text"] as? String ?? ""
185+
}
186+
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
187+
seenPromptIds.insert(promptId)
188+
continue
189+
}
190+
seenPromptIds.insert(promptId)
191+
currentTurnIndex += 1
192+
} else if type == "assistant", currentTurnIndex >= 0 {
193+
let msg = json["message"] as? [String: Any]
194+
let inner = msg?["message"] as? [String: Any] ?? msg
195+
guard let contentArr = inner?["content"] as? [[String: Any]] else { continue }
196+
197+
for block in contentArr {
198+
guard block["type"] as? String == "tool_use",
199+
let name = block["name"] as? String else { continue }
200+
let input = block["input"] as? [String: Any] ?? [:]
201+
var desc = name
202+
if let fp = input["file_path"] as? String {
203+
let filename = (fp as NSString).lastPathComponent
204+
desc = "\(name) \(filename)"
205+
} else if let cmd = input["command"] as? String {
206+
let brief = cmd.components(separatedBy: "\n").first ?? cmd
207+
desc = "\(name): \(String(brief.prefix(50)))"
208+
} else if let pattern = input["pattern"] as? String {
209+
desc = "\(name) \(pattern)"
210+
}
211+
result[currentTurnIndex, default: []].append(desc)
212+
}
213+
}
214+
}
215+
216+
return result
217+
}
218+
219+
/// Creates a truncated copy of a session JSONL, keeping everything up to (and including
220+
/// the full response for) the Nth unique user turn. Returns the new session ID.
221+
func truncateSession(sessionId: String, projectPath: String, afterTurnIndex: Int) -> String? {
222+
let encoded = projectPath.claudeProjectDirName
223+
let dir = NSHomeDirectory() + "/.claude/projects/\(encoded)"
224+
let jsonlPath = dir + "/\(sessionId).jsonl"
225+
226+
guard let data = try? Data(contentsOf: URL(fileURLWithPath: jsonlPath)),
227+
let content = String(data: data, encoding: .utf8) else { return nil }
228+
229+
let lines = content.components(separatedBy: "\n")
230+
var seenPromptIds = Set<String>()
231+
var uniqueTurnCount = -1 // will be incremented to 0 on first user turn
232+
var cutoffLineIndex = lines.count
233+
234+
for (i, line) in lines.enumerated() where !line.isEmpty {
235+
guard let lineData = line.data(using: .utf8),
236+
let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any],
237+
let type = json["type"] as? String, type == "user",
238+
let promptId = json["promptId"] as? String,
239+
!seenPromptIds.contains(promptId) else { continue }
240+
241+
// Check if this user message has actual content (not a continuation)
242+
let msg = json["message"] as? [String: Any]
243+
var text = ""
244+
if let c = msg?["content"] as? String {
245+
text = c
246+
} else if let arr = msg?["content"] as? [[String: Any]] {
247+
text = arr.first(where: { $0["type"] as? String == "text" })?["text"] as? String ?? ""
248+
}
249+
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
250+
seenPromptIds.insert(promptId)
251+
continue
252+
}
253+
254+
seenPromptIds.insert(promptId)
255+
uniqueTurnCount += 1
256+
257+
// When we hit the turn AFTER the one we want, cut here
258+
if uniqueTurnCount > afterTurnIndex {
259+
cutoffLineIndex = i
260+
break
261+
}
262+
}
263+
264+
let truncatedLines = lines.prefix(cutoffLineIndex).filter { !$0.isEmpty }
265+
let truncatedContent = truncatedLines.joined(separator: "\n") + "\n"
266+
267+
let newSessionId = UUID().uuidString.lowercased()
268+
let newPath = dir + "/\(newSessionId).jsonl"
269+
270+
guard let writeData = truncatedContent.data(using: .utf8) else { return nil }
271+
do {
272+
try writeData.write(to: URL(fileURLWithPath: newPath), options: .atomic)
273+
return newSessionId
274+
} catch {
275+
return nil
276+
}
277+
}
278+
101279
/// Get context usage for a session by reading its JSONL file.
102280
/// Only reads the tail of the file to find the most recent usage entry.
103281
func getUsage(sessionId: String, projectPath: String) -> ContextUsage? {

0 commit comments

Comments
 (0)