Skip to content

Commit 630dcd1

Browse files
authored
feat(terminal): add Recover Terminal Display command (#1472) (#1473)
A terminal pane can go black while its shell keeps running, and no input brings it back. SwiftTerm's Metal renderer refuses a frame when the frame semaphore is held or no drawable exists; both paths set `pendingRedraw` without submitting a command buffer, yet only that command buffer's completion handler consumes the flag. The view then accepts input and PTY output forever without presenting anything. Pine cannot detect this from the outside — with the semaphore held, both `setNeedsDisplay` and `drawMetalFrameNow()` re-enter the same refusal, and there is no public "frame presented" signal. Rebuilding the renderer is the only escape: it installs a fresh MTKView, semaphore, and drawable chain while Terminal, the PTY, and the scrollback stay untouched. Terminal ▸ Recover Terminal Display (⌘⌥R) rebuilds the renderer for the active tab of every visible terminal pane, plus the quick terminal when it is on screen. Background tabs are skipped — they are detached, so a rebuild would cost a GPU round-trip for pixels nobody sees, and their own re-attach already repaints them. The item is never disabled: the quick terminal lives in its own window where `focusedProject` is nil, and gating on project panes would leave exactly that surface unfixable. Existing coverage missed this class entirely — `TerminalMetalRendererTests` exercises only attachment-shaped events, and the UI-test harness launches with `--disable-metal`. The durable fix belongs upstream in SwiftTerm; it will be filed separately, after which this command stays useful but no longer load-bearing.
1 parent 44228ae commit 630dcd1

11 files changed

Lines changed: 535 additions & 0 deletions

Pine/Localizable.xcstrings

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9750,6 +9750,66 @@
97509750
}
97519751
}
97529752
},
9753+
"menu.recoverTerminalDisplay": {
9754+
"comment": "Menu item: rebuild the renderer of visible terminals that stopped presenting frames (#1472).",
9755+
"extractionState": "manual",
9756+
"localizations": {
9757+
"de": {
9758+
"stringUnit": {
9759+
"state": "translated",
9760+
"value": "Terminal-Anzeige wiederherstellen"
9761+
}
9762+
},
9763+
"en": {
9764+
"stringUnit": {
9765+
"state": "translated",
9766+
"value": "Recover Terminal Display"
9767+
}
9768+
},
9769+
"es": {
9770+
"stringUnit": {
9771+
"state": "translated",
9772+
"value": "Recuperar la visualización del terminal"
9773+
}
9774+
},
9775+
"fr": {
9776+
"stringUnit": {
9777+
"state": "translated",
9778+
"value": "Restaurer l’affichage du terminal"
9779+
}
9780+
},
9781+
"ja": {
9782+
"stringUnit": {
9783+
"state": "translated",
9784+
"value": "ターミナル表示を復元"
9785+
}
9786+
},
9787+
"ko": {
9788+
"stringUnit": {
9789+
"state": "translated",
9790+
"value": "터미널 화면 복구"
9791+
}
9792+
},
9793+
"pt-BR": {
9794+
"stringUnit": {
9795+
"state": "translated",
9796+
"value": "Recuperar exibição do terminal"
9797+
}
9798+
},
9799+
"ru": {
9800+
"stringUnit": {
9801+
"state": "translated",
9802+
"value": "Восстановить отображение терминала"
9803+
}
9804+
},
9805+
"zh-Hans": {
9806+
"stringUnit": {
9807+
"state": "translated",
9808+
"value": "恢复终端显示"
9809+
}
9810+
}
9811+
}
9812+
},
97539813
"menu.toggleTerminalZoom": {
97549814
"comment": "Menu item: toggle zoom-to-fullscreen of the focused terminal pane (#1115).",
97559815
"extractionState": "manual",

Pine/MenuIcons.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ nonisolated enum MenuIcons {
5757
static let newTerminalTab = "plus"
5858
static let sendToTerminal = "paperplane"
5959
static let maximizeTerminal = "arrow.up.left.and.arrow.down.right"
60+
static let recoverTerminalDisplay = "arrow.triangle.2.circlepath"
6061

6162
// MARK: - Tasks menu (issue #1009)
6263
static let tasks = "wrench.and.screwdriver"

Pine/PineApp.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ struct PineApp: App {
3535
toggleQuickTerminal: { [weak appDelegate] in
3636
appDelegate?.quickTerminalCoordinator.toggle()
3737
},
38+
recoverQuickTerminalDisplay: { [weak appDelegate] in
39+
appDelegate?.quickTerminalCoordinator.recoverDisplay()
40+
},
3841
recentProjects: { [weak appDelegate] in
3942
appDelegate?.registry.recentProjects ?? []
4043
},

Pine/PineAppMenuCommands.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ struct PineAppMenuCommands: Commands {
3131
/// accidentally starting another Sparkle runtime in hosted tests.
3232
let checkForUpdatesViewModel: CheckForUpdatesViewModel
3333
let toggleQuickTerminal: () -> Void
34+
/// Recovers the quick terminal alongside the focused project's panes: the
35+
/// panel is a separate window, so a stuck session there is unreachable
36+
/// through `focusedProject`. Inert when the panel is hidden.
37+
let recoverQuickTerminalDisplay: () -> Void
3438
/// Reads the app-scoped registry without coupling Commands back to the
3539
/// AppDelegate. The closure keeps hosted tests inert while preserving
3640
/// observation of `ProjectRegistry.recentProjects` in the menu body.
@@ -808,6 +812,27 @@ struct PineAppMenuCommands: Commands {
808812
}
809813
.keyboardShortcut(.return, modifiers: [.command, .option])
810814
.disabled(focusedProject?.hasTerminalPanes != true)
815+
816+
Divider()
817+
818+
// Escape hatch for a terminal that renders nothing while its shell
819+
// keeps running: SwiftTerm's Metal renderer can drop into a state
820+
// where every frame request is refused and no repaint recovers it,
821+
// so this rebuilds the renderer itself (issue #1472).
822+
//
823+
// Deliberately never disabled. The quick terminal lives in its own
824+
// window, where `focusedProject` is nil — gating on project panes
825+
// would leave exactly the surface a user is staring at unfixable.
826+
Button {
827+
focusedProject?.terminal.recoverVisibleTerminalDisplays()
828+
recoverQuickTerminalDisplay()
829+
} label: {
830+
Label(
831+
Strings.menuRecoverTerminalDisplay,
832+
systemImage: MenuIcons.recoverTerminalDisplay
833+
)
834+
}
835+
.keyboardShortcut("r", modifiers: [.command, .option])
811836
}
812837

813838
// MARK: - Tasks menu (issue #1009)

Pine/QuickTerminal/QuickTerminalController.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,18 @@ final class QuickTerminalController {
170170

171171
// MARK: - Public
172172

173+
/// Rebuilds the quick terminal's presentation layer, recovering a session
174+
/// stuck on a renderer that refuses every frame (see
175+
/// `TerminalTab.recoverDisplay()`).
176+
///
177+
/// Only while on screen: a hidden panel is detached, so a rebuild would be
178+
/// discarded and the next `show()` repaints through the ordinary
179+
/// attachment path anyway.
180+
func recoverDisplay() {
181+
guard isVisible else { return }
182+
paneState.activeTab?.recoverDisplay()
183+
}
184+
173185
/// Shows the quick terminal if hidden, hides it if visible. Bound to the
174186
/// global hotkey and the menu command.
175187
func toggle() {

Pine/Strings.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2074,6 +2074,8 @@ enum Strings {
20742074
static let menuFindInTerminal: LocalizedStringKey = "menu.findInTerminal"
20752075
static let menuSendToTerminal: LocalizedStringKey = "menu.sendToTerminal"
20762076
static let menuToggleTerminalZoom: LocalizedStringKey = "menu.toggleTerminalZoom"
2077+
static let menuRecoverTerminalDisplay: LocalizedStringKey =
2078+
"menu.recoverTerminalDisplay"
20772079

20782080
static var terminalSearchPreviousTooltip: String {
20792081
String(localized: "terminal.search.previousTooltip")

Pine/TerminalManager.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1096,6 +1096,27 @@ final class TerminalManager {
10961096
}
10971097
}
10981098

1099+
// MARK: - Display recovery
1100+
1101+
/// Rebuilds the presentation layer of every terminal the user can actually
1102+
/// see in this project, recovering panes stuck on a renderer that refuses
1103+
/// every frame (see `TerminalTab.recoverDisplay()`).
1104+
///
1105+
/// Scoped to the active tab of each terminal pane rather than to
1106+
/// `allTerminalTabs`: a background tab is detached, so recovery there is a
1107+
/// no-op that would still pay a renderer rebuild, and its own re-attach
1108+
/// already repaints it. Every visible pane is covered rather than only the
1109+
/// focused one — the stuck pane is frequently not the one holding focus,
1110+
/// and a user reaching for this command should not have to guess which.
1111+
///
1112+
/// A permanently invalidated manager owns no live PTYs worth repainting.
1113+
func recoverVisibleTerminalDisplays() {
1114+
guard !isPermanentlyInvalidated, let pm = paneManager else { return }
1115+
for state in pm.terminalStates.values {
1116+
state.activeTab?.recoverDisplay()
1117+
}
1118+
}
1119+
10991120
// MARK: - Queries (delegate to PaneManager)
11001121

11011122
var allTerminalTabs: [TerminalTab] {

Pine/TerminalSession.swift

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,49 @@ final class PineTerminalView: LocalProcessTerminalView {
383383
}
384384
}
385385

386+
/// Rebuilds the presentation layer on explicit user request, recovering a
387+
/// terminal whose renderer stopped presenting frames.
388+
///
389+
/// SwiftTerm's Metal renderer re-requests a dropped frame only from the
390+
/// completion handler of a *submitted* command buffer. Both refusal paths
391+
/// in `MetalTerminalRenderer.draw(in:)` — a busy frame semaphore and a
392+
/// missing drawable/render-pass descriptor — set the pending-redraw flag
393+
/// without submitting anything, so nothing ever consumes it. The view then
394+
/// keeps accepting input and PTY output while never presenting a frame,
395+
/// and no invalidation from Pine recovers it: `setNeedsDisplay` and
396+
/// `drawMetalFrameNow()` both re-enter the same refusal.
397+
///
398+
/// Recreating the renderer is the only escape — it installs a fresh
399+
/// `MTKView`, semaphore, and drawable chain while `Terminal`, the PTY, and
400+
/// the scrollback stay untouched. CoreGraphics has no such trap, so a
401+
/// repaint through the backend-aware bridge is both necessary and
402+
/// sufficient there.
403+
///
404+
/// No-op while detached: `viewDidMoveToWindow` rebuilds and repaints on
405+
/// the next real attachment anyway.
406+
func recoverRendererNow() {
407+
guard window != nil else { return }
408+
409+
if isUsingMetalRenderer {
410+
do {
411+
try setUseMetal(false)
412+
try setUseMetal(true)
413+
} catch {
414+
Logger.terminal.error(
415+
"SwiftTerm Metal renderer recreation during display recovery failed: \(String(describing: error), privacy: .public)"
416+
)
417+
}
418+
if isUsingMetalRenderer {
419+
// A freshly created CAMetalLayer can miss its first drawable
420+
// exactly like one created on attach; reuse the same bounded
421+
// retry batch instead of betting recovery on a single frame.
422+
scheduleInitialMetalRedrawRetries()
423+
}
424+
}
425+
426+
requestRendererDisplay()
427+
}
428+
386429
/// Re-arms first-frame recovery when visible terminal content first
387430
/// changes, rather than relying solely on the earlier view-attachment
388431
/// window. This matters for shells whose startup takes longer than the
@@ -2372,6 +2415,27 @@ final class TerminalTab: Identifiable, Hashable {
23722415
}
23732416
}
23742417

2418+
/// User-invoked escape hatch for a terminal that renders nothing while its
2419+
/// shell is demonstrably alive (Terminal ▸ Recover Display).
2420+
///
2421+
/// Unlike `refreshAfterReparent()`, this rebuilds the renderer itself
2422+
/// rather than only repainting: the failure it targets is a Metal
2423+
/// presentation chain that refuses every frame, where repainting re-enters
2424+
/// the same refusal (see `PineTerminalView.recoverRendererNow()`).
2425+
///
2426+
/// SIGWINCH is raised unconditionally rather than only for the alternate
2427+
/// screen. A stuck renderer leaves Pine unable to tell whether the primary
2428+
/// buffer still matches what the child last drew, and the signal is
2429+
/// harmless to an ordinary shell — it simply reprints its prompt.
2430+
///
2431+
/// A terminated tab keeps its scrollback and is still worth repainting,
2432+
/// but has no child to signal.
2433+
func recoverDisplay() {
2434+
(terminalView as? PineTerminalView)?.recoverRendererNow()
2435+
forceFullRedraw()
2436+
kickPTYWindowSize()
2437+
}
2438+
23752439
/// Whether the shell process is still running.
23762440
var isProcessRunning: Bool {
23772441
!isTerminated && processStarted && terminalView.process.running

PineTests/PineAppMenuCommandsTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ struct PineAppMenuCommandsTests {
2828
checkForUpdatesAction: {}
2929
),
3030
toggleQuickTerminal: {},
31+
recoverQuickTerminalDisplay: {},
3132
recentProjects: { [] },
3233
showAgentInbox: {}
3334
)

PineTests/QuickTerminalTests.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,41 @@ struct QuickTerminalTests {
175175
#expect(coordinator.paneState.activeTab?.id == firstTabID)
176176
}
177177

178+
@Test("display recovery while hidden is a no-op")
179+
func recoverDisplayWhileHiddenIsNoOp() throws {
180+
// The panel is a separate window: the menu command fires at it
181+
// unconditionally because `focusedProject` is nil while it holds focus.
182+
// While hidden it is detached, so a rebuild would be discarded and the
183+
// next `show()` repaints through the ordinary attachment path.
184+
let fixture = try QuickTerminalControllerFixture()
185+
defer { fixture.cleanUp() }
186+
let coordinator = fixture.controller
187+
188+
coordinator.recoverDisplay()
189+
190+
#expect(coordinator.isVisible == false)
191+
#expect(coordinator.paneState.terminalTabs.isEmpty)
192+
}
193+
194+
@Test("display recovery while visible repaints the live session")
195+
func recoverDisplayWhileVisibleRepaints() throws {
196+
let fixture = try QuickTerminalControllerFixture()
197+
defer { fixture.cleanUp() }
198+
let coordinator = fixture.controller
199+
coordinator.show()
200+
let tab = try #require(coordinator.paneState.activeTab)
201+
let view = try #require(tab.terminalView as? PineTerminalView)
202+
var redrawRequests = 0
203+
view.backendRedrawRequestObserver = { redrawRequests += 1 }
204+
205+
coordinator.recoverDisplay()
206+
207+
#expect(redrawRequests >= 1)
208+
// Recovery repaints the session; it must never restart it.
209+
#expect(coordinator.paneState.activeTab?.id == tab.id)
210+
coordinator.hide()
211+
}
212+
178213
@Test("cwd resolves to most-recent project, else $HOME")
179214
func cwdFallbackChain() throws {
180215
let fixture = try QuickTerminalControllerFixture()

0 commit comments

Comments
 (0)