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: 7 additions & 5 deletions .claude/rules/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,14 @@ paths:
Row menus target their node; global surfaces call `clearActiveSessionStatus`; all set an empty indicator.
- `GhosttySurfaceView.keyDown` always calls `onUserInputClearsStatus(isInterrupt:)`. Main `.left`, split
`.right`, and scratch `.scratch` factories own the pane-scoped decision, allowing scratch to clear
without `view.session`. `AgentIndicator.clearedBy` clears blocked/completed on any key, active only on
interrupt, and only when the key's pane owns the status. Thus foreground typing cannot clear another
pane's status.
without `view.session`. `AgentIndicator.clearedBy` takes the key's kind (`InterruptKeystroke.classify`:
interrupt, submit for a bare Return or keypad Enter, else other) and the `StatusReset` mode: blocked and
completed clear on any key under `firstKey`, on submit alone under `enter`, never under `never`; active
clears on interrupt in every mode; and only when the key's pane owns the status. Thus foreground typing
cannot clear another pane's status.
- `session.type` fires that same clear through `GhosttySurfaceView.injectAsUserInput`, the input a blocked
agent was waiting for having arrived. `isInterrupt` is false like the AX insert's, and an EMPTY payload
clears nothing — `inject` queues no keystrokes yet still returns true. Unlike the AX insert it does not
agent was waiting for having arrived. Injected text classifies as submit when it carries a newline and
other otherwise, never interrupt, like the AX insert, and an EMPTY payload clears nothing — `inject` queues no keystrokes yet still returns true. Unlike the AX insert it does not
fire `onUserInput`: that stamps the user as present and holds off auto-follow, which a script typing into
a background pane must not do. `quick.type` keeps plain `inject`; the quick terminal is no session and
carries no glyph.
Expand Down
5 changes: 4 additions & 1 deletion .claude/rules/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ paths:
Multiple Windows and the quick terminal's panel size, which sits there rather than under Appearance's
Window because the panel belongs to no window.
Notifications holds banner/badge/attention/bounce/sound. Agent Status holds colors/shapes, sound,
auto-follow, and Reset. Key Mapping holds config directory, diagnostics, and Reload.
status reset, auto-follow, and Reset. Key Mapping holds config directory, diagnostics, and Reload.
- `statusReset` stores a raw `StatusReset` (`firstKey`|`enter`|`never`), nil for the default `firstKey`,
resolved by `effectiveStatusReset` and mirrored to `GhosttyApp.statusReset`, which the surface factories'
keystroke-clear closure reads at keystroke time. Reset to defaults clears it with the glyph settings.
- Keep titlebar construction in `WindowContentView+Titlebar.swift` so `WindowContentView.swift` remains
below the 1000-line limit.
- Keep Agent Status shape pickers in a trailing-aligned 80-point column wider than the 64.5...68-point
Expand Down
7 changes: 7 additions & 0 deletions agterm/Ghostty/GhosttyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ final class GhosttyApp {
/// Whether the sidebar draws the red unseen-notification count badge. The sidebar Coordinator reads it
/// (gating the count to 0 when off); settings-mirrored like `toolbarMode`.
private(set) var notificationBadgeEnabled: Bool = true
/// Which keystroke clears a blocked or completed glyph; read at keystroke time by the surface factories'
/// status-clear closure. Settings-mirrored like `toolbarMode`.
private(set) var statusReset: StatusReset = .firstKey
/// Whether a click anywhere on a sidebar workspace row toggles its expansion; on by default. The sidebar
/// Coordinator reads it in `handleSingleClick`, and the disclosure triangle ignores it because AppKit
/// toggles that natively. Settings-mirrored like `toolbarMode`.
Expand Down Expand Up @@ -213,6 +216,10 @@ final class GhosttyApp {
attentionButtonEnabled = enabled
}

func setStatusReset(_ mode: StatusReset) {
statusReset = mode
}

func setHiddenInterfaceElements(_ elements: Set<InterfaceElement>) {
hiddenInterfaceElements = elements
}
Expand Down
8 changes: 4 additions & 4 deletions agterm/Ghostty/GhosttySurfaceView+Accessibility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -279,12 +279,12 @@ extension GhosttySurfaceView {
/// dictating user counts as idle (`onUserInput` is what resets the window's auto-follow timer, so
/// auto-follow would yank the selection to a blocked session MID-sentence and deliver the rest of the
/// text to the wrong terminal) and a session's stale agent-status glyph survives a dictated reply
/// (`onUserInputClearsStatus`). `isInterrupt` is always false: an AX insert carries text, never the
/// Escape/Ctrl-C keystroke that clears an ACTIVE glyph, so it clears blocked/completed only — the same
/// answer `isInterruptKeystroke` gives for an ordinary printable key.
/// (`onUserInputClearsStatus`). Always plain typing: a multi-line insert lands as a paste, not as Return,
/// and an AX insert is never the Escape/Ctrl-C interrupt that clears an ACTIVE glyph, so it clears
/// blocked/completed only, and only when the Status reset setting clears on the first key.
private func insertFromAccessibility(_ text: String) {
onUserInput?()
onUserInputClearsStatus?(false)
onUserInputClearsStatus?(.other)
// ends any live composition BEFORE either branch. `insertPasted` commits for itself now, so the
// paste branch's second call is a no-op against the `hasMarkedText()` guard; the `insertText`
// branch has no commit of its own (it IS the commit path), so the call has to happen here.
Expand Down
7 changes: 4 additions & 3 deletions agterm/Ghostty/GhosttySurfaceView+IO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,16 @@ extension GhosttySurfaceView {
}

/// `inject` plus the pane-scoped status clear `keyDown` fires, for `session.type`: the input a blocked
/// agent was waiting for has arrived, so the glyph must not outlive it. `isInterrupt` is false like the AX
/// insert's — injected text is not the Escape/Ctrl-C keystroke that clears an ACTIVE glyph. It deliberately
/// agent was waiting for has arrived, so the glyph must not outlive it. The text classifies as a submit
/// when it carries a newline and plain typing otherwise, never as the Escape/Ctrl-C interrupt that clears
/// an ACTIVE glyph, like the AX insert. It deliberately
/// does NOT fire `onUserInput`, unlike dictation: that stamps the user as present and holds off auto-follow,
/// which a script typing into a background pane must not do. Empty text queues no keystrokes yet still
/// returns true, so it clears nothing.
@discardableResult
func injectAsUserInput(text: String) -> Bool {
guard inject(text: text) else { return false }
if !text.isEmpty { onUserInputClearsStatus?(false) }
if !text.isEmpty { onUserInputClearsStatus?(InterruptKeystroke.classify(text: text)) }
return true
}

Expand Down
25 changes: 13 additions & 12 deletions agterm/Ghostty/GhosttySurfaceView+Input.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,16 @@ extension GhosttySurfaceView {
// MARK: - Keyboard

/// Reduce an `NSEvent` to the host-free `InterruptKeystroke` classifier — Escape or a bare Ctrl-C.
private func isInterruptKeystroke(_ event: NSEvent) -> Bool {
private func classifyKeystroke(_ event: NSEvent) -> StatusKeystroke {
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
var modifiers: KeyModifiers = []
if flags.contains(.control) { modifiers.insert(.control) }
if flags.contains(.command) { modifiers.insert(.command) }
if flags.contains(.option) { modifiers.insert(.option) }
if flags.contains(.shift) { modifiers.insert(.shift) }
return InterruptKeystroke.isInterrupt(keyCode: event.keyCode,
character: event.charactersIgnoringModifiers,
modifiers: modifiers)
return InterruptKeystroke.classify(keyCode: event.keyCode,
character: event.charactersIgnoringModifiers,
modifiers: modifiers)
}

override func keyDown(with event: NSEvent) {
Expand All @@ -50,14 +50,15 @@ extension GhosttySurfaceView {
// every keystroke is user activity: reset the auto-follow idle timer UNCONDITIONALLY, not gated on
// the status-clear below, else typing in an idle session yanks the user to a blocked one mid-type.
onUserInput?()
// a keystroke clears an attention glyph to idle: blocked/completed on ANY key, active ONLY on an
// interrupt (Escape or Ctrl-C), so typing while the agent works keeps the "working" glyph but
// cancelling a pending prompt drops it. Claude Code treats Ctrl-C like Esc for dismissing a prompt,
// yet neither fires a hook and a cancelled prompt can still read active (its blocked notification
// lands seconds later), so this is the only signal that drops the stale glyph. fire UNCONDITIONALLY
// with the isInterrupt flag: the pane-scoped decision belongs to AgentIndicator.clearedBy, so the
// scratch (no view.session) self-clears too and a background pane's block survives foreground typing.
onUserInputClearsStatus?(isInterruptKeystroke(event))
// a keystroke clears an attention glyph to idle: blocked/completed as the Status reset setting says,
// active ONLY on an interrupt (Escape or Ctrl-C), so typing while the agent works keeps the "working"
// glyph but cancelling a pending prompt drops it. Claude Code treats Ctrl-C like Esc for dismissing a
// prompt, yet neither fires a hook and a cancelled prompt can still read active (its blocked
// notification lands seconds later), so this is the only signal that drops the stale glyph. fire
// UNCONDITIONALLY with the classified key: the pane-scoped decision belongs to AgentIndicator.clearedBy,
// so the scratch (no view.session) self-clears too and a background pane's block survives foreground
// typing.
onUserInputClearsStatus?(classifyKeystroke(event))
let action: ghostty_input_action_e = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)

Expand Down
16 changes: 8 additions & 8 deletions agterm/Ghostty/GhosttySurfaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,14 @@ final class GhosttySurfaceView: NSView, PaneRoleMutableSurface {
/// whose focus report is suppressed though the user is looking at it. Set by the main/split factories.
var onClearUnseen: (() -> Void)?

/// Called on the main actor on EVERY keystroke into this surface, carrying whether the key interrupts the
/// agent (Escape or Ctrl-C). The factory decides per pane via `AgentIndicator.clearedBy(pane:isInterrupt:)`:
/// clear the glyph to idle only when THIS surface's pane owns a clearable status — `blocked`/`completed`
/// on any key, `active` only on an interrupt — so foreground typing cannot wipe a background pane's block.
/// Passing the pane rather than reading `view.session` lets the scratch, which has none, self-clear.
/// Status is otherwise control-driven; this is the one input-driven clear, for the decline case Claude
/// Code fires no hook for.
var onUserInputClearsStatus: ((Bool) -> Void)?
/// Called on the main actor on EVERY keystroke into this surface, carrying what the key means to the glyph
/// (`InterruptKeystroke.classify`: interrupt, submit or plain typing). The factory decides per pane via
/// `AgentIndicator.clearedBy(pane:keystroke:reset:)`: clear the glyph to idle only when THIS surface's pane
/// owns a clearable status — `blocked`/`completed` as the Status reset setting says, `active` only on an
/// interrupt — so foreground typing cannot wipe a background pane's block. Passing the pane rather than
/// reading `view.session` lets the scratch, which has none, self-clear. Status is otherwise
/// control-driven; this is the one input-driven clear, for the decline case Claude Code fires no hook for.
var onUserInputClearsStatus: ((StatusKeystroke) -> Void)?

/// Called on the main actor on EVERY keystroke to stamp user activity and reset the window's auto-follow
/// idle timer. Fires unconditionally, unlike `onUserInputClearsStatus`: ordinary typing in an idle
Expand Down
9 changes: 9 additions & 0 deletions agterm/SettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ final class SettingsModel {
applyAgentStatusShapes()
applyWorkspaceRowClickExpands()
applyAttentionButtonEnabled()
applyStatusReset()
applyInterfaceElements()
applyAutoHideSidebarInactiveWindows()
ensureStarterKeymap()
Expand Down Expand Up @@ -287,6 +288,8 @@ final class SettingsModel {
/// Persist the system sound played when a session enters `blocked` (nil/empty = none). Not a ghostty
/// key and nothing renders it continuously, so it only saves — `ControlServer` reads it on demand.
func setBlockedStatusSoundName(_ name: String?) { settings.blockedStatusSoundName = name; try? settingsStore.save(settings) }
/// nil restores the default (clear on the first key), keeping the stored file minimal.
func setStatusReset(_ mode: StatusReset?) { settings.statusReset = mode?.rawValue; persistAndApply() }
/// Persist where a new (⌘T) session opens (nil = home). Read only at the next `AppActions.newSession()`,
/// so it just saves — no config rewrite or surface reload.
func setNewSessionDirectory(_ value: String?) { settings.newSessionDirectory = value; try? settingsStore.save(settings) }
Expand Down Expand Up @@ -402,6 +405,7 @@ final class SettingsModel {
settings.blockedStatusShape = nil
settings.completedStatusShape = nil
settings.blockedStatusSoundName = nil
settings.statusReset = nil
persistAndApply()
}

Expand Down Expand Up @@ -643,6 +647,7 @@ final class SettingsModel {
applyAgentStatusShapes()
applyWorkspaceRowClickExpands()
applyAttentionButtonEnabled()
applyStatusReset()
applyInterfaceElements()
applyAutoHideSidebarInactiveWindows()
// refresh the chrome (title bar + sidebar + quick terminal) for the new terminal color,
Expand Down Expand Up @@ -683,6 +688,10 @@ final class SettingsModel {
GhosttyApp.shared.setAttentionButtonEnabled(settings.attentionButtonEnabled ?? false)
}

private func applyStatusReset() {
GhosttyApp.shared.setStatusReset(settings.effectiveStatusReset)
}

private func applyInterfaceElements() {
GhosttyApp.shared.setHiddenInterfaceElements(settings.resolvedHiddenInterfaceElements)
}
Expand Down
19 changes: 18 additions & 1 deletion agterm/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,8 @@ private struct NotificationsSettingsView: View {
}

/// Agent Status tab: Colors and Shapes (a row per state — active/blocked/completed — with that glyph's color
/// well and shape picker), Sound, Auto-follow (idle timeout + stay-on-active), and a Reset clearing all three.
/// well and shape picker), Sound, Typing (which keystroke clears a blocked/completed glyph), Auto-follow (idle
/// timeout + stay-on-active), and a Reset clearing the first three.
private struct AgentStatusSettingsView: View {
/// Gap between a glyph row's color well and its shape picker.
private static let controlSpacing: CGFloat = 8
Expand Down Expand Up @@ -623,6 +624,16 @@ private struct AgentStatusSettingsView: View {
.accessibilityIdentifier("settings-status-blocked-sound")
}

Section("Typing") {
Picker("Status reset", selection: statusReset) {
Text("On first key").tag(StatusReset.firstKey)
Text("On Enter").tag(StatusReset.enter)
Text("Disabled").tag(StatusReset.never)
}
.accessibilityIdentifier("settings-status-clear")
SettingHint("When typing into a blocked or completed session clears its status.")
}

Section("Auto-follow") {
Picker("Auto-follow blocked sessions", selection: autoFollowAttention) {
Text("Disabled").tag(AppSettings.AutoFollowAttention.off)
Expand Down Expand Up @@ -748,6 +759,12 @@ private struct AgentStatusSettingsView: View {
}

// the sound played when a session enters `blocked`; selecting one previews it, like the notification sound
/// Default first key; the default maps to nil so it never lands in the file.
private var statusReset: Binding<StatusReset> {
Binding(get: { model.settings.effectiveStatusReset },
set: { model.setStatusReset($0 == .firstKey ? nil : $0) })
}

private var blockedStatusSound: Binding<String> {
Binding(get: { model.settings.blockedStatusSoundName ?? "None" },
set: { name in
Expand Down
11 changes: 7 additions & 4 deletions agterm/agtermApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -490,17 +490,20 @@ struct agtermApp: App {
}

/// Wires the pane-scoped keystroke-clear: `keyDown` fires `onUserInputClearsStatus` unconditionally, and this
/// closure clears to idle only when host-free `AgentIndicator.clearedBy(pane:isInterrupt:)` says the keystroke's
/// OWN pane owns the status, so a block set from a background pane survives typing elsewhere. Main/split read
/// closure clears to idle only when host-free `AgentIndicator.clearedBy(pane:keystroke:reset:)` says the
/// keystroke's OWN pane owns the status under the Status reset setting, read live from `GhosttyApp` so a
/// Settings change applies to the next key. A block set from a background pane survives typing elsewhere. Main/split read
/// the LIVE `isSplitPane` at keystroke time, so a promoted survivor clears as `.left`, matching its migrated
/// status identity and `tree` addressing; a captured `.right` would clear the wrong pane and leave both panes
/// `.right`-wired after a re-split. The scratch passes `fixedPane: .scratch`: never promoted, no `view.session`.
@MainActor
private static func wireStatusClear(_ view: GhosttySurfaceView, store: AppStore, sessionID: UUID,
fixedPane: StatusPane? = nil) {
view.onUserInputClearsStatus = { [weak view] isInterrupt in
view.onUserInputClearsStatus = { [weak view] keystroke in
let pane = fixedPane ?? ((view?.isSplitPane ?? false) ? .right : .left)
if store.session(withID: sessionID)?.agentIndicator.clearedBy(pane: pane, isInterrupt: isInterrupt) == true {
let reset = GhosttyApp.shared.statusReset
if store.session(withID: sessionID)?.agentIndicator
.clearedBy(pane: pane, keystroke: keystroke, reset: reset) == true {
store.setAgentIndicator(AgentIndicator(), forSession: sessionID)
}
}
Expand Down
Loading
Loading