Skip to content

fix: detect in-place terminal activity reliably - #185

Merged
thdxg merged 8 commits into
thdxg:mainfrom
onliner10:fix/reliable-activity-detection
Jul 28, 2026
Merged

fix: detect in-place terminal activity reliably#185
thdxg merged 8 commits into
thdxg:mainfrom
onliner10:fix/reliable-activity-detection

Conversation

@onliner10

@onliner10 onliner10 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

What

Makes the tab activity indicator follow real terminal work reliably, including in-place redraws from same-PID Pi ! commands and output from fully occluded tabs. Active tabs now render their actual execution state without special masking.

Also exposes the underlying idle / running / done state additively in macterm pane list.

Why

The existing renderer/scrollback signals stop when a surface is occluded and miss raw-mode TUIs that redraw in place. Process polling cannot identify nested Pi shell commands because Pi keeps the same foreground PID throughout the command.

How

  • Consume Ghostty's new throttled GHOSTTY_ACTION_OUTPUT_ACTIVITY signal from the PTY IO path.
  • Preserve explicit progress and canonical foreground processes as authoritative execution sources.
  • For ambiguous same-PID agents, require two timely heartbeats after an actually forwarded nonempty submission; blank Return, startup output, typing, IME composition, and ordinary raw TUIs do not start activity.
  • Track committed input across physical Return/keypad Enter, IME commits, paste resolution, and control-CLI sendText / sendKey paths, with destructive edits and cancellation clearing stale evidence.
  • Quiet-settle activity-owned runs through a dedicated deadline that still polls when ordinary polling is coalesced or the window is fully occluded.
  • Reject or refresh cached GhosttyKit artifacts that lack the required ABI.
  • Keep the new control-protocol state optional so older responses remain decodable.

Verified

  • mise run format, mise run lint, and mise run test all pass
  • Built and ran the change in the app (mise run run) and confirmed the behavior
  • Added or updated tests for new model / persistence / palette / hotkey logic

Validated against the official thdxg/ghostty build-2026-07-24 universal framework, built from c459b2a after thdxg/ghostty#5:

  • Correctly submitted Pi ! sleep 6 transitioned running → done while the window was minimized, with the foreground Pi PID unchanged.
  • Physical Return and keypad Enter, plus programmatic sendText / sendKey, start and finish activity correctly.
  • Physical and programmatic blank Return stay idle; Return in Vim stays idle.
  • Canonical foreground commands remain running through silent periods.
  • Output after completion does not resurrect a finished state.
  • Release archive and DMG build successfully.

Notes for reviewers

The Ghostty dependency is already merged and released: thdxg/ghostty#5. The output heartbeat is emitted from the PTY IO path rather than the renderer, so it remains available while the terminal surface is occluded.

The local Xcode installation prints its existing stale-CoreSimulator warning, but macOS tests and the release archive complete successfully.

Summary by CodeRabbit

  • New Features
    • Pane listings and tab status indicators now expose execution state: idle, running, done (including when tracking is enabled).
  • Bug Fixes
    • Improved terminal “quiet settle” behavior to be consistent regardless of pane occlusion.
    • More robust Return/command submission detection and clipboard paste handling (including safer clipboard parsing).
  • Documentation
    • Updated setup guidance for GhosttyKit compatibility, refined tab naming/activity notes, and expanded pane list output documentation to include execution state.

Use Ghostty's IO-path output heartbeat to track raw-mode and occluded work
without relying on renderer activity. Preserve foreground/progress authority,
require committed input evidence for ambiguous same-PID agent commands, and
quiet-settle activity with a non-coalesced deadline.

Expose the additive execution state through the CLI, show active-tab status
without special masking, and reject GhosttyKit artifacts missing the required
ABI.
@github-actions github-actions Bot added area:ui Views, Settings UI area:terminal Terminal surface, ghostty integration area:state AppState, models, persistence area:tests Test changes area:ci CI workflows, dev tooling area:docs Documentation labels Jul 24, 2026
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ea295e43-759b-483e-94cc-a5611cf75877

📥 Commits

Reviewing files that changed from the base of the PR and between 5c6ba6a and e91cbed.

📒 Files selected for processing (7)
  • AGENTS.md
  • Macterm/App/AppState.swift
  • Macterm/Model/SplitNode.swift
  • Macterm/Views/Terminal/GhosttyTerminalNSView.swift
  • Macterm/Views/TerminalPane.swift
  • MactermTests/App/AppStateTests.swift
  • MactermTests/Model/PaneTests.swift
💤 Files with no reviewable changes (1)
  • MactermTests/Model/PaneTests.swift
🚧 Files skipped from review as they are similar to previous changes (3)
  • Macterm/Views/TerminalPane.swift
  • Macterm/Views/Terminal/GhosttyTerminalNSView.swift
  • Macterm/Model/SplitNode.swift

📝 Walkthrough

Walkthrough

Changes

The PR adds Ghostty output-activity heartbeats and command-submission evidence, expands terminal execution-state tracking, removes occlusion-specific settling, updates sidebar indicators, and exposes pane state through control responses and CLI output.

Terminal activity and input handling

Layer / File(s) Summary
Heartbeat and command submission plumbing
Macterm/Ghostty/*, Macterm/Views/Terminal/*, Macterm/Views/TerminalPane.swift, scripts/setup.sh
Output-activity callbacks and command-submission evidence now flow into Pane, with setup validation for the required ABI symbol.
Execution state machine and quiet polling
Macterm/Model/SplitNode.swift, MactermTests/Model/*
Execution tracking now handles foreground ownership, progress, blank submissions, in-place output starts, heartbeat timing, and quiet-settle deadlines.
Quiet polling and status indicators
Macterm/App/*, Macterm/Views/Sidebar.swift, MactermTests/App/*
Quiet-settle deadlines force polling without occlusion-specific settling, and sidebar glyphs use execution state directly.
Pane state protocol and output reporting
Macterm/Control/*, CLI/Output.swift, website/docs/pages/80-cli.md, MactermTests/Control/*
Pane responses and CLI rows include optional idle, running, or done state values, while older responses remain decodable.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GhosttyCallbacks
  participant GhosttyTerminalNSView
  participant TerminalPane
  participant Pane
  participant AppState
  GhosttyCallbacks->>GhosttyTerminalNSView: forward output-activity heartbeat
  GhosttyTerminalNSView->>TerminalPane: emit output or command-submission event
  TerminalPane->>Pane: update execution activity
  Pane->>AppState: post quiet-settle deadline
  AppState->>Pane: poll and settle eligible activity
Loading

Possibly related PRs

  • thdxg/macterm#170: Overlaps with the sidebar tab-status rendering and agent-icon/process handling.

Suggested reviewers: thdxg, jcuhnoio

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: more reliable terminal activity detection.
Description check ✅ Passed The description follows the template and covers What, Why, How, Verified, and reviewer notes with concrete details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Window-state benchmark

State Metric main@2d4e9aed4 this branch Δ
focused CPU % 0.70 1.00 +43%
Memory (RSS MB) 109.4 109.2 -0%
CPU ms/s (powermetrics) 7.5 9.8 +31%
Wakeups/s (powermetrics) 113.1 140.0 +24%
workload-focused CPU % 2.30 2.40 +4%
Memory (RSS MB) 147.8 165.3 +12%
CPU ms/s (powermetrics) 22.6 23.5 +4%
Wakeups/s (powermetrics) 250.8 290.3 +16%
workload-unfocused CPU % 2.10 2.30 +10%
Memory (RSS MB) 151.1 168.4 +11%
CPU ms/s (powermetrics) 20.9 22.5 +7%
Wakeups/s (powermetrics) 234.3 268.3 +15%

Reported value is the median of 3×10s windows per state (splitting the window and taking the median keeps one co-scheduled spike from skewing a state); CPU % is the process CPU-time delta over a window. Runs land on different shared runners, so treat small deltas as noise — 🔺/🔻 marks changes ≥25% that also clear the metric's absolute noise floor (CPU % ≥0.5, Memory (RSS MB) ≥25, CPU ms/s ≥5, Wakeups/s ≥50); CPU deltas off a noise-dominated baseline aren't flagged (CPU % baseline ≥1.5, CPU ms/s baseline ≥15). The benchmark:regression / benchmark:improvement label needs corroboration — ≥2 flagged metrics in the same direction, at least one under workload — so a lone noisy cell shows its arrow here without tagging the PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
MactermTests/Control/ControlHandlerTests.swift (1)

204-217: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Drive execution state through AppState in this test.

Lines 211 and 215 mutate Pane.executionState directly, bypassing the production mutation/notification path. Use the AppState transition API or an injected test seam so this test validates the real state flow.

As per coding guidelines, all state mutations must go through AppState.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@MactermTests/Control/ControlHandlerTests.swift` around lines 204 - 217,
Update pane_list_reports_running_and_done_states to change execution state
through AppState’s transition API or an injected test seam instead of assigning
pane.executionState directly. Preserve the existing assertions for both running
and done states while exercising the production mutation and notification path.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@MactermTests/Control/ControlHandlerTests.swift`:
- Around line 204-217: Update pane_list_reports_running_and_done_states to
change execution state through AppState’s transition API or an injected test
seam instead of assigning pane.executionState directly. Preserve the existing
assertions for both running and done states while exercising the production
mutation and notification path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 652b2616-5ec7-4a90-a9f3-f652dadac11d

📥 Commits

Reviewing files that changed from the base of the PR and between f1cd144 and 5059793.

📒 Files selected for processing (20)
  • AGENTS.md
  • CLI/Output.swift
  • Macterm/App/AppState.swift
  • Macterm/App/Notifications.swift
  • Macterm/Control/ControlHandler.swift
  • Macterm/Control/ControlProtocol.swift
  • Macterm/Ghostty/GhosttyCallbacks.swift
  • Macterm/Model/SplitNode.swift
  • Macterm/Views/Sidebar.swift
  • Macterm/Views/Terminal/GhosttyTerminalNSView.swift
  • Macterm/Views/Terminal/TerminalCommandSubmission.swift
  • Macterm/Views/TerminalPane.swift
  • MactermTests/App/AppStateTests.swift
  • MactermTests/Control/ControlHandlerTests.swift
  • MactermTests/Control/ControlProtocolTests.swift
  • MactermTests/Model/PaneTests.swift
  • MactermTests/Model/TerminalExecutionTrackerTests.swift
  • MactermTests/Views/Terminal/TerminalCommandSubmissionTests.swift
  • scripts/setup.sh
  • website/docs/pages/80-cli.md

@github-actions github-actions Bot added the benchmark:improvement CI benchmark: significant resource improvement vs main label Jul 24, 2026
The OUTPUT_ACTIVITY heartbeat is now required (setup.sh rejects a GhosttyKit
without the ABI) and fires from the pty IO path regardless of occlusion, so a
quiet activity run's silence is meaningful on or off screen. That makes the
whole pre-heartbeat occlusion workaround dead weight: drop the parked-renderer
fallback (settleIfVisible, paneIsOccluded, previouslyOccludedPanes,
refreshActivityWindow, hasOcclusionIndependentHeartbeat) so the poll settles
occluded and visible panes identically.

Also route activity solely through the heartbeat: the render-path scrollbar no
longer feeds activity detection (it only drove the overlay scrollbar and was a
redundant, visible-only echo of the heartbeat), which incidentally removes the
double foreground re-resolve on visible panes.

The TerminalExecutionTracker state machine is untouched.
@github-actions github-actions Bot removed the benchmark:improvement CI benchmark: significant resource improvement vs main label Jul 25, 2026

@thdxg thdxg left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@onliner10 Thanks! I've left some comments

Comment thread scripts/setup.sh Outdated
need_xcframework=false
else
echo "Existing GhosttyKit lacks GHOSTTY_ACTION_OUTPUT_ACTIVITY; refreshing it"
rm -rf "$XCFRAMEWORK_DIR"

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rm -rf runs before the replacement is known-good. If the latest release turns out to lack the ABI, the exit 1 down at line 55 fires after the working (if older) framework is already gone — so a tree that could previously build is left with no framework at all, and re-running setup hits the same failure. Worth downloading and validating into a temp location, then swapping, so a bad release is a no-op rather than a regression.

Separately, GHOSTTY_HEADER hardcodes the macos-arm64_x86_64 slice name. If the fork ever ships a differently-named slice, has_output_activity_action silently returns false and you get a re-download every single setup run with no indication why. A glob over the slice dirs would be more robust.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed. Download+validate now happens in a mktemp -d, and the old framework is only removed once the replacement passes, so a bad release is a no-op. Probe takes the root as an arg and globs */Headers/ghostty.h instead of naming the slice.

Comment thread scripts/setup.sh
if ! has_output_activity_action; then
echo "Error: GhosttyKit from $LATEST_TAG lacks GHOSTTY_ACTION_OUTPUT_ACTIVITY" >&2
echo "The thdxg/ghostty output-activity downstream patch must be released first." >&2
exit 1

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth calling out that this makes the app unbuildable against any GhosttyKit release predating the fork patch — including when bisecting across this commit. That's a defensible call given the feature genuinely requires the ABI, but the error message could say so directly ("checkouts before need an older GhosttyKit; see AGENTS.md") to save the next person the archaeology.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. The error now says the ABI has been required since this change, that a pre-change checkout needs a GhosttyKit from a contemporary release, and that the existing one was left alone. Expanded the AGENTS.md paragraph it points at.

Comment thread Macterm/Model/SplitNode.swift Outdated
shell: String? = nil,
env: [String: String]? = nil
env: [String: String]? = nil,
activityQuietPollDelay: Duration = .seconds(3)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

activityQuietPollDelay defaults to 3s and settleTerminalActivityIfQuiet defaults quietInterval to 3s (line 591). So the wake fires at almost exactly the threshold it needs to have crossed: the settle requires now - lastActivityAt >= 3, and the sleep targets 3.0s after the last heartbeat.

Today this works because Task.sleep jitter is positive. But it's load-bearing on that assumption, and the failure mode is bad: if the wake ever lands marginally early the settle no-ops, and with every window occluded there's no timer left to retry — the run stays .running indefinitely, which is precisely what this machinery exists to prevent.

Suggest making the margin explicit, e.g. delay = quietInterval + 0.25s, ideally deriving both from one constant so they can't drift apart.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that was relying on jitter sign. Added TerminalActivityTiming with quietInterval = 3 / quietPollMargin = 0.25 / quietPollDelay = interval + margin; the wake and the settle default both derive from it, and a test pins the margin so it cannot collapse.


func refreshTerminalActivityWindow(now: Date = Date()) {
executionTracker.refreshActivityWindow(now: now)
private func scheduleActivityQuietPollIfNeeded() {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this cancels and recreates a Task on every markOutputActivity, i.e. ~2 Hz per pane with live output. Bounded by the throttle so it's not a real cost, but DispatchWorkItem would be lighter and matches the idiom already used a few files over for commandSubmissionEvidenceReset.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to DispatchWorkItem + asyncAfter. activityQuietPollDelay is a TimeInterval now.

// Same liveness signal a keystroke sends (execution tracking + poll
// resume), so an injected command updates the tab title promptly.
onInteraction?()
recordCommandInput(text)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recordCommandInput(text) records the same string that's checked for a newline two lines down, so hasContent here is derived from the caller's own payload rather than from prior typing. Fine for pane run "sleep 5".

The issue is that Evidence.recordText only ORs in — it never clears — combined with preserveProgrammaticCommandInput's 2s window. So a pane run followed within 2s by a genuinely blank Return will report hasContent == true for that blank Return, which is the exact false positive the blank-submission suppression exists to prevent. Narrow, but reachable from a script that injects a command and then sends a bare newline.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reachable, yeah. The view cannot tell the two cases apart (it does not know the payload already executed), so I scoped the carry rather than tried to detect it: it is gated on Pane.allowsInPlaceOutputStart now, which is the same agent-TUI foreground the in-place heuristic needs and the only place a bracketed paste can eat the newline. pane run into a plain shell no longer opens the window.

/// moves away, so a settled program that reported progress doesn't flip back
/// to running on its own render output. `pendingProgressQuiesce` covers the
/// race where progress started and cleared before any foreground poll.
/// moves away. `pendingProgressQuiesce` covers progress that starts and

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recordUserInteraction now clears pendingOutputStart, which makes the arming call sites order-dependent: keyDown, sendText, and sendKey all fire onInteraction?() before onCommandSubmitted?(), so it's clear-then-arm and works. Reorder either call and the Pi path silently stops arming, with no test to catch it.

I tried to construct a failing case and couldn't, so this isn't a bug report — just asking for a line of comment here stating that callers must arm after interaction, since the constraint is currently invisible at both ends.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented at both ends: an ordering note on recordUserInteraction, one on onCommandSubmitted, and one at the TerminalPane wiring.

hasCommand: Bool
) -> Bool {
if keyCode == 53 || keyCode == 51 || keyCode == 117 { return true } // Escape / delete
if hasControl, [4, 8, 13, 32, 40].contains(keyCode) { return true } // H/C/W/U/K

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The keycodes check out (4=h, 8=c, 13=w, 32=u, 40=k), but bare literals with a // H/C/W/U/K gloss are easy to get wrong on edit. HotkeyRegistry.parseShortcut already carries a keycode vocabulary the CLI leans on — reusing it, or at minimum naming these, would be more maintainable.

Also line 54: // Escape / delete is two labels for three codes (53 escape, 51 delete, 117 forward-delete).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Named them, and split the escape/delete gloss into three cases. Cannot read HotkeyRegistry at runtime here since it is @MainActor and this helper is isolation-free, so I added keyCode(forToken:) and a test that drives the codes from that vocabulary through the public API.

Comment thread MactermTests/Model/PaneTests.swift Outdated
defer { NotificationCenter.default.removeObserver(token) }

p.markOutputActivity(totalRows: 20)
try? await Task.sleep(for: .milliseconds(100))

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed 100ms sleep against a 20ms delay — this is the shape that's flaked on loaded CI runners before (cf. #181). Better to poll until the confirmation fires with a sleep in the loop, or widen the margin substantially, so a co-scheduled spike doesn't fail the run.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Polls in 25ms ticks with a 5s ceiling, so a healthy run exits on the first tick and a spike cannot fail it.

Comment thread Macterm/Ghostty/GhosttyCallbacks.swift Outdated
// non-null on the Zig side; a request completing during surface
// teardown (nil `surface`) is UB, not a graceful no-op. Guard it.
guard let surface = surface(from: ud) else { return false }
guard let ud else { return false }

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Functionally identical to the surface(from:) helper this replaces, so the teardown guard the comment describes is preserved — but it open-codes Unmanaged.fromOpaque at a second call site, and the comment above now refers to a guard spread across three lines. A surfaceView(from ud:) overload would keep the pointer-casting in one place.

Also worth a note in the comment: surfaceDidPasteText is dispatched async before the synchronous complete_clipboard_request, so the evidence lands after the paste reaches the surface but before any subsequent Return. That ordering is correct and load-bearing, and currently unremarked.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a surfaceView(from ud:) overload with surface(from:) delegating to it, so the cast lives in one place. Also noted why the paste record is dispatched before the synchronous completion.

…g one

The ABI refresh deleted the existing xcframework up front and only checked
the downloaded replacement afterwards, so a release that also lacked
GHOSTTY_ACTION_OUTPUT_ACTIVITY left the tree with no framework at all — a
checkout that could previously build, broken, with every re-run repeating
the failure. Download and validate into a scratch dir and swap only on
success, so a bad release is a no-op instead of a regression.

The probe also named the macos-arm64_x86_64 slice. A renamed slice would
have reported "missing ABI" forever, re-downloading on every setup run with
no indication why; glob the slice dirs instead.

Requiring the ABI is a bisect hazard, so say so where someone hits it: the
failure message now explains that pre-change checkouts need a GhosttyKit
from a contemporary release, and points at AGENTS.md.
… one place

Bare keycode literals behind a `// H/C/W/U/K` gloss are easy to transpose on
edit, and nothing downstream notices when they change which keys count as a
submission or wipe the evidence — the escape/delete line was already two
labels for three codes. Name them, and pin them to HotkeyRegistry's shared
vocabulary in a test: TerminalCommandSubmission stays isolation-free so it
can't read that @mainactor map at runtime, but it can be held to it.

readClipboard open-coded Unmanaged.fromOpaque at a second call site; give
surfaceView an overload taking the userdata pointer so the teardown guard
and the cast stay together. Also record why the paste evidence is dispatched
before the synchronous clipboard completion — that ordering is load-bearing
and was unremarked.
The scheduled wake fired at 3s and the settle it triggers requires
now - lastActivityAt >= 3s, so it only worked while timer jitter ran
positive. An early wake would no-op the settle, and with every window
occluded there is no timer left to retry — the run would sit at .running
forever, which is exactly what this machinery exists to prevent. Derive both
from one constant with an explicit margin so they can't drift apart or meet.

preserveProgrammaticCommandInput carried a payload's content evidence for
2s, and the evidence only ORs in — so a `pane run "cmd"` followed by a
genuinely blank Return inside that window reported content it didn't have.
The carry is only needed where a bracketed paste can swallow the newline,
which is the same agent-TUI foreground the in-place heuristic requires, so
gate it on that and keep the ambiguous window out of the shell case.

Also: reschedule the wake with a work item rather than cancelling and
respawning a Task at ~2 Hz per live pane, matching the idiom the view
already uses; state the interaction-before-submission ordering contract at
both ends, since recordUserInteraction clears the arming that
recordCommandSubmission sets; and wait for the wake by polling instead of a
fixed 100ms sleep against a 20ms timer (the shape that flaked in thdxg#181).
@github-actions github-actions Bot added the area:hotkeys Key routing, hotkeys label Jul 27, 2026
thdxg

This comment was marked as outdated.

@thdxg
thdxg merged commit 6c556fa into thdxg:main Jul 28, 2026
10 checks passed
@thdxg

thdxg commented Jul 28, 2026

Copy link
Copy Markdown
Owner

@onliner10 Thanks for your work!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:ci CI workflows, dev tooling area:docs Documentation area:hotkeys Key routing, hotkeys area:state AppState, models, persistence area:terminal Terminal surface, ghostty integration area:tests Test changes area:ui Views, Settings UI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants