Skip to content

Commit 064ec44

Browse files
gi11esclaude
andcommitted
feat: add completed-unseen badge states for tabs
Add two new badge states (completedUnseen, terminalCompletedUnseen) that show vivid colors when a Claude or terminal tab finishes work while the user is looking at a different tab. The badge clears instantly when the user visits the tab. Colors are configurable in Settings. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent bda6741 commit 064ec44

File tree

5 files changed

+68
-4
lines changed

5 files changed

+68
-4
lines changed

Sources/Detection/HookHandler.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ class HookHandler {
2222
reply(ControlResponse(ok: true))
2323

2424
case "hook.stop", "hook.stop-failure":
25-
// Claude finished responding (or hit a limit/error) — waiting for user input
25+
// Claude finished responding — mark as unseen if tab isn't focused
2626
if let surfaceId = message.surfaceId {
27-
windowController?.updateBadge(forSurfaceId: surfaceId, state: .waitingForInput)
27+
windowController?.updateBadgeToIdleOrUnseen(forSurfaceId: surfaceId, isClaude: true)
2828
}
2929
forwardRateLimits(from: message)
3030
reply(ControlResponse(ok: true))
@@ -35,7 +35,11 @@ class HookHandler {
3535
if type.contains("permission") {
3636
windowController?.updateBadge(forSurfaceId: surfaceId, state: .needsPermission)
3737
} else {
38-
windowController?.updateBadge(forSurfaceId: surfaceId, state: .waitingForInput)
38+
// Don't overwrite completedUnseen — the tab hasn't been visited yet
39+
if let tab = windowController?.tabForSurfaceId(surfaceId),
40+
tab.badgeState != .completedUnseen {
41+
windowController?.updateBadge(forSurfaceId: surfaceId, state: .waitingForInput)
42+
}
3943
}
4044
}
4145
reply(ControlResponse(ok: true))

Sources/Window/DeckardWindowController.swift

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ class TabItem {
3131
case terminalIdle // muted teal - terminal at prompt
3232
case terminalActive // teal pulsing - terminal foreground process has activity
3333
case terminalError // red - terminal process exited with error
34+
case completedUnseen // vivid purple - Claude finished while tab unfocused
35+
case terminalCompletedUnseen // vivid teal - terminal finished while tab unfocused
3436
}
3537

3638
init(surface: TerminalSurface, name: String, isClaude: Bool) {
@@ -566,6 +568,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate {
566568
} else {
567569
// Always clamp for safe array access, even during restore
568570
let safeIdx = max(0, min(project.selectedTabIndex, project.tabs.count - 1))
571+
clearUnseenIfNeeded(project.tabs[safeIdx])
569572
showTab(project.tabs[safeIdx])
570573
}
571574

@@ -747,15 +750,33 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate {
747750
project.selectedTabIndex = min(idx, project.tabs.count - 1)
748751
rebuildTabBar()
749752
rebuildSidebar()
753+
clearUnseenIfNeeded(project.tabs[project.selectedTabIndex])
750754
showTab(project.tabs[project.selectedTabIndex])
751755
}
752756
saveState()
753757
}
754758

759+
/// If the tab is in a completedUnseen state, revert to the normal idle state.
760+
func clearUnseenIfNeeded(_ tab: TabItem) {
761+
switch tab.badgeState {
762+
case .completedUnseen:
763+
tab.badgeState = .waitingForInput
764+
rebuildSidebar()
765+
rebuildTabBar()
766+
case .terminalCompletedUnseen:
767+
tab.badgeState = .terminalIdle
768+
rebuildSidebar()
769+
rebuildTabBar()
770+
default:
771+
break
772+
}
773+
}
774+
755775
func selectTabInProject(at tabIndex: Int) {
756776
guard let project = currentProject else { return }
757777
guard tabIndex >= 0, tabIndex < project.tabs.count else { return }
758778
project.selectedTabIndex = tabIndex
779+
clearUnseenIfNeeded(project.tabs[tabIndex])
759780
rebuildTabBar()
760781
showTab(project.tabs[tabIndex])
761782
}
@@ -768,6 +789,7 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate {
768789
guard tabIndex >= 0, tabIndex < project.tabs.count else { return }
769790
guard tabIndex != project.selectedTabIndex else { return }
770791
project.selectedTabIndex = tabIndex
792+
clearUnseenIfNeeded(project.tabs[tabIndex])
771793
showTab(project.tabs[tabIndex])
772794
}
773795

@@ -915,7 +937,20 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate {
915937
terminalActiveStreak[tab.id] = newStreak
916938
let confirmedActive = newStreak >= 2
917939

918-
let newBadge: TabItem.BadgeState = confirmedActive ? .terminalActive : .terminalIdle
940+
let newBadge: TabItem.BadgeState
941+
if confirmedActive {
942+
newBadge = .terminalActive
943+
} else if tab.badgeState == .terminalActive {
944+
// Transitioning from active to idle — check focus
945+
let focused = isTabFocused(tab.id.uuidString)
946+
newBadge = focused ? .terminalIdle : .terminalCompletedUnseen
947+
} else if tab.badgeState == .terminalCompletedUnseen {
948+
// Stay unseen until tab is visited (cleared elsewhere)
949+
newBadge = .terminalCompletedUnseen
950+
} else {
951+
newBadge = .terminalIdle
952+
}
953+
919954
terminalActivity[tab.id] = activity
920955
if tab.badgeState != newBadge {
921956
if newBadge == .terminalActive {
@@ -1049,6 +1084,24 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate {
10491084
rebuildTabBar()
10501085
}
10511086

1087+
/// Like updateBadge, but substitutes completedUnseen/terminalCompletedUnseen
1088+
/// when the tab transitions to an idle state while unfocused.
1089+
func updateBadgeToIdleOrUnseen(forSurfaceId surfaceIdStr: String, isClaude: Bool) {
1090+
guard let tab = tabForSurfaceId(surfaceIdStr) else { return }
1091+
let wasBusy = isClaude
1092+
? (tab.badgeState == .thinking || tab.badgeState == .needsPermission)
1093+
: (tab.badgeState == .terminalActive)
1094+
let focused = isTabFocused(surfaceIdStr)
1095+
let idleState: TabItem.BadgeState = isClaude ? .waitingForInput : .terminalIdle
1096+
let unseenState: TabItem.BadgeState = isClaude ? .completedUnseen : .terminalCompletedUnseen
1097+
let newState = (wasBusy && !focused) ? unseenState : idleState
1098+
DiagnosticLog.shared.log("badge",
1099+
"updateBadgeToIdleOrUnseen: surfaceId=\(surfaceIdStr) wasBusy=\(wasBusy) focused=\(focused) -> \(newState)")
1100+
tab.badgeState = newState
1101+
rebuildSidebar()
1102+
rebuildTabBar()
1103+
}
1104+
10521105
func listTabInfo() -> [TabInfo] {
10531106
var result: [TabInfo] = []
10541107
for project in projects {

Sources/Window/SettingsWindow.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,12 +687,14 @@ class SettingsWindowController: NSWindowController, NSToolbarDelegate, NSTextFie
687687
(.waitingForInput, "Ready"),
688688
(.needsPermission, "Needs Permission"),
689689
(.error, "Error"),
690+
(.completedUnseen, "Done (Unvisited)"),
690691
]
691692

692693
private static let terminalBadgeEntries: [(state: TabItem.BadgeState, label: String)] = [
693694
(.terminalIdle, "Idle"),
694695
(.terminalActive, "Busy"),
695696
(.terminalError, "Error"),
697+
(.terminalCompletedUnseen, "Done (Unvisited)"),
696698
]
697699

698700
/// Default animation settings per state.

Sources/Window/SidebarViews.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource {
154154
case .terminalIdle: return "Idle"
155155
case .terminalActive: return activity?.description ?? "Running"
156156
case .terminalError: return "Error"
157+
case .completedUnseen: return "Done (unvisited)"
158+
case .terminalCompletedUnseen: return "Done (unvisited)"
157159
}
158160
}
159161

@@ -166,6 +168,8 @@ class VerticalTabRowView: NSView, NSTextFieldDelegate, NSDraggingSource {
166168
.terminalIdle: NSColor(red: 0.35, green: 0.55, blue: 0.54, alpha: 1.0),
167169
.terminalActive: NSColor(red: 0.45, green: 0.72, blue: 0.71, alpha: 1.0),
168170
.terminalError: NSColor(red: 0.85, green: 0.3, blue: 0.3, alpha: 1.0),
171+
.completedUnseen: NSColor(red: 0.95, green: 0.4, blue: 0.7, alpha: 1.0),
172+
.terminalCompletedUnseen: NSColor(red: 0.3, green: 0.75, blue: 0.73, alpha: 1.0),
169173
]
170174

171175
static func colorForBadge(_ state: TabItem.BadgeState) -> NSColor {

Sources/Window/TabBarController.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ extension DeckardWindowController {
145145
project.selectedTabIndex = min(idx, project.tabs.count - 1)
146146
rebuildTabBar()
147147
rebuildSidebar()
148+
clearUnseenIfNeeded(project.tabs[project.selectedTabIndex])
148149
showTab(project.tabs[project.selectedTabIndex])
149150
}
150151
saveState()

0 commit comments

Comments
 (0)