Skip to content

Commit d04b473

Browse files
committed
fix: terminal lifecycle and history viewer bugs
- History viewer read a session-id log that never exists; read per-pane logs (splitRoot.allPaneIds) and concatenate them - Reset reveal counter on warm-terminal claim so it no longer flashes the previous $HOME prompt before its own cd && clear - Verify warm terminal process is alive before handoff; dead PTY falls back to a cold spawn - Implement processTerminated: clear exited terminals and reset status so the tab/notch no longer freeze on a dead shell - Strip C1 control chars (U+0080-U+009F) from notification text - Surface checkpoint-restore failures instead of swallowing with try? - Measure real working duration for the taskCompleted filter - Sparkle appcast advertises minimum macOS 26.0 (was 14.0) - Remove dead code; de-duplicate buffer-read and launch-command paths
1 parent be41a8d commit d04b473

9 files changed

Lines changed: 131 additions & 139 deletions

CHANGELOG.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.23.2] - 2026-05-29
11+
12+
### Security
13+
- Notification text now strips C1 control characters (U+0080–U+009F, including NEL and CSI) in addition to C0/DEL, closing a notification-spoofing vector from hostile terminal output.
14+
15+
### Fixed
16+
- Session history viewer always showed "No history". History is written per pane, but the viewer read a log keyed by the session id (a file that never exists). It now reads and concatenates every pane's log in the session's split tree.
17+
- Claimed warm-pool terminals briefly flashed the previous shell's `$HOME` prompt because the reveal counter wasn't reset. A claimed terminal now stays hidden until its own `cd && clear` completes.
18+
- A warm terminal whose shell had already exited could be handed to a new tab. The pool now verifies the process is alive before handoff and falls back to a fresh spawn.
19+
- A terminal whose shell exited (`exit`, crash, failing login script) left the tab/notch showing a frozen status and leaked the dead terminal. `processTerminated` now clears it and resets status (a later reselect respawns a fresh shell).
20+
- Restoring a checkpoint no longer fails silently — the error is logged instead of being swallowed by `try?`.
21+
- "Task completed" notifications were suppressed for many real tasks: the trivial-task filter measured wall time including the 3-second confirmation delay. It now measures actual working duration.
22+
23+
### Changed
24+
- Sparkle appcast now advertises the correct minimum macOS (26.0). It previously defaulted to 14.0, offering the update to systems that cannot run the app.
25+
- Internal cleanup: removed dead code and de-duplicated the terminal buffer-reading and launch-command paths (no behavior change).
26+
1027
## [0.23.1] - 2026-05-28
1128

1229
### Changed
@@ -229,7 +246,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
229246
- Global backtick hotkey to toggle panel
230247
- Pin panel open option
231248

232-
[Unreleased]: https://github.com/javierpr0/notchly/compare/v0.23.1...HEAD
249+
[Unreleased]: https://github.com/javierpr0/notchly/compare/v0.23.2...HEAD
250+
[0.23.2]: https://github.com/javierpr0/notchly/compare/v0.23.1...v0.23.2
233251
[0.23.1]: https://github.com/javierpr0/notchly/compare/v0.23.0...v0.23.1
234252
[0.23.0]: https://github.com/javierpr0/notchly/compare/v0.22.5...v0.23.0
235253
[0.20.0]: https://github.com/javierpr0/notchly/compare/v0.19.0...v0.20.0

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Notchly is a macOS menu bar app that provides a floating terminal panel anchored
1818

1919
## Architecture
2020

21-
**App lifecycle**: `NotchyApp` (in `BotdockApp.swift`) uses `@NSApplicationDelegateAdaptor` to delegate to `AppDelegate`, which owns the `NSStatusItem` (menu bar icon), the `TerminalPanel`, and the `NotchWindow`. The SwiftUI `App` body is an empty `Settings` scene — all UI lives in the panel and notch window.
21+
**App lifecycle**: `NotchyApp` (in `NotchyApp.swift`) uses `@NSApplicationDelegateAdaptor` to delegate to `AppDelegate`, which owns the `NSStatusItem` (menu bar icon), the `TerminalPanel`, and the `NotchWindow`. The SwiftUI `App` body is an empty `Settings` scene — all UI lives in the panel and notch window.
2222

2323
**Notch integration**: `NotchWindow` is an always-visible `NSPanel` positioned over the MacBook notch. It detects notch dimensions via `NSScreen.auxiliaryTopLeftArea`/`auxiliaryTopRightArea`, tracks mouse hover to trigger the main panel, and expands with a bounce animation (via `CVDisplayLinkWrapper`) when any session is working. `NotchPillContent` (SwiftUI) renders status icons (spinner, checkmark, warning) inside the pill. `NotchDisplayState` computes a priority-based aggregate status across all sessions.
2424

@@ -46,7 +46,7 @@ Notchly is a macOS menu bar app that provides a floating terminal panel anchored
4646

4747
| File | Purpose |
4848
|------|---------|
49-
| `BotdockApp.swift` | App entry point (`NotchyApp`) |
49+
| `NotchyApp.swift` | App entry point (`NotchyApp`) |
5050
| `AppDelegate.swift` | Menu bar icon, hotkey, hover tracking, panel lifecycle |
5151
| `TerminalManager.swift` | Terminal creation, `ClickThroughTerminalView`, status detection, autocomplete integration |
5252
| `TerminalPanel.swift` | Floating panel window, keyboard shortcuts |
@@ -70,4 +70,4 @@ Notchly is a macOS menu bar app that provides a floating terminal panel anchored
7070

7171
## Entitlements
7272

73-
The app requires `com.apple.security.automation.apple-events` for AppleScript communication with Xcode.
73+
`Notchy.entitlements` is currently empty (`<dict/>`) — the app is unsandboxed (required: it spawns the user's login shell and runs arbitrary commands) and declares no special entitlements. There is no AppleScript/Xcode-automation code. If Developer-ID signing + notarization is added later, any needed entitlement must be declared explicitly here, since CI currently signs ad-hoc (`CODE_SIGN_IDENTITY="-"`) and the hardened runtime isn't truly enforced.

Notchy/HistoryViewerPanel.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ class HistoryViewerPanel: NSPanel {
44
private let scrollView: NSScrollView
55
private let textView: NSTextView
66

7-
init(sessionName: String, sessionId: UUID) {
7+
init(sessionName: String, paneIds: [UUID]) {
88
scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 700, height: 500))
99
scrollView.hasVerticalScroller = true
1010
scrollView.hasHorizontalScroller = false
@@ -42,7 +42,7 @@ class HistoryViewerPanel: NSPanel {
4242

4343
// The read + ANSI strip runs off-main; fill the view when it lands so
4444
// opening history on a large log never blocks the UI.
45-
SessionHistoryManager.shared.readHistoryAsync(for: sessionId) { [weak self] content in
45+
SessionHistoryManager.shared.readHistoryAsync(for: paneIds) { [weak self] content in
4646
guard let self else { return }
4747
if content.isEmpty {
4848
self.textView.string = L10n.shared.noHistory

Notchy/NotchWindow.swift

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -655,19 +655,6 @@ struct NotchPillContent: View {
655655
}
656656
}
657657

658-
struct SpinnerView: View {
659-
@State private var isAnimating = false
660-
661-
var body: some View {
662-
Circle()
663-
.trim(from: 0.05, to: 0.8)
664-
.stroke(Color.white, style: StrokeStyle(lineWidth: 2, lineCap: .round))
665-
.rotationEffect(.degrees(isAnimating ? 360 : 0))
666-
.animation(.linear(duration: 0.8).repeatForever(autoreverses: false), value: isAnimating)
667-
.onAppear { isAnimating = true }
668-
}
669-
}
670-
671658
// MARK: - CVDisplayLink wrapper for smooth animation
672659

673660
/// Drives a frame-by-frame animation callback on the display refresh rate.

Notchy/SessionHistoryManager.swift

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,24 @@ class SessionHistoryManager {
106106
/// only the final delivery hops back to main. Avoids a multi-hundred-ms
107107
/// main-thread stall when opening the history viewer on a large log.
108108
func readHistoryAsync(for sessionId: UUID, completion: @escaping (String) -> Void) {
109-
let path = logPath(for: sessionId)
109+
readHistoryAsync(for: [sessionId], completion: completion)
110+
}
111+
112+
/// Reads and concatenates the logs of several panes (a session's split
113+
/// tree) in tree order, each separated by a divider. All file work runs on
114+
/// `queue`; only delivery hops to main.
115+
func readHistoryAsync(for paneIds: [UUID], completion: @escaping (String) -> Void) {
110116
queue.async { [self] in
111-
// Flush any buffered writes so reads see the latest data.
112-
flushPending(for: sessionId)
113-
openHandles[sessionId]?.synchronizeFile()
114-
let raw = (try? String(contentsOf: path, encoding: .utf8)) ?? ""
115-
let stripped = Self.stripAnsi(raw)
116-
DispatchQueue.main.async { completion(stripped) }
117+
var parts: [String] = []
118+
for paneId in paneIds {
119+
flushPending(for: paneId)
120+
openHandles[paneId]?.synchronizeFile()
121+
let raw = (try? String(contentsOf: logPath(for: paneId), encoding: .utf8)) ?? ""
122+
let stripped = Self.stripAnsi(raw)
123+
if !stripped.isEmpty { parts.append(stripped) }
124+
}
125+
let joined = parts.joined(separator: "\n\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\n")
126+
DispatchQueue.main.async { completion(joined) }
117127
}
118128
}
119129

Notchy/SessionStore.swift

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ class SessionStore {
7676
}
7777
}
7878

79+
/// Tasks whose working phase was shorter than this don't emit a
80+
/// "task completed" pill/notification — avoids noise from trivial commands.
81+
private static let minTaskWorkDuration: TimeInterval = 7
7982
private static let sessionsKey = "persistedSessions"
8083
private static let activeSessionKey = "activeSessionId"
8184
private static let persistDebounceInterval: TimeInterval = 1.5
@@ -324,16 +327,17 @@ class SessionStore {
324327
NotificationCenter.default.post(name: .NotchyNotchStatusChanged, object: nil)
325328
}
326329

327-
// Per-pane working→idle delay for taskCompleted
330+
// Per-pane working→idle delay for taskCompleted. Skip trivial tasks,
331+
// but measure the ACTUAL working duration (idle moment − start) now —
332+
// the old check read `now − start` inside the delayed task, so the 3 s
333+
// confirmation sleep inflated it and suppressed real short tasks.
328334
if status == .idle && oldPaneStatus == .working {
329-
let workingStartedAt = sessions[index].paneWorkingStartedAt[paneId]
335+
let workDuration = sessions[index].paneWorkingStartedAt[paneId].map { Date().timeIntervalSince($0) }
330336
Task { @MainActor in
331337
try? await Task.sleep(for: .seconds(3))
332338
guard let idx = self.sessions.firstIndex(where: { $0.id == sessionId }),
333339
self.sessions[idx].paneStatuses[paneId] == .idle else { return }
334-
if let started = workingStartedAt, Date().timeIntervalSince(started) < 10 {
335-
return
336-
}
340+
if let d = workDuration, d < Self.minTaskWorkDuration { return }
337341
self.updateTerminalStatus(paneId, status: .taskCompleted)
338342
}
339343
}

Notchy/SessionTabBar.swift

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,10 @@ struct SessionTab: View {
200200
}
201201

202202
private func showHistory() {
203-
let panel = HistoryViewerPanel(sessionName: session.projectName, sessionId: session.id)
203+
// History is logged per pane (terminal.sessionId is a pane id), so read
204+
// every pane in this session's split tree, not the session id — that
205+
// file never exists and the viewer always showed "No history".
206+
let panel = HistoryViewerPanel(sessionName: session.projectName, paneIds: session.splitRoot.allPaneIds)
204207
panel.makeKeyAndOrderFront(nil)
205208
}
206209

@@ -392,7 +395,11 @@ struct SessionTab: View {
392395
if let checkpoint = latestCheckpoint {
393396
guard let dir = session.projectPath else { return }
394397
let projectDir = (dir as NSString).deletingLastPathComponent
395-
try? CheckpointManager.shared.restoreCheckpoint(checkpoint, to: projectDir)
398+
do {
399+
try CheckpointManager.shared.restoreCheckpoint(checkpoint, to: projectDir)
400+
} catch {
401+
NSLog("Notchly: checkpoint restore failed: \(error.localizedDescription)")
402+
}
396403
}
397404
}
398405
Button(L10n.shared.cancel, role: .cancel) {}
@@ -413,16 +420,4 @@ struct SessionTab: View {
413420
}
414421
}
415422

416-
struct TabSpinnerView: View {
417-
@State private var isAnimating = false
418-
419-
var body: some View {
420-
Circle()
421-
.trim(from: 0.05, to: 0.8)
422-
.stroke(Color(nsColor: SessionStore.shared.currentTheme.chromeForeground), style: StrokeStyle(lineWidth: 1.5, lineCap: .round))
423-
.rotationEffect(.degrees(isAnimating ? 360 : 0))
424-
.animation(.linear(duration: 0.8).repeatForever(autoreverses: false), value: isAnimating)
425-
.onAppear { isAnimating = true }
426-
}
427-
}
428423

0 commit comments

Comments
 (0)