diff --git a/.claude/rules/notifications.md b/.claude/rules/notifications.md index 1f3130f4..b903ef75 100644 --- a/.claude/rules/notifications.md +++ b/.claude/rules/notifications.md @@ -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. diff --git a/.claude/rules/settings.md b/.claude/rules/settings.md index 168639be..15b6c2f9 100644 --- a/.claude/rules/settings.md +++ b/.claude/rules/settings.md @@ -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 diff --git a/agterm/Ghostty/GhosttyApp.swift b/agterm/Ghostty/GhosttyApp.swift index 739ab09a..08848a37 100644 --- a/agterm/Ghostty/GhosttyApp.swift +++ b/agterm/Ghostty/GhosttyApp.swift @@ -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`. @@ -213,6 +216,10 @@ final class GhosttyApp { attentionButtonEnabled = enabled } + func setStatusReset(_ mode: StatusReset) { + statusReset = mode + } + func setHiddenInterfaceElements(_ elements: Set) { hiddenInterfaceElements = elements } diff --git a/agterm/Ghostty/GhosttySurfaceView+Accessibility.swift b/agterm/Ghostty/GhosttySurfaceView+Accessibility.swift index e1f11340..1efea63e 100644 --- a/agterm/Ghostty/GhosttySurfaceView+Accessibility.swift +++ b/agterm/Ghostty/GhosttySurfaceView+Accessibility.swift @@ -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. diff --git a/agterm/Ghostty/GhosttySurfaceView+IO.swift b/agterm/Ghostty/GhosttySurfaceView+IO.swift index 2fab9946..64be9444 100644 --- a/agterm/Ghostty/GhosttySurfaceView+IO.swift +++ b/agterm/Ghostty/GhosttySurfaceView+IO.swift @@ -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 } diff --git a/agterm/Ghostty/GhosttySurfaceView+Input.swift b/agterm/Ghostty/GhosttySurfaceView+Input.swift index 2bfb8043..51c5130e 100644 --- a/agterm/Ghostty/GhosttySurfaceView+Input.swift +++ b/agterm/Ghostty/GhosttySurfaceView+Input.swift @@ -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) { @@ -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) diff --git a/agterm/Ghostty/GhosttySurfaceView.swift b/agterm/Ghostty/GhosttySurfaceView.swift index c14eb18c..ec7f9f6e 100644 --- a/agterm/Ghostty/GhosttySurfaceView.swift +++ b/agterm/Ghostty/GhosttySurfaceView.swift @@ -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 diff --git a/agterm/SettingsModel.swift b/agterm/SettingsModel.swift index 323da460..566f1e76 100644 --- a/agterm/SettingsModel.swift +++ b/agterm/SettingsModel.swift @@ -69,6 +69,7 @@ final class SettingsModel { applyAgentStatusShapes() applyWorkspaceRowClickExpands() applyAttentionButtonEnabled() + applyStatusReset() applyInterfaceElements() applyAutoHideSidebarInactiveWindows() ensureStarterKeymap() @@ -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) } @@ -402,6 +405,7 @@ final class SettingsModel { settings.blockedStatusShape = nil settings.completedStatusShape = nil settings.blockedStatusSoundName = nil + settings.statusReset = nil persistAndApply() } @@ -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, @@ -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) } diff --git a/agterm/Views/SettingsView.swift b/agterm/Views/SettingsView.swift index 9a4e619f..ad06aabc 100644 --- a/agterm/Views/SettingsView.swift +++ b/agterm/Views/SettingsView.swift @@ -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 @@ -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) @@ -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 { + Binding(get: { model.settings.effectiveStatusReset }, + set: { model.setStatusReset($0 == .firstKey ? nil : $0) }) + } + private var blockedStatusSound: Binding { Binding(get: { model.settings.blockedStatusSoundName ?? "None" }, set: { name in diff --git a/agterm/agtermApp.swift b/agterm/agtermApp.swift index b395687c..e5d146e5 100644 --- a/agterm/agtermApp.swift +++ b/agterm/agtermApp.swift @@ -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) } } diff --git a/agtermCore/Sources/agtermCore/AgentStatus.swift b/agtermCore/Sources/agtermCore/AgentStatus.swift index 60633ced..07d948a6 100644 --- a/agtermCore/Sources/agtermCore/AgentStatus.swift +++ b/agtermCore/Sources/agtermCore/AgentStatus.swift @@ -1,3 +1,19 @@ +/// StatusReset is which keystroke clears a `blocked` or `completed` glyph: the first key, a submit (Return), +/// or none. Raw-stored in `AppSettings.statusReset`, resolved by `effectiveStatusReset`. +public enum StatusReset: String, Codable, Sendable, CaseIterable { + case firstKey + case enter + case never +} + +/// StatusKeystroke is what one keystroke means to the glyph: an interrupt (Escape or Ctrl-C), a submit +/// (Return with no modifier, or injected text carrying a newline), or plain typing. +public enum StatusKeystroke: Sendable, Equatable { + case interrupt + case submit + case other +} + /// AgentStatus is the per-session agent state driven over the control channel (`session.status`). /// `idle` means nothing is shown; the other cases each render a tinted sidebar glyph. public enum AgentStatus: String, Codable, Sendable, CaseIterable { @@ -8,15 +24,21 @@ public enum AgentStatus: String, Codable, Sendable, CaseIterable { public var needsAttention: Bool { self == .blocked || self == .completed } /// Whether a keystroke in the session's terminal should clear this glyph back to idle. `blocked` and - /// `completed` clear on ANY key (you've engaged with the prompt / the finished result); `active` clears ONLY - /// on an interrupt (Escape or Ctrl-C), so typing while the agent works keeps the "working" glyph. That - /// covers the quick-cancel case: a pending question can still read `active` when you cancel it (Claude - /// Code's `blocked` notification lands seconds later) and the interrupt fires no hook, so nothing else - /// drops the stale value. - func clearedByKeystroke(isInterrupt: Bool) -> Bool { + /// `completed` clear as `reset` says: on any key (you've engaged with the prompt / the finished result), + /// on a submit only (the reply is sent, so a half-typed one keeps the glyph), or never. `active` clears + /// ONLY on an interrupt (Escape or Ctrl-C) in every mode, so typing while the agent works keeps the + /// "working" glyph. That covers the quick-cancel case: a pending question can still read `active` when + /// you cancel it (Claude Code's `blocked` notification lands seconds later) and the interrupt fires no + /// hook, so nothing else drops the stale value. + func clearedBy(keystroke: StatusKeystroke, reset: StatusReset) -> Bool { switch self { - case .blocked, .completed: return true - case .active: return isInterrupt + case .blocked, .completed: + switch reset { + case .firstKey: return true + case .enter: return keystroke == .submit + case .never: return false + } + case .active: return keystroke == .interrupt case .idle: return false } } @@ -125,9 +147,10 @@ public struct AgentIndicator: Equatable, Sendable { } /// clearedBy: a keystroke from `pane` clears this indicator only when that pane owns the current status and - /// `clearedByKeystroke` allows it, so foreground typing can't wipe a background pane's status. - public func clearedBy(pane: StatusPane, isInterrupt: Bool) -> Bool { - (statusPane ?? .left) == pane && status.clearedByKeystroke(isInterrupt: isInterrupt) + /// `AgentStatus.clearedBy(keystroke:reset:)` allows it, so foreground typing can't wipe a background + /// pane's status. + public func clearedBy(pane: StatusPane, keystroke: StatusKeystroke, reset: StatusReset) -> Bool { + (statusPane ?? .left) == pane && status.clearedBy(keystroke: keystroke, reset: reset) } /// normalizedPane: the tag as the store keeps it — a `.right` tag on a splitless session folds to `.left`, diff --git a/agtermCore/Sources/agtermCore/AppSettings.swift b/agtermCore/Sources/agtermCore/AppSettings.swift index 5846fed5..947fbcc6 100644 --- a/agtermCore/Sources/agtermCore/AppSettings.swift +++ b/agtermCore/Sources/agtermCore/AppSettings.swift @@ -264,6 +264,9 @@ public struct AppSettings: Codable, Equatable, Sendable { /// System sound played when a session enters `blocked` (resolved by `NSSound(named:)`), nil/empty for /// silent. A per-call `session.status --sound` overrides this. public var blockedStatusSoundName: String? + /// Raw `StatusReset`: which keystroke clears a blocked or completed glyph. nil = `firstKey`, resolved by + /// `effectiveStatusReset`. + public var statusReset: String? /// Whether a right-click pastes the clipboard (ghostty `right-click-action`); nil = on, since agterm /// forwards right-/middle-click to libghostty. agterm has no terminal context menu, so paste-or-off is /// the whole meaningful choice. @@ -324,7 +327,7 @@ public struct AppSettings: Codable, Equatable, Sendable { restoreRunningCommand: Bool? = nil, inheritGlobalGhosttyConfig: Bool? = nil, attentionButtonEnabled: Bool? = nil, dockBounce: String? = nil, notificationSoundName: String? = nil, - blockedStatusSoundName: String? = nil, rightClickPaste: Bool? = nil, + blockedStatusSoundName: String? = nil, statusReset: String? = nil, rightClickPaste: Bool? = nil, workspaceRowClickExpands: Bool? = nil, newSessionDirectory: String? = nil, newSessionCustomDirectory: String? = nil, confirmCloseSession: Bool? = nil, closeGraceUndoEnabled: Bool? = nil, @@ -363,6 +366,7 @@ public struct AppSettings: Codable, Equatable, Sendable { self.dockBounce = dockBounce self.notificationSoundName = notificationSoundName self.blockedStatusSoundName = blockedStatusSoundName + self.statusReset = statusReset self.rightClickPaste = rightClickPaste self.workspaceRowClickExpands = workspaceRowClickExpands self.newSessionDirectory = newSessionDirectory @@ -409,6 +413,11 @@ public struct AppSettings: Codable, Equatable, Sendable { toolbarMode.flatMap(ToolbarMode.init(rawValue:)) ?? (compactToolbar == false ? .normal : .compact) } + /// The resolved status-reset mode: the explicit `statusReset` when a KNOWN raw value, else `firstKey`. + public var effectiveStatusReset: StatusReset { + statusReset.flatMap(StatusReset.init(rawValue:)) ?? .firstKey + } + /// The resolved Dock-bounce mode: the explicit `dockBounce` when a KNOWN raw value, else `off`. The /// single read point. public var effectiveDockBounce: DockBounce { diff --git a/agtermCore/Sources/agtermCore/InterruptKeystroke.swift b/agtermCore/Sources/agtermCore/InterruptKeystroke.swift index e096f907..69ebd3cd 100644 --- a/agtermCore/Sources/agtermCore/InterruptKeystroke.swift +++ b/agtermCore/Sources/agtermCore/InterruptKeystroke.swift @@ -18,6 +18,28 @@ public enum InterruptKeystroke { public static let cKeyCode: UInt16 = 8 /// The Escape key (macOS `kVK_Escape`). public static let escapeKeyCode: UInt16 = 53 + /// The Return key and the keypad Enter (macOS `kVK_Return`, `kVK_ANSI_KeypadEnter`). + public static let returnKeyCode: UInt16 = 36 + public static let keypadEnterKeyCode: UInt16 = 76 + + /// What the keystroke means to a status glyph: interrupt first, then submit, else plain typing. + public static func classify(keyCode: UInt16, character: String?, modifiers: KeyModifiers) -> StatusKeystroke { + if isInterrupt(keyCode: keyCode, character: character, modifiers: modifiers) { return .interrupt } + return isSubmit(keyCode: keyCode, modifiers: modifiers) ? .submit : .other + } + + /// What injected text (`session type`) means to a status glyph: an LF or CR anywhere submits, since the + /// injector types Return for each; anything else is plain typing. Never an interrupt. Scalars, not + /// characters: CRLF is one `Character` equal to neither, and Unicode separators are typed as text. + public static func classify(text: String) -> StatusKeystroke { + text.unicodeScalars.contains(where: { $0 == "\n" || $0 == "\r" }) ? .submit : .other + } + + /// Whether the keystroke submits the line: Return or keypad Enter with NO modifier. Shift-Return and + /// Option-Return insert a newline in Claude Code and Codex, so they stay plain typing. + public static func isSubmit(keyCode: UInt16, modifiers: KeyModifiers) -> Bool { + (keyCode == returnKeyCode || keyCode == keypadEnterKeyCode) && modifiers.isEmpty + } /// Whether the keystroke interrupts the agent. `character` is the layout's base letter for the key /// (`NSEvent.charactersIgnoringModifiers`); matching it covers Latin layouts including Dvorak, where the diff --git a/agtermCore/Tests/agtermCoreTests/AgentStatusTests.swift b/agtermCore/Tests/agtermCoreTests/AgentStatusTests.swift index 4b45a11b..48cac1f7 100644 --- a/agtermCore/Tests/agtermCoreTests/AgentStatusTests.swift +++ b/agtermCore/Tests/agtermCoreTests/AgentStatusTests.swift @@ -26,16 +26,45 @@ struct AgentStatusTests { #expect(!AgentStatus.active.needsAttention) } - @Test func clearedByKeystrokeClearsAttentionAlwaysAndActiveOnlyOnInterrupt() { - #expect(AgentStatus.blocked.clearedByKeystroke(isInterrupt: false)) - #expect(AgentStatus.blocked.clearedByKeystroke(isInterrupt: true)) - #expect(AgentStatus.completed.clearedByKeystroke(isInterrupt: false)) - #expect(AgentStatus.completed.clearedByKeystroke(isInterrupt: true)) - // isInterrupt = Esc or Ctrl-C; ordinary typing leaves the glyph - #expect(!AgentStatus.active.clearedByKeystroke(isInterrupt: false)) - #expect(AgentStatus.active.clearedByKeystroke(isInterrupt: true)) - #expect(!AgentStatus.idle.clearedByKeystroke(isInterrupt: false)) - #expect(!AgentStatus.idle.clearedByKeystroke(isInterrupt: true)) + @Test(arguments: [ + // (status, keystroke, reset, cleared) + (AgentStatus.blocked, StatusKeystroke.other, StatusReset.firstKey, true), + (AgentStatus.completed, StatusKeystroke.other, StatusReset.firstKey, true), + (AgentStatus.completed, StatusKeystroke.submit, StatusReset.firstKey, true), + (AgentStatus.completed, StatusKeystroke.interrupt, StatusReset.firstKey, true), + (AgentStatus.blocked, StatusKeystroke.other, StatusReset.enter, false), + (AgentStatus.completed, StatusKeystroke.other, StatusReset.enter, false), + (AgentStatus.completed, StatusKeystroke.submit, StatusReset.enter, true), + (AgentStatus.blocked, StatusKeystroke.submit, StatusReset.enter, true), + (AgentStatus.completed, StatusKeystroke.interrupt, StatusReset.enter, false), + (AgentStatus.blocked, StatusKeystroke.other, StatusReset.never, false), + (AgentStatus.completed, StatusKeystroke.submit, StatusReset.never, false), + (AgentStatus.completed, StatusKeystroke.interrupt, StatusReset.never, false), + // active clears on an interrupt alone, whatever the setting + (AgentStatus.active, StatusKeystroke.other, StatusReset.firstKey, false), + (AgentStatus.active, StatusKeystroke.submit, StatusReset.firstKey, false), + (AgentStatus.active, StatusKeystroke.interrupt, StatusReset.firstKey, true), + (AgentStatus.active, StatusKeystroke.interrupt, StatusReset.enter, true), + (AgentStatus.active, StatusKeystroke.interrupt, StatusReset.never, true), + (AgentStatus.idle, StatusKeystroke.interrupt, StatusReset.firstKey, false), + (AgentStatus.idle, StatusKeystroke.submit, StatusReset.enter, false), + ]) + func clearedByKeystrokeFollowsTheResetModeForAttentionAndInterruptForActive( + status: AgentStatus, keystroke: StatusKeystroke, reset: StatusReset, cleared: Bool + ) { + #expect(status.clearedBy(keystroke: keystroke, reset: reset) == cleared) + } + + @Test func indicatorClearsOnlyFromTheOwningPaneUnderTheResetMode() { + let right = AgentIndicator(status: .completed, statusPane: .right) + #expect(right.clearedBy(pane: .right, keystroke: .other, reset: .firstKey)) + #expect(!right.clearedBy(pane: .left, keystroke: .other, reset: .firstKey)) + #expect(!right.clearedBy(pane: .right, keystroke: .other, reset: .enter)) + #expect(right.clearedBy(pane: .right, keystroke: .submit, reset: .enter)) + #expect(!right.clearedBy(pane: .right, keystroke: .submit, reset: .never)) + let untagged = AgentIndicator(status: .blocked) + #expect(untagged.clearedBy(pane: .left, keystroke: .other, reset: .firstKey)) + #expect(!untagged.clearedBy(pane: .right, keystroke: .other, reset: .firstKey)) } @Test func indicatorDefaults() { @@ -88,27 +117,27 @@ struct AgentStatusTests { } @Test func clearedByMatchingPaneFollowsClearedByKeystroke() { - #expect(AgentIndicator(status: .blocked, statusPane: .right).clearedBy(pane: .right, isInterrupt: false)) - #expect(AgentIndicator(status: .blocked, statusPane: .right).clearedBy(pane: .right, isInterrupt: true)) - #expect(AgentIndicator(status: .completed, statusPane: .scratch).clearedBy(pane: .scratch, isInterrupt: false)) - #expect(!AgentIndicator(status: .active, statusPane: .right).clearedBy(pane: .right, isInterrupt: false)) - #expect(AgentIndicator(status: .active, statusPane: .right).clearedBy(pane: .right, isInterrupt: true)) - #expect(!AgentIndicator(status: .idle, statusPane: .right).clearedBy(pane: .right, isInterrupt: true)) + #expect(AgentIndicator(status: .blocked, statusPane: .right).clearedBy(pane: .right, keystroke: .other, reset: .firstKey)) + #expect(AgentIndicator(status: .blocked, statusPane: .right).clearedBy(pane: .right, keystroke: .interrupt, reset: .firstKey)) + #expect(AgentIndicator(status: .completed, statusPane: .scratch).clearedBy(pane: .scratch, keystroke: .other, reset: .firstKey)) + #expect(!AgentIndicator(status: .active, statusPane: .right).clearedBy(pane: .right, keystroke: .other, reset: .firstKey)) + #expect(AgentIndicator(status: .active, statusPane: .right).clearedBy(pane: .right, keystroke: .interrupt, reset: .firstKey)) + #expect(!AgentIndicator(status: .idle, statusPane: .right).clearedBy(pane: .right, keystroke: .interrupt, reset: .firstKey)) } @Test func clearedByNonMatchingPaneNeverClears() { - #expect(!AgentIndicator(status: .blocked, statusPane: .right).clearedBy(pane: .left, isInterrupt: false)) - #expect(!AgentIndicator(status: .blocked, statusPane: .right).clearedBy(pane: .left, isInterrupt: true)) - #expect(!AgentIndicator(status: .blocked, statusPane: .scratch).clearedBy(pane: .left, isInterrupt: false)) - #expect(!AgentIndicator(status: .active, statusPane: .scratch).clearedBy(pane: .right, isInterrupt: true)) + #expect(!AgentIndicator(status: .blocked, statusPane: .right).clearedBy(pane: .left, keystroke: .other, reset: .firstKey)) + #expect(!AgentIndicator(status: .blocked, statusPane: .right).clearedBy(pane: .left, keystroke: .interrupt, reset: .firstKey)) + #expect(!AgentIndicator(status: .blocked, statusPane: .scratch).clearedBy(pane: .left, keystroke: .other, reset: .firstKey)) + #expect(!AgentIndicator(status: .active, statusPane: .scratch).clearedBy(pane: .right, keystroke: .interrupt, reset: .firstKey)) } @Test func clearedByNilStatusPaneTreatedAsLeft() { - #expect(AgentIndicator(status: .blocked).clearedBy(pane: .left, isInterrupt: false)) - #expect(!AgentIndicator(status: .blocked).clearedBy(pane: .right, isInterrupt: false)) - #expect(!AgentIndicator(status: .blocked).clearedBy(pane: .scratch, isInterrupt: true)) - #expect(AgentIndicator(status: .active).clearedBy(pane: .left, isInterrupt: true)) - #expect(!AgentIndicator(status: .active).clearedBy(pane: .left, isInterrupt: false)) + #expect(AgentIndicator(status: .blocked).clearedBy(pane: .left, keystroke: .other, reset: .firstKey)) + #expect(!AgentIndicator(status: .blocked).clearedBy(pane: .right, keystroke: .other, reset: .firstKey)) + #expect(!AgentIndicator(status: .blocked).clearedBy(pane: .scratch, keystroke: .interrupt, reset: .firstKey)) + #expect(AgentIndicator(status: .active).clearedBy(pane: .left, keystroke: .interrupt, reset: .firstKey)) + #expect(!AgentIndicator(status: .active).clearedBy(pane: .left, keystroke: .other, reset: .firstKey)) } @Test func indicatorEquatableEqual() { diff --git a/agtermCore/Tests/agtermCoreTests/AppSettingsTests.swift b/agtermCore/Tests/agtermCoreTests/AppSettingsTests.swift index ce8dcace..dd613894 100644 --- a/agtermCore/Tests/agtermCoreTests/AppSettingsTests.swift +++ b/agtermCore/Tests/agtermCoreTests/AppSettingsTests.swift @@ -699,6 +699,18 @@ struct AppSettingsTests { #expect(hidden.resolvedHiddenInterfaceElements == [.customCommands, .dashboard]) } + @Test func statusResetDefaultsToFirstKeyAndResolvesKnownRawValues() throws { + #expect(AppSettings().statusReset == nil) + #expect(AppSettings().effectiveStatusReset == .firstKey) + #expect(AppSettings(statusReset: "enter").effectiveStatusReset == .enter) + #expect(AppSettings(statusReset: "never").effectiveStatusReset == .never) + #expect(AppSettings(statusReset: "teleporter").effectiveStatusReset == .firstKey) + let original = AppSettings(statusReset: "enter") + let decoded = try JSONDecoder().decode(AppSettings.self, from: JSONEncoder().encode(original)) + #expect(decoded == original) + #expect(original.ghosttyConfigLines() == ["mouse-scroll-multiplier = 3", "right-click-action = paste"]) + } + @Test func unknownInterfaceElementDecodesTolerantly() throws { // forward-compat rule: an unknown name is dropped from the resolved set and must not fail the // whole decode. diff --git a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift index 584fef3d..98031b55 100644 --- a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift +++ b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift @@ -1792,7 +1792,7 @@ struct AppStorePaneTests { store.closePrimaryPane(session.id) // primary exits → survivor promoted, hasSplit/splitSurface cleared store.setAgentIndicator(AgentIndicator(status: .blocked, statusPane: .right), forSession: session.id) #expect(session.agentIndicator.statusPane == .left) // coerced — no live split - #expect(session.agentIndicator.clearedBy(pane: .left, isInterrupt: false)) // the sole (left) pane clears it + #expect(session.agentIndicator.clearedBy(pane: .left, keystroke: .other, reset: .firstKey)) // the sole (left) pane clears it // and the tree agrees: split:false with statusPane "left", never the contradictory "right". let node = store.controlTree().workspaces[0].sessions.first #expect(node?.split == false) @@ -1823,7 +1823,7 @@ struct AppStorePaneTests { #expect(session.agentIndicator.statusPane == .right) // kept — the split is coming up // once the deck realizes the surface, the block is exactly where the right pane can clear it. session.splitSurface = SpySurface() - #expect(session.agentIndicator.clearedBy(pane: .right, isInterrupt: false)) + #expect(session.agentIndicator.clearedBy(pane: .right, keystroke: .other, reset: .firstKey)) let node = store.controlTree().workspaces[0].sessions.first #expect(node?.split == true) #expect(node?.statusPane == "right") diff --git a/agtermCore/Tests/agtermCoreTests/InterruptKeystrokeTests.swift b/agtermCore/Tests/agtermCoreTests/InterruptKeystrokeTests.swift index d70926b5..c7b18b5a 100644 --- a/agtermCore/Tests/agtermCoreTests/InterruptKeystrokeTests.swift +++ b/agtermCore/Tests/agtermCoreTests/InterruptKeystrokeTests.swift @@ -29,6 +29,35 @@ struct InterruptKeystrokeTests { #expect(!InterruptKeystroke.isInterrupt(keyCode: Self.cKey, character: "j", modifiers: [.control])) } + @Test func returnWithoutModifiersSubmits() { + #expect(InterruptKeystroke.isSubmit(keyCode: InterruptKeystroke.returnKeyCode, modifiers: [])) + #expect(InterruptKeystroke.isSubmit(keyCode: InterruptKeystroke.keypadEnterKeyCode, modifiers: [])) + // shift-return and option-return insert a newline in claude code and codex + #expect(!InterruptKeystroke.isSubmit(keyCode: InterruptKeystroke.returnKeyCode, modifiers: [.shift])) + #expect(!InterruptKeystroke.isSubmit(keyCode: InterruptKeystroke.returnKeyCode, modifiers: [.option])) + #expect(!InterruptKeystroke.isSubmit(keyCode: InterruptKeystroke.returnKeyCode, modifiers: [.command])) + #expect(!InterruptKeystroke.isSubmit(keyCode: 0, modifiers: [])) + } + + @Test func classifyOrdersInterruptBeforeSubmitBeforeOther() { + #expect(InterruptKeystroke.classify(keyCode: Self.escKey, character: "\u{1b}", modifiers: []) == .interrupt) + #expect(InterruptKeystroke.classify(keyCode: Self.cKey, character: "c", modifiers: [.control]) == .interrupt) + #expect(InterruptKeystroke.classify(keyCode: InterruptKeystroke.returnKeyCode, character: "\r", modifiers: []) == .submit) + #expect(InterruptKeystroke.classify(keyCode: InterruptKeystroke.returnKeyCode, character: "\r", modifiers: [.shift]) == .other) + #expect(InterruptKeystroke.classify(keyCode: 0, character: "a", modifiers: []) == .other) + } + + @Test func classifyTextSubmitsOnlyWithANewline() { + #expect(InterruptKeystroke.classify(text: "a") == .other) + #expect(InterruptKeystroke.classify(text: "yes\n") == .submit) + #expect(InterruptKeystroke.classify(text: "\r") == .submit) + // crlf is a single character in swift, equal to neither lf nor cr + #expect(InterruptKeystroke.classify(text: "yes\r\n") == .submit) + #expect(InterruptKeystroke.classify(text: "\r\n") == .submit) + #expect(InterruptKeystroke.classify(text: "a\u{2028}b") == .other) + #expect(InterruptKeystroke.classify(text: "\u{1b}") == .other) + } + @Test func nonInterruptKeystrokesDoNotClear() { #expect(!InterruptKeystroke.isInterrupt(keyCode: Self.cKey, character: "c", modifiers: [])) #expect(!InterruptKeystroke.isInterrupt(keyCode: 0, character: "a", modifiers: [])) diff --git a/agtermUITests/SettingsUITests.swift b/agtermUITests/SettingsUITests.swift index 02e50a24..ba79c4e1 100644 --- a/agtermUITests/SettingsUITests.swift +++ b/agtermUITests/SettingsUITests.swift @@ -55,6 +55,31 @@ final class SettingsUITests: XCTestCase { "turning notifications off should persist notificationsEnabled=false") } + func testStatusResetPickerPersists() throws { + let picker = settingsControl(tab: "Agent Status", control: "settings-status-clear") + + picker.click() + let onEnter = app.menuItems["On Enter"] + XCTAssertTrue(onEnter.waitForExistence(timeout: 5), "the status-reset picker should offer 'On Enter'") + onEnter.click() + XCTAssertTrue(poll { self.settingsValue("statusReset") == "enter" }, + "selecting 'On Enter' should persist statusReset=enter to settings.json") + + picker.click() + let disabled = app.menuItems["Disabled"] + XCTAssertTrue(disabled.waitForExistence(timeout: 5), "the status-reset picker should offer 'Disabled'") + disabled.click() + XCTAssertTrue(poll { self.settingsValue("statusReset") == "never" }, + "selecting 'Disabled' should persist statusReset=never to settings.json") + + picker.click() + let firstKey = app.menuItems["On first key"] + XCTAssertTrue(firstKey.waitForExistence(timeout: 5), "the status-reset picker should offer 'On first key'") + firstKey.click() + XCTAssertTrue(poll { self.settingsObject()?["statusReset"] == nil }, + "the default 'On first key' should remove statusReset from settings.json") + } + func testDockBouncePickerPersists() throws { let picker = settingsControl(tab: "Notifications", control: "settings-dock-bounce") diff --git a/agtermUITests/StatusResetUITests.swift b/agtermUITests/StatusResetUITests.swift new file mode 100644 index 00000000..7ffe71ac --- /dev/null +++ b/agtermUITests/StatusResetUITests.swift @@ -0,0 +1,90 @@ +import XCTest + +/// End-to-end tests for Settings ▸ Agent Status ▸ Status reset: which keystroke clears a `completed` glyph. +/// The status is set and read over the socket and the typing goes through `session.type`, which fires the +/// same pane-scoped clear a keystroke does, so each mode is pinned without driving the keyboard. +@MainActor +final class StatusResetUITests: ControlAPITestCase { + override var seededSettings: [String: Any]? { + if name.contains("OnEnter") { return ["statusReset": "enter"] } + if name.contains("Disabled") { return ["statusReset": "never"] } + return nil + } + + func testDefaultClearsCompletedOnTheFirstKey() throws { + let sid = try markCompleted() + try type("a", into: sid) + XCTAssertTrue(pollStatus(sid, equals: nil, timeout: 8), "the first key should clear completed to idle by default") + } + + func testOnEnterKeepsCompletedUntilReturn() throws { + let sid = try markCompleted() + try type("true", into: sid) + XCTAssertTrue(statusHolds(sid, equals: "completed", for: 2), "typing without Return should keep completed under On Enter") + try type("\n", into: sid) + XCTAssertTrue(pollStatus(sid, equals: nil, timeout: 8), "Return should clear completed under On Enter") + } + + // real keyboard events reach keyDown and its modifier mapping, which session.type bypasses. + func testOnEnterRealKeysKeepCompletedUntilBareReturn() throws { + let sid = try markCompleted() + focusTerminal() + app.typeText("true") + app.typeKey(.return, modifierFlags: [.shift]) + XCTAssertTrue(statusHolds(sid, equals: "completed", for: 2), "typing and Shift-Return should keep completed under On Enter") + app.typeKey(.return, modifierFlags: []) + XCTAssertTrue(pollStatus(sid, equals: nil, timeout: 8), "a bare Return should clear completed under On Enter") + } + + func testDisabledKeepsCompletedThroughReturn() throws { + let sid = try markCompleted() + try type("true\n", into: sid) + XCTAssertTrue(statusHolds(sid, equals: "completed", for: 2), "neither typing nor Return should clear completed when disabled") + } + + /// Marks the seeded session completed over the socket and waits for the tree to report it. + private func markCompleted() throws -> String { + let sid = try activeSessionID() + let set = try sendCommand(#"{"cmd":"session.status","target":"\#(sid)","args":{"status":"completed"}}"#) + XCTAssertEqual(set["ok"] as? Bool, true, "session.status completed should succeed: \(set)") + XCTAssertTrue(pollStatus(sid, equals: "completed", timeout: 8), "the tree should report completed before typing") + return sid + } + + private func type(_ text: String, into sid: String) throws { + let payload: [String: Any] = ["cmd": "session.type", "target": sid, "args": ["text": text]] + let typed = try sendCommand(String(decoding: JSONSerialization.data(withJSONObject: payload), as: UTF8.self)) + XCTAssertEqual(typed["ok"] as? Bool, true, "session.type should succeed: \(typed)") + } + + /// Click the seeded session row so the terminal surface takes first responder, then let the responder + /// bounce settle, so the keys reach that surface's keyDown. + private func focusTerminal() { + let row = app.staticTexts["session-row"].firstMatch + XCTAssertTrue(row.waitForHittable(timeout: 20), "seeded session should be hittable") + row.click() + let deadline = Date().addingTimeInterval(2) + while Date() < deadline, row.isSelected == false { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + } + + private func currentStatus(_ sid: String) -> String? { + (try? sessionNodeIfPresent(id: sid))??["status"] as? String + } + + private func pollStatus(_ sid: String, equals expected: String?, timeout: TimeInterval) -> Bool { + poll(until: currentStatus(sid) == expected, timeout: timeout) + } + + /// True when the status reads `expected` on every sample across `seconds`; a clear that arrives late + /// still fails it, which is the point of sampling rather than reading once. + private func statusHolds(_ sid: String, equals expected: String, for seconds: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + if currentStatus(sid) != expected { return false } + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + return currentStatus(sid) == expected + } +} diff --git a/plugins/agterm/skills/agterm/SKILL.md b/plugins/agterm/skills/agterm/SKILL.md index bb347a30..2bac093b 100644 --- a/plugins/agterm/skills/agterm/SKILL.md +++ b/plugins/agterm/skills/agterm/SKILL.md @@ -334,7 +334,9 @@ omitted when expanded). open runs in the hidden shell and is invisible until it closes. There is no write twin of `session overlay text`: an overlay runs the caller's own program, so nothing types into one. Typing is the input a waiting agent asked for, so it clears that pane's `blocked`/`completed` glyph exactly as a - keystroke does; another pane's glyph, an `active` one, and an empty payload are left alone. + keystroke does, under Settings ▸ Agent Status ▸ Status reset: on the first key by default, only when the + text carries a newline under On Enter, never when Disabled; another pane's glyph, an `active` one, and an + empty payload are left alone. - `session copy` — print the session's selected text (does NOT touch the system clipboard). - `session paste` — paste the system clipboard into the session (the socket analogue of ⌘V; read it back with `session text`). `--pane left|right|scratch` picks the pane, with the usual role and position aliases; diff --git a/plugins/agterm/skills/agterm/reference.md b/plugins/agterm/skills/agterm/reference.md index 8cdcef17..b81e5648 100644 --- a/plugins/agterm/skills/agterm/reference.md +++ b/plugins/agterm/skills/agterm/reference.md @@ -576,7 +576,8 @@ error keeps those names for compatibility. which pane set the status. It has three effects: (1) keystroke-clear becomes pane-scoped — a status set from a background pane survives typing in a DIFFERENT pane (so a `right`- or `scratch`-tagged block is no longer wiped by foreground typing in the main pane, and only input in the OWNING pane clears it, - whether typed by hand or sent with `session type`), (2) while the session is `blocked`, a status from + whether typed by hand or sent with `session type`, and only as Settings ▸ Agent Status ▸ Status reset + allows: the first key by default, Return or a newline in the text under On Enter, never when Disabled), (2) while the session is `blocked`, a status from another pane that is not itself `blocked` is REFUSED with `blocked status owned by pane ` — it changes nothing and plays no sound, so an agent working in one pane cannot erase the other pane's request for input; a second pane may still report its own `blocked`, `idle` is NOT exempt (Codex's diff --git a/site/docs.html b/site/docs.html index b0923bb5..21d6af1a 100644 --- a/site/docs.html +++ b/site/docs.html @@ -1479,9 +1479,10 @@ style="position: absolute; left: 0; top: 1px; color: #6d82f3; font-family: "JetBrains Mono", monospace" >›Agent Status — the status-glyph colors and shapes, the blocked-session - sound, and an idle timeout to auto-follow blocked sessions. A - Reset to defaults button clears the colors, shapes, and sound back to the - shipped values. + sound, a Status reset choice for when typing into a blocked or completed + session clears its glyph (on the first key, on Enter, or disabled), and an idle timeout to auto-follow blocked + sessions. A Reset to defaults button clears the colors, shapes, sound, and + status reset back to the shipped values.
completed - session clears its status; an interrupt keystroke, Esc or Ctrl-C, interrupts an - active one and clears it too. + session clears its status, on the first key by default. + Settings ▸ Agent Status ▸ Status reset can move that to Enter, so a reply + you started and walked away from keeps the glyph until you send it, or disable it, leaving the status to the + agent's next hook or Clear Status. An interrupt keystroke, Esc or Ctrl-C, interrupts an + active one and clears it + whatever the setting.

When the sidebar is hidden the glyphs go with it, so an optional