fix(tui): kept ssh partial result commit-unstable

Reviewer flagged that ToolExecutionComponent.isTranscriptBlockCommitStable\ntreated any block with a defined #result as commit-stable, so a long\nstreaming SSH partial render could sit on its stable pending header until\nderiveLiveCommitState's stable-prefix ratchet promoted it to native\nscrollback, then the final SSH glyph render would land below and strand a\nduplicate pending header above the settled frame.\n\n- Added ToolRenderer.provisionalPartialResult opt-in for renderers whose\n  partial-result chrome differs from the final render.\n- Honored it in ToolExecutionComponent.isTranscriptBlockCommitStable so the\n  block stays commit-unstable while isPartial holds and flips stable as soon\n  as the result settles.\n- Set provisionalPartialResult: true on the SSH renderer.\n- Added test/tools/ssh-commit-stability.test.ts covering the partial/final\n  transition and the non-regression for bash.\n\nFixes #3177
This commit is contained in:
roboomp
2026-06-21 08:13:56 +00:00
parent 5910ae32b4
commit 3faeddd3a6
5 changed files with 113 additions and 10 deletions
@@ -635,15 +635,31 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
// `provisionalPendingPreview` describes only the PENDING call preview
// (`renderCall`, before any result): the result render may re-anchor it
// wholesale, so its rows must never commit. Once a (streaming partial)
// result exists the result renderer is the live shape — its body is
// top-anchored and grows append-only, and `deriveLiveCommitState` gates
// per-row durability — so the block is commit-stable like any settled
// stream. Gating the flag on the pending phase is what keeps a collapsed
// streaming eval/bash/ssh whose box outgrows the viewport from stranding
// its head: while commit-unstable its scrolled-off top committed nowhere
// and repainted nowhere, so it read as truncated until ctrl+o (expanded)
// flipped it stable.
if (this.#result !== undefined) return true;
// result exists the result renderer is usually the live shape — its body
// is top-anchored and grows append-only, and `deriveLiveCommitState`
// gates per-row durability — so the block is commit-stable like any
// settled stream. Gating the flag on the pending phase is what keeps a
// collapsed streaming eval/bash/ssh whose box outgrows the viewport from
// stranding its head: while commit-unstable its scrolled-off top
// committed nowhere and repainted nowhere, so it read as truncated until
// ctrl+o (expanded) flipped it stable.
//
// Renderers whose partial-result chrome (header glyph, frame state)
// differs from the final result render set `provisionalPartialResult`
// to opt out of stream-commit while `isPartial` holds: the ratchet
// would otherwise promote the stable partial chrome to native scrollback
// after `STABLE_PREFIX_COMMIT_FRAMES` and leave it stranded above the
// final frame once the chrome flips. Once the result settles
// (`isPartial === false`) the block is commit-stable again.
if (this.#result !== undefined) {
if (this.#isPartial) {
const tool = this.#tool as { provisionalPartialResult?: boolean } | undefined;
const provisionalPartialResult =
tool?.provisionalPartialResult ?? toolRenderers[this.#toolName]?.provisionalPartialResult;
if (provisionalPartialResult) return false;
}
return true;
}
const tool = this.#tool as { provisionalPendingPreview?: boolean | "collapsed" } | undefined;
const provisionalPendingPreview =
tool?.provisionalPendingPreview ?? toolRenderers[this.#toolName]?.provisionalPendingPreview;