Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/lib/models/terminal_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,23 @@ class TerminalTab {
final String? type; // "agent" | "service"
final bool unread;

/// Bumped whenever what the app knew about this PTY's geometry stops being
/// trustworthy — a reconnect, a same-id respawn, or a resize the service
/// queued and then discarded.
///
/// The driver only re-sends `terminal:resize` when its computed grid differs
/// from the last size it believes the PTY received, so a size that never
/// arrived (a send dropped in a keyless window, or a queued one cancelled
/// before it reached the wire) and one a fresh PTY never had (a respawn takes
/// the bridge's `lastDriverGeometry`, which is whatever terminal resized
/// last — 80x24 only on a bridge that has never seen a resize) are both
/// disagreements nothing else can detect: the panel is not moving, so the
/// wrapper computes the same grid forever and the gate stays shut. The
/// counter is the invalidation edge that reopens it, invalidated by the same
/// events as the snapshot-seq cutoff in `TerminalService._rehydrateTerminals`
/// and for the same reason.
final int sizeEpoch;

/// Ghostty controller — the agent's PTY bytes are fed in via
/// `ghostty.appendOutputBytes(...)` from terminal_service, and user
/// input is routed back through `attachExternalTransport` to the
Expand All @@ -34,6 +51,7 @@ class TerminalTab {
this.exitCode,
this.type,
this.unread = false,
this.sizeEpoch = 0,
GhosttyTerminalController? ghostty,
}) : ghostty =
ghostty ??
Expand Down Expand Up @@ -64,6 +82,7 @@ class TerminalTab {
bool clearExitCode = false,
String? type,
bool? unread,
int? sizeEpoch,
}) {
return TerminalTab(
terminalId: terminalId,
Expand All @@ -78,6 +97,7 @@ class TerminalTab {
exitCode: clearExitCode ? null : (exitCode ?? this.exitCode),
type: type ?? this.type,
unread: unread ?? this.unread,
sizeEpoch: sizeEpoch ?? this.sizeEpoch,
ghostty: ghostty,
);
}
Expand Down
99 changes: 95 additions & 4 deletions app/lib/services/terminal_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,30 @@ class TerminalService {
// conditional on one would strand a cutoff above every seq a respawned PTY
// emits and leave the pane blank behind a live process.
_snapshotSeq.clear();
for (final tab in _state.tabs.values) {
// The geometry is invalidated on the same grounds as the cutoff above: a
// reattach can hide a resize the agent never applied, and an
// exit-and-respawn nothing on the wire reported (the seq reasoning in the
// constructor). Neither is detectable from the app's side, and the driver's
// gate compares against what it BELIEVES it sent, so an unmoving panel
// never reopens it. Bumped for every live tab rather than only the
// re-pulled ones; the bridge folds a resize that changes nothing away, so a
// bump that proves unnecessary costs no SIGWINCH.
//
// The `focusResumed` caller keeps its transport, so nothing was lost there
// — but it shares this path because the same edge covers a heavy
// re-subscribe, which an unwitnessed respawn can hide just as a reconnect
// can.
final rehydrated = Map<String, TerminalTab>.from(_state.tabs);
var invalidated = false;
for (final entry in _state.tabs.entries) {
if (!_hasLivePty(entry.value)) continue;
rehydrated[entry.key] = entry.value.copyWith(
sizeEpoch: entry.value.sizeEpoch + 1,
);
invalidated = true;
}
if (invalidated) _setState(_state.copyWith(tabs: rehydrated));
for (final tab in rehydrated.values) {
// A pending tab is the app's own optimistic invention — the agent has
// never confirmed the id, so it would answer "snapshot requested for
// unknown terminal" and send nothing. Its own terminal:started carries
Expand All @@ -155,6 +178,33 @@ class TerminalService {
}
}

/// Whether a resize aimed at [tab] can reach a PTY.
///
/// A pending id is the app's own optimistic invention the agent has never
/// confirmed, and an exited tab has no PTY behind it — an agent tab keeps
/// rendering the terminal view past its own exit, so a geometry
/// invalidation on either only buys a frame the bridge logs as unknown and
/// drops.
bool _hasLivePty(TerminalTab tab) =>
tab.sessionState == TerminalSessionState.running &&
!_pendingTerminalIds.contains(tab.terminalId);

/// Retires whatever the driver believes [terminalId]'s geometry is, so its
/// next build re-sends at an unchanged panel size.
///
/// Called wherever a resize the app already reported as accepted is known
/// not to have reached the PTY. `sendResize` answers at QUEUE time, and the
/// 100ms debounce it arms can still be cancelled or discarded afterwards;
/// the caller has booked the size by then, so nothing but an epoch bump
/// reopens its gate.
void _invalidateGeometry(String terminalId) {
final tab = _state.tabs[terminalId];
if (tab == null) return;
final tabs = Map<String, TerminalTab>.from(_state.tabs);
tabs[terminalId] = tab.copyWith(sizeEpoch: tab.sizeEpoch + 1);
_setState(_state.copyWith(tabs: tabs));
}

void _setState(TerminalState state) {
if (_disposed) return;
// Focusing a terminal — by ANY path (list tap, pinned/pushed view, agent
Expand Down Expand Up @@ -353,13 +403,21 @@ class TerminalService {

if (existing != null) {
existing.ghostty.setSessionRunning(true);
// A start on an id the app already holds is a RESPAWN, and the new PTY
// carries whatever `TerminalManager.lastDriverGeometry` held — the size
// of whichever terminal resized last in that bridge process, or 80x24 on
// a bridge that has never seen a resize — not the one the driver sent the
// dead one. The driver gates its re-sends on the last size it believes
// the PTY has, so without this bump it computes the same grid, sees no
// change, and leaves a wide panel rendering a narrower process.
tabs[msg.terminalId] = existing.copyWith(
sessionState: TerminalSessionState.running,
shell: msg.shell,
cols: msg.cols,
rows: msg.rows,
clearExitCode: true,
type: msg.terminalType,
sizeEpoch: existing.sizeEpoch + 1,
);
} else {
final tab = _createTab(
Expand Down Expand Up @@ -401,15 +459,22 @@ class TerminalService {
clientId == null ||
(msg.driverClientId != clientId &&
(pendingBase == null || msg.driverClientId != pendingBase));
var dropped = false;
if (stalePending) {
_resizeTimers.remove(msg.terminalId)?.cancel();
final queued = _resizeTimers.remove(msg.terminalId);
queued?.cancel();
dropped = queued != null;
_resizeBaseDrivers.remove(msg.terminalId);
}
final tabs = Map<String, TerminalTab>.from(_state.tabs);
tabs[msg.terminalId] = tab.copyWith(
cols: msg.cols,
rows: msg.rows,
driverClientId: msg.driverClientId,
// The caller booked that size the moment `sendResize` queued it, so a
// frame cancelled here would otherwise leave its gate shut against a
// geometry the PTY never received.
sizeEpoch: dropped ? tab.sizeEpoch + 1 : null,
);
_setState(_state.copyWith(tabs: tabs));
}
Expand Down Expand Up @@ -468,6 +533,14 @@ class TerminalService {
final existing = _state.tabs[info.terminalId];
if (existing != null) {
existing.ghostty.setSessionRunning(info.running);
// The same respawn `_handleTerminalStarted` bumps on, seen through the
// status frame instead: a client that missed the live started frame
// builds its tabs from this replay (see the discovery pull below), so
// without the bump here the driver's booking survives a PTY that never
// received it — on the one path a relay app actually uses.
final respawned =
info.running &&
existing.sessionState == TerminalSessionState.exited;
final updated = existing.copyWith(
name: info.name,
sessionState: info.running
Expand All @@ -477,6 +550,7 @@ class TerminalService {
cols: info.cols,
rows: info.rows,
type: info.type,
sizeEpoch: respawned ? existing.sizeEpoch + 1 : null,
);
newTabs[info.terminalId] = info.driverClientId == null
? updated.copyWith(clearDriverClientId: true)
Expand Down Expand Up @@ -657,14 +731,27 @@ class TerminalService {
sendInput(agentTabs.first.terminalId, text);
}

void sendResize(
/// Queues a debounced `terminal:resize`, reporting whether it was QUEUED.
///
/// False means nothing was queued and nothing ever will be for this call —
/// the per-install client id has not resolved yet (see
/// `terminalStateProvider`, which pushes it in), or the service is gone. The
/// caller must not record the size as sent: the wrapper gates re-sends on the
/// last size it believes the PTY has, so a drop booked as a send strands the
/// PTY at the previous geometry until the panel happens to change size again.
///
/// True is not a delivery receipt. The 100ms debounce this arms can still be
/// cancelled (`_handleTerminalSize`, `deleteTerminal`) or discarded by its own
/// driver guard, and every such path owes the caller an `_invalidateGeometry`
/// — that bump is what retires a booking the wire never honoured.
bool sendResize(
String terminalId,
int cols,
int rows, {
String? baseDriverClientId,
}) {
final clientId = _clientId;
if (clientId == null) return;
if (_disposed || clientId == null) return false;
_resizeTimers[terminalId]?.cancel();
_resizeBaseDrivers[terminalId] = baseDriverClientId;
_resizeTimers[terminalId] = Timer(const Duration(milliseconds: 100), () {
Expand All @@ -675,6 +762,9 @@ class TerminalService {
currentDriver != null &&
currentDriver != baseDriverClientId &&
currentDriver != clientId) {
// Discarded, not sent — and the caller booked this size when the queue
// accepted it, so hand back the invalidation edge that reopens its gate.
_invalidateGeometry(terminalId);
return;
}
_send(
Expand All @@ -687,6 +777,7 @@ class TerminalService {
}),
);
});
return true;
}

void requestStart(
Expand Down
8 changes: 5 additions & 3 deletions app/lib/widgets/terminal_cell_metrics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ const double kGhosttyLineHeight = 1.35;
/// one frame LATE, and on every remount (a session switch re-keys the wrapper)
/// that first frame has no metrics at all. Laying the grid out at a
/// locally-derived width for that one frame and then correcting it is a real
/// grid resize under the guest, and `ghostty_vte_flutter` does not reflow: an
/// Ink-style TUI leaks stale fragments across it.
/// grid resize under the guest, and an Ink-style TUI leaks stale fragments
/// across it. The engine's own reflow does not save this: it re-wraps SOFT
/// wrapped rows only, and a TUI that wraps its own output writes the break
/// itself — see `terminal_reflow_contract_test.dart`, which pins both halves.
///
/// HAND-MIRRORED against the pinned fork (`ghostty_vte_flutter`, ref
/// `c262d5f2002d26b2116b2c5c943a46a63f994133`):
/// `6831ba09fe9a298ecd9e1d9bdb40de141b9bfbee`):
/// `_GhosttyTerminalViewState._measureMetrics` and
/// `_snapLogicalExtentToPhysical` in `lib/src/terminal_view.dart`. The package
/// defaults the wrapper does not override are folded in — `cellWidthScale = 1`,
Expand Down
Loading