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 .claude/rules/menu-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,12 @@ paths:
- An empty query skips ranking in two cases: attention mode and a caller-supplied picker. Both keep their
source order, because every row scores 0 and the tie-break would re-sort A→Z and replace the row Return
runs. Every other palette lists everything A→Z.
- Attention mode lists every non-idle session, ordered blocked, active, completed and then newest
`statusChangedAt`, with nil last. Palette items carry status plus per-call color/shape, resolved by the
same helpers as sidebar glyphs. Typed queries use fuzzy score.
- Attention mode lists every open window's non-idle sessions (`WindowLibrary.attentionAcrossWindows`),
ordered blocked, active, completed and then newest `statusChangedAt`, with nil last, as one combined
sort. Palette items carry status plus per-call color/shape, resolved by the same helpers as sidebar
glyphs, a subtitle naming the window once more than one is open, and `isEnabled` asking the OWNING
window's modal gate. A pick defers `AppActions.selectAttention` past the palette's close, which raises
another window before selecting. Typed queries use fuzzy score.
- Open attention through `show_attention` (Ctrl-Shift-I), Navigate > Go to Attention, or Show Attention
in the action palette. The titlebar bell opens a popover, not this palette. Palette opening is
keep-in-sync exempt.
Expand All @@ -252,7 +255,8 @@ paths:
background, and chrome text.
- Clock lists up to `maxCandidates` recent visible sessions excluding active and enables only with at
least two sessions. Selection records activity, selects, and focuses.
- Bell lists all non-idle sessions including current. Selection uses pane-aware reveal.
- Bell lists all non-idle sessions across open windows, current included. Selection uses the same
pane-aware reveal as the palette; see [[notifications]] for the cross-window raise.
- Popover opens are keep-in-sync exempt. Synthesized XCUITest clicks inside `NSPopover` do not fire the
SwiftUI button, though real clicks do; tests verify open/list contents, while selection is manual plus
host-free API coverage.
20 changes: 14 additions & 6 deletions .claude/rules/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,21 @@ paths:
## Titlebar attention

- With `attentionButtonEnabled` off by default, `customTitlebar` places a bell after recent sessions and
before scratch/split/quick-terminal controls. It derives live state from all non-idle
`AppStore.attentionSessions`: empty is disabled `bell` at about 0.35 opacity; non-blocked is enabled
`bell` in `chromeText`; any blocked is enabled `bell.fill` in `blockedStatusColor`. There is no count
or pulse.
before scratch/split/quick-terminal controls. It derives live state from
`WindowLibrary.attentionAcrossWindows`, every open window's non-idle sessions in one
`AppStore.attentionPrecedes` order: empty is disabled `bell` at about 0.35 opacity; non-blocked is
enabled `bell` in `chromeText`; any blocked is enabled `bell.fill` in `blockedStatusColor`. There is no
count or pulse. That getter reads a private open-set version the library bumps on every store load and
drop, because `stores` is observation-ignored and a background window closing changes neither
`windows` nor `frontmostWindowID`.
- Clicking opens the mouse popover of `SessionPopoverRow`s with `StatusGlyph`, ordered
blocked, active, completed; selection reveals the tagged blocked pane. Ctrl-Shift-I, Navigate > Go to
Attention, and Show Attention in the palette retain the searchable keyboard surface.
blocked, active, completed, subtitled by `attentionSubtitle` (window name first once more than one
window is open). Selection closes the popover, then `AppActions.selectAttention` on the next turn:
it rechecks the owning window's modal gate, raises it through `WindowRegistry.raise` plus
`takeFrontmost` when it is not the active one, and reveals the tagged blocked pane through
`revealActiveBlockedPane` exactly as the per-window path does. Ctrl-Shift-I, Navigate > Go to
Attention, and Show Attention in the palette retain the searchable keyboard surface over the same
list. The Dock menu stays scoped to its captured window.
- The button ID is `attention-button`, with help and value `none`, `attention`, or `blocked`.
`WindowContentView` mirrors `GhosttyApp.attentionButtonEnabled` into state and refreshes on
`.agtermAppearanceChanged`, not `model.settings`. This mouse form of controllable attention selection is
Expand Down
21 changes: 21 additions & 0 deletions agterm/AppActions+Focus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,27 @@ extension AppActions {
}
}

/// Whether an attention row can be acted on right now: its window open and not under a cover, its
/// session still there. Asked at render and again at the pick, since a row outlives all three.
func canSelectAttention(windowID: WindowInfo.ID, sessionID: UUID) -> Bool {
uiActionsEnabled(for: windowID) && library.store(for: windowID)?.session(withID: sessionID) != nil
}

/// Select a row of the cross-window attention list and reveal its pane, raising its window first when it
/// is not the active one so `store` resolves there. A raise that fails (the window still attaching)
/// drops the pick as a window step does.
func selectAttention(windowID: WindowInfo.ID, sessionID: UUID) {
guard canSelectAttention(windowID: windowID, sessionID: sessionID),
let target = library.store(for: windowID) else { return }
if windowID != library.activeWindowID {
guard WindowRegistry.shared.raise(windowID) else { return }
takeFrontmost(windowID)
}
target.noteUserActivity()
let indicator = target.selectSession(sessionID)
revealActiveBlockedPane(captured: indicator)
}

/// Front and focus the window a recent-closed reopen restored into. The id is published here rather
/// than left to the key-window report, which `focusActiveSession` would otherwise outrun; publishing it
/// also has to save and post, because `WindowAccessor.reportFrontmost` gates both on the id having
Expand Down
2 changes: 1 addition & 1 deletion agterm/AppActions+Navigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ extension AppActions {
/// `didBecomeKey` and a step from the quick terminal raises with agterm INACTIVE — leaving the id stale,
/// so every later step recomputes from the same origin. The control twin omits the post below, its
/// dispatch refreshing that cache inline.
private func takeFrontmost(_ id: WindowInfo.ID) {
func takeFrontmost(_ id: WindowInfo.ID) {
guard library.frontmostWindowID != id else { return }
library.frontmostWindowID = id
library.saveIndex()
Expand Down
33 changes: 20 additions & 13 deletions agterm/AppActions+Palette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,27 +225,34 @@ extension AppActions {
return store.navigableSessions.map { paletteItem(for: $0, in: store) }
}

/// The window's non-idle sessions as palette items (`.attention` mode), each row carrying the session's
/// agent-status glyph. `store.attentionSessions` orders blocked→active→completed, newest status-change
/// first, so the empty-query order matches; choosing one selects it. Subtitle as in `paletteSessions()`.
/// Every open window's non-idle sessions as palette items (`.attention` mode), in the library's order so
/// the empty query keeps it. Enablement asks the OWNING window, not the frontmost one, and the run
/// defers `selectAttention` past the palette's close and focus-restore so a raise never competes with
/// the dismissal.
func paletteAttention() -> [PaletteItem] {
guard let store else { return [] }
return store.attentionSessions.map {
paletteItem(for: $0, in: store, status: $0.agentIndicator.status,
statusColor: $0.agentIndicator.color, statusShape: $0.agentIndicator.shape)
library.attentionAcrossWindows.map { entry in
let windowID = entry.window.id
let sessionID = entry.session.id
let indicator = entry.session.agentIndicator
return PaletteItem(id: sessionID.uuidString, title: entry.session.displayName,
subtitle: library.attentionSubtitle(entry), status: indicator.status,
statusColor: indicator.color, statusShape: indicator.shape,
isEnabled: { [weak self] in
self?.canSelectAttention(windowID: windowID, sessionID: sessionID) ?? false
},
run: { [weak self] in
DispatchQueue.main.async { self?.selectAttention(windowID: windowID, sessionID: sessionID) }
})
}
}

/// Maps one session to a palette row — title `displayName`, subtitle "`workspace` · `subtitleDetail`", run
/// selects it. Shared by `paletteSessions()` (status nil) and `paletteAttention()`, where a set status makes
/// `CommandPalette.row` render the leading `StatusGlyph` in the per-call `statusColor`/`statusShape`.
private func paletteItem(for session: Session, in store: AppStore, status: AgentStatus? = nil,
statusColor: String? = nil, statusShape: StatusShape? = nil) -> PaletteItem {
/// selects it.
private func paletteItem(for session: Session, in store: AppStore) -> PaletteItem {
let id = session.id
let workspaceName = store.workspace(forSession: id)?.name ?? ""
let subtitle = "\(workspaceName) · \(session.subtitleDetail)"
return PaletteItem(id: id.uuidString, title: session.displayName, subtitle: subtitle,
status: status, statusColor: statusColor, statusShape: statusShape) { [weak self] in
return PaletteItem(id: id.uuidString, title: session.displayName, subtitle: subtitle) { [weak self] in
guard self?.uiActionsEnabled == true else { return }
// a palette pick is user-initiated: note activity so it buys the full idle grace before
// auto-follow can pull the selection back.
Expand Down
4 changes: 2 additions & 2 deletions agterm/Views/SessionSwitcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,8 @@ struct SessionSwitcherOverlay: View {
}

/// Carries the measured row-stack height out of the scroll content, so the panel can size to its rows
/// instead of to the height it is offered.
private struct RowsHeightKey: PreferenceKey {
/// instead of to the height it is offered. Shared with the title-bar attention popover.
struct RowsHeightKey: PreferenceKey {
static let defaultValue: Double = 0
static func reduce(value: inout Double, nextValue: () -> Double) { value = max(value, nextValue()) }
}
Expand Down
95 changes: 59 additions & 36 deletions agterm/Views/WindowContentView+RecentSessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,18 +106,17 @@ extension WindowContentView {
recentSessionsShown = false
}

/// Title-bar bell reflecting the window's attention state (opt-in, gated by the `attentionButtonEnabled`
/// mirror). Three states from `store.attentionSessions`: empty → a dimmed disabled outline bell; non-empty
/// with nothing blocked → a plain enabled bell in `chromeText`; any blocked session → a filled bell tinted
/// the blocked-status color. No count, no pulse. Click opens the attention popover (the mouse form; ⌃⇧I /
/// the Navigate menu keep the searchable `.attention` palette). Reading `store.attentionSessions` in the
/// body registers the per-session `agentIndicator` observation, so the glyph re-renders live;
/// `.accessibilityValue` (none|attention|blocked) exposes the otherwise-unobservable bell↔bell.fill state
/// to XCUITest, mirroring `StatusIconView`.
/// Title-bar bell reflecting the attention state across every open window (opt-in, gated by the
/// `attentionButtonEnabled` mirror): dimmed and disabled when empty, plain when nothing is blocked, filled
/// in the blocked-status color otherwise. No count, no pulse. Click opens the attention popover (the
/// mouse form; ⌃⇧I / the Navigate menu keep the searchable `.attention` palette). Reading the library
/// list in the body registers the per-session `agentIndicator` observation and the open-set version, so
/// the glyph re-renders live; `.accessibilityValue` (none|attention|blocked) exposes the
/// otherwise-unobservable bell↔bell.fill state to XCUITest, mirroring `StatusIconView`.
var attentionButton: some View {
let sessions = store.attentionSessions
let blocked = sessions.contains { $0.agentIndicator.status == .blocked }
let empty = sessions.isEmpty
let entries = library.attentionAcrossWindows
let blocked = entries.contains { $0.session.agentIndicator.status == .blocked }
let empty = entries.isEmpty
let enabled = !empty && !pick.modalPending
return Button {
guard !pick.modalPending else { return }
Expand Down Expand Up @@ -145,39 +144,59 @@ extension WindowContentView {
}
}

/// The attention popover body: the window's sessions needing attention (`store.attentionSessions`, sorted
/// blocked→active→completed) as full-row `SessionPopoverRow`s with a leading status glyph — the mouse form
/// of the ⌃⇧I attention palette, tinted and hover-highlighted like the recent-sessions popover. Clicking a
/// row selects the session and reveals its blocked pane.
/// The attention popover body: every open window's sessions needing attention as full-row
/// `SessionPopoverRow`s with a leading status glyph — the mouse form of the ⌃⇧I attention palette. A row
/// whose window sits under a cover renders disabled, as the palette's does, rather than dismissing into
/// a no-op. The rows are measured so short lists stay compact and long lists scroll at
/// `attentionRowsCap`, for the reason `measuredPanelHeight` gives.
private var attentionPopover: some View {
VStack(spacing: 2) {
ForEach(store.attentionSessions) { session in
SessionPopoverRow(
title: session.displayName,
subtitle: "\(store.workspace(forSession: session.id)?.name ?? "") · \(session.subtitleDetail)",
status: session.agentIndicator.status,
statusColorHex: session.agentIndicator.color,
statusShape: session.agentIndicator.shape,
foreground: chromeText,
hoverColor: popoverHoverColor,
accessibilityID: "attention-session-row"
) { selectAttention(session.id) }
let metrics = GhosttyApp.shared.interfaceMetrics
return ScrollView {
VStack(spacing: 2) {
ForEach(library.attentionAcrossWindows) { entry in
SessionPopoverRow(
title: entry.session.displayName,
subtitle: library.attentionSubtitle(entry),
status: entry.session.agentIndicator.status,
statusColorHex: entry.session.agentIndicator.color,
statusShape: entry.session.agentIndicator.shape,
foreground: chromeText,
hoverColor: popoverHoverColor,
accessibilityID: "attention-session-row",
isEnabled: actions.canSelectAttention(windowID: entry.window.id, sessionID: entry.session.id)
) { selectAttention(entry) }
}
}
.background(
GeometryReader { rows in
Color.clear.preference(key: RowsHeightKey.self, value: rows.size.height)
}
)
}
.frame(height: metrics.measuredPanelHeight(rowsHeight: attentionRowsHeight,
maxRowsHeight: metrics.scaled(Self.attentionRowsCap)).map { CGFloat($0) })
.scrollBounceBehavior(.basedOnSize)
.onPreferenceChange(RowsHeightKey.self) { height in
attentionRowsHeight = height
}
.padding(6)
.frame(width: GhosttyApp.shared.interfaceMetrics.scaled(320))
.frame(width: metrics.scaled(320))
.background(terminalColor)
.presentationBackground(terminalColor)
}

/// Commit an attention popover row click: select the session and reveal its blocked pane (the pane that
/// set the status), then close the popover — the mouse twin of the ⌃⇧I palette's select-and-reveal.
private func selectAttention(_ id: UUID) {
guard !pick.modalPending else { return }
store.noteUserActivity()
let indicator = store.selectSession(id)
actions.revealActiveBlockedPane(captured: indicator)
/// The attention popover's row-stack cap at the default interface size, about ten rows.
static let attentionRowsCap: Double = 440

/// Commit an attention popover row click. The popover closes first and the select runs on the next turn,
/// so a raise of another window never competes with this popover's dismissal; the action rechecks the
/// target then.
private func selectAttention(_ entry: AttentionEntry) {
let windowID = entry.window.id
let sessionID = entry.session.id
guard !pick.modalPending, actions.canSelectAttention(windowID: windowID, sessionID: sessionID) else { return }
attentionPopoverShown = false
DispatchQueue.main.async { actions.selectAttention(windowID: windowID, sessionID: sessionID) }
}
}

Expand All @@ -187,6 +206,7 @@ extension WindowContentView {
/// the row matches the sidebar glyph), a pointer-hover highlight (`hoverColor`) and a full-row hit area
/// (`.contentShape`), so the WHOLE row selects on click, not just the text. Kept a `Button` so it reads as an
/// actionable control to VoiceOver; `accessibilityID` distinguishes the two popovers' rows for the tests.
/// `isEnabled` false renders the row dimmed and inert, for an attention row whose window is under a cover.
private struct SessionPopoverRow: View {
let title: String
let subtitle: String
Expand All @@ -196,18 +216,21 @@ private struct SessionPopoverRow: View {
let foreground: Color
let hoverColor: Color
let accessibilityID: String
var isEnabled = true
let onSelect: () -> Void
@State private var hovering = false

var body: some View {
Button(action: onSelect) {
SessionSwitcherRow(title: title, subtitle: subtitle, foreground: foreground,
status: status, statusColorHex: statusColorHex, statusShape: statusShape)
.background(hovering ? hoverColor : Color.clear)
.background(hovering && isEnabled ? hoverColor : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 6))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!isEnabled)
.opacity(isEnabled ? 1 : 0.35)
.onHover { hovering = $0 }
.accessibilityIdentifier(accessibilityID)
}
Expand Down
3 changes: 3 additions & 0 deletions agterm/Views/WindowContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ struct WindowContentView: View {
/// Whether the attention popover (the mouse equivalent of the ⌃⇧I attention palette) is shown, anchored
/// on the title-bar bell. Non-private so the `+RecentSessions` extension's bell/rows can toggle it.
@State var attentionPopoverShown = false
/// The attention popover's measured row-stack height, 0 until its preference lands; the popover sizes to
/// it up to a cap so a long cross-window list scrolls instead of running off the screen.
@State var attentionRowsHeight: Double = 0
/// Whether the custom-commands popover (the mouse form of the ⌃⇧O palette) is shown, anchored on its
/// title-bar button. Non-private so the `+CustomCommands` extension's button/rows can toggle it.
@State var customCommandsShown = false
Expand Down
Loading
Loading