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
8 changes: 7 additions & 1 deletion packages/tui/src/tui/controller/chat-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,13 @@ export class TuiChatController {
}

getTerminalDurationId(): string | undefined {
return this.transcript.snapshot().find((cell) => cell.kind === 'turn-duration')?.id;
// Older notes can stay in history once settled output follows them (#426);
// the terminal duration is the most recent one.
const cells = this.transcript.snapshot();
for (let index = cells.length - 1; index >= 0; index -= 1) {
if (cells[index]?.kind === 'turn-duration') return cells[index]?.id;
}
return undefined;
}

dismissTerminalDuration(id: string | undefined): void {
Expand Down
71 changes: 61 additions & 10 deletions packages/tui/src/tui/controller/projection/turn-projection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { TuiMessage, TuiStreamEvent } from '../../../runtime/port.js';
import type {
TranscriptAttachment,
TranscriptCell,
TranscriptCellStatus,
TranscriptUserPresentation,
} from '../../transcript/model.js';
Expand Down Expand Up @@ -62,7 +63,10 @@ export class TuiTurnProjection {
let changed = this.todoProjection.clearSettled();
changed = this.removePreviousTerminalDuration(turnId) || changed;
// One-time feedback survives history refreshes, so dismiss it when a new run starts.
for (const cell of this.transcript.snapshot()) {
const cells = this.transcript.snapshot();
const tailStart = dismissibleTailStart(cells);
cells.forEach((cell, index) => {
if (index < tailStart) return;
// Runtime terminal errors survive history reconciliation, but belong only
// to the failed run. Otherwise a successful retry can reproject them again.
if (cell.kind === 'error' && cell.ephemeral && cell.turnId && cell.turnId !== turnId) {
Expand All @@ -75,18 +79,24 @@ export class TuiTurnProjection {
) {
changed = this.transcript.remove(cell.id) || changed;
}
}
});
if (changed) this.onChange();
this.liveProjection.beginTurn(turnId, timestamp);
}

removePreviousTerminalDuration(turnId?: string): boolean {
let changed = false;
for (const cell of this.transcript.snapshot()) {
if (cell.kind === 'turn-duration' && (turnId === undefined || cell.turnId !== turnId)) {
const cells = this.transcript.snapshot();
const tailStart = dismissibleTailStart(cells);
cells.forEach((cell, index) => {
if (
index >= tailStart &&
cell.kind === 'turn-duration' &&
(turnId === undefined || cell.turnId !== turnId)
) {
changed = this.transcript.remove(cell.id) || changed;
}
}
});
return changed;
}

Expand Down Expand Up @@ -249,11 +259,16 @@ export class TuiTurnProjection {
// history. Its cell is `ephemeral`, and `replaceDurableProjection` retains
// ephemeral cells across reprojection, so without pruning here every
// finished turn would leave its own note behind and they would stack up
// ("Completed in 37s / 4m 02s / 8m 44s"). Drop older notes so only the
// most recent one survives.
for (const cell of this.transcript.snapshot()) {
if (cell.kind === 'turn-duration' && cell.id !== id) this.transcript.remove(cell.id);
}
// ("Completed in 37s / 4m 02s / 8m 44s"). Drop older notes that are still
// at the live tail; a note with settled output after it stays (see
// `dismissibleTailStart`).
const cells = this.transcript.snapshot();
const tailStart = dismissibleTailStart(cells);
cells.forEach((cell, index) => {
if (index >= tailStart && cell.kind === 'turn-duration' && cell.id !== id) {
this.transcript.remove(cell.id);
}
});
this.transcript.upsert({
id,
kind: 'turn-duration',
Expand Down Expand Up @@ -304,6 +319,42 @@ export class TuiTurnProjection {
}
}

/**
* Index from which one-time feedback (a run-duration note, a finished
* ephemeral `!` shell block, a stale run error) may still be dismissed.
*
* Rows of settled cells are final, so in regular mode they can already sit in
* the terminal's native scrollback. Removing a feedback cell that has settled
* output after it would shift every later row and force the renderer to
* rebuild native history, which moves a scrolled-up reader to the top (#426).
* Feedback stays dismissible only while nothing settled follows it: just the
* next prompt, live cells, or other feedback. Once settled output follows, the
* cell is history and stays where it is.
*/
function dismissibleTailStart(cells: readonly TranscriptCell[]): number {
for (let index = cells.length - 1; index >= 0; index -= 1) {
const cell = cells[index];
if (cell && isSettledOutput(cell)) return index + 1;
}
return 0;
}

function isSettledOutput(cell: TranscriptCell): boolean {
if (cell.kind === 'user') return false;
if (cell.status === 'pending' || cell.status === 'running' || cell.status === 'blocked') {
return false;
}
return !isOneTimeFeedback(cell);
}

function isOneTimeFeedback(cell: TranscriptCell): boolean {
return (
cell.kind === 'turn-duration' ||
(cell.ephemeral === true && cell.kind === 'error' && Boolean(cell.turnId)) ||
(cell.ephemeral === true && cell.kind === 'shell')
);
}

function isPositiveFinite(value: number | undefined): value is number {
return typeof value === 'number' && Number.isFinite(value) && value > 0;
}
6 changes: 3 additions & 3 deletions packages/tui/src/tui/engine/LOCAL_CHANGES.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@
},
{
"path": "tui-main-screen.ts",
"currentSha256": "b8f93a0b2e93f7295b3330d9af3d98a577d2c4a5d4023b1833c77d5226780ffb",
"currentSha256": "3be305eaf9113705e403a995fefe93aadc23256b57b2e38c163a15e1a4a1950b",
"changeIds": [
"L005",
"L017",
Expand All @@ -179,8 +179,8 @@
"L047"
],
"upstreamCommit": "6c4f360264397c59801f6da2bdac13e3b1fcbe91",
"reason": "Keep strict TypeScript fixes and stream full and differential renders through Pi's bounded terminal writer. 缩放期间仅重绘可见尾部 常规模式在差分比较前剥离行首 OSC 133 zone 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。 Preserve ordered asynchronous terminal output under POSIX TTY backpressure. Defer history reconstruction after output-driven layout shrink until user input.",
"behaviorImpact": "Regular viewport redraws erase rows in place so hosts that save an erased screen to scrollback do not retain stale transcript or footer rows. Visible text-only shrink with unchanged historical text temporarily pads the active screen to preserve host scrolling and the input position; later output reuses this space. Historical text replacement or removal still reconstructs the session to avoid stale or duplicate history. Rebuilding clears pre-launch shell scrollback. Genuine resize retains delayed history replay; redundant same-size notifications are ignored. Slow terminal output does not block input; pending frames coalesce and terminal handoffs drain ordered output. A scrolled-up reader stays in place when a transient layout shrinks without user input; the reconstruction runs after the next key, a resize replay or at stop."
"reason": "Keep strict TypeScript fixes and stream full and differential renders through Pi's bounded terminal writer. 缩放期间仅重绘可见尾部 常规模式在差分比较前剥离行首 OSC 133 zone 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。 Preserve ordered asynchronous terminal output under POSIX TTY backpressure. Defer history reconstruction without recent user input until the next key.",
"behaviorImpact": "Regular viewport redraws erase rows in place so hosts that save an erased screen to scrollback do not retain stale transcript or footer rows. Visible text-only shrink with unchanged historical text temporarily pads the active screen to preserve host scrolling and the input position; later output reuses this space. Historical text replacement or removal reconstructs the session to avoid stale or duplicate history; without recent user input the screen is repainted in place first and the reconstruction waits for the next key. Rebuilding clears pre-launch shell scrollback. Genuine resize retains delayed history replay; redundant same-size notifications are ignored. Slow terminal output does not block input; pending frames coalesce and terminal handoffs drain ordered output. A scrolled-up reader stays in place when a transient layout shrinks, historical text changes or the document shrinks without user input; the reconstruction runs after the next key, a resize replay or at stop."
},
{
"path": "tui.ts",
Expand Down
15 changes: 8 additions & 7 deletions packages/tui/src/tui/engine/LOCAL_CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct
- Tradeoff: structural reconstruction clears native scrollback, including shell history from before TUI startup. Initial short chat documents retain natural document placement. L038 keeps freed visible rows temporarily blank instead of reconstructing unchanged history.
- Evidence: local-delta tests assert every visible row and the complete history, while real Tasks and feature lifecycle tests cover short/long content, background growth, paging, resize, nested panels and return to chat. Queue lifecycle tests replay bracketed CJK paste, Alt+Enter, auto-drain, and history refresh through Ghostty; equal-height and growing historical edits are also covered by xterm. Virtual terminals do not establish native Windows Terminal or iTerm2 touchpad acceptance.
- Product boundary: the regular chat layout keeps rows that may still change out of native history (see L045), so this reconstruction remains for resize and for content that changes after being reported final.
- Scrolled-up readers: reconstruction after an output-driven layout shrink is deferred until the next user input (see L047); resize, input-driven reconstructions and changed historical text remain immediate.
- Scrolled-up readers: without recent user input, reconstruction (output-driven layout shrink, changed or removed historical text, a shrink past the previous viewport) repaints the screen in place and is deferred until the next user input (see L047); resize and input-driven reconstructions remain immediate.
- Removal condition: the selected Pi baseline provides equivalent complete viewport and unique-history behavior.

## L036: Unframed multiline paste chunks
Expand All @@ -145,7 +145,7 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct

- Product contract: settling visible activity rows must not clear native scrollback or pin a scrolled host viewport to the top. The Composer and status remain at the bottom, and historical content remains unique.
- Minimal difference: when terminal geometry, the text already in scrollback and the declared transient layout keys are unchanged, absorb visible text-only shrink with blank rows at the current screen boundary before cursor extraction and differential rendering. L041 makes this an explicit background-content policy; unclassified layouts restore exposed rows. Subsequent output consumes the space before advancing native history. Ignore redundant same-size resize notifications without cancelling a genuine pending resize replay.
- Boundary: padding is confined to the active screen. Historical text replacement/removal, real resize, overlays and image reflow retain the structural reconstruction path. Blank rows can temporarily separate native history from the visible tail; this is preferable to clearing and replaying the terminal's scrollback during ordinary completion. No mouse capture is enabled in regular mode.
- Boundary: padding is confined to the active screen. Historical text replacement/removal, real resize, overlays and image reflow retain the structural reconstruction path (deferred to the next key without recent input, see L047). Blank rows can temporarily separate native history from the visible tail; this is preferable to clearing and replaying the terminal's scrollback during ordinary completion. No mouse capture is enabled in regular mode.
- Evidence: local-delta tests use xterm's host scroll API independently of the hardware cursor, reproduce the pre-fix jump to line zero, and verify stable scrolling, Composer position, unique history, reclaimed space, corrected-history reconstruction and resize behavior. The product queue/feature tests continue to cover canonical history replacement. Native Windows Terminal and UU Remote acceptance remain separate.
- Removal condition: the selected Pi baseline preserves host scrolling and unique history through visible shrink.

Expand Down Expand Up @@ -212,11 +212,12 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct
- Evidence: `tui-engine-local-deltas.test.ts` retained-document cases assert no scrollback erase and exact native history after trimming (failing without the rebase), and exact history without stale rows when a root reports too few or too many discarded rows.
- Removal condition: the selected Pi baseline supports discarding a committed document prefix.

## L047: Keep scrolled-up readers in place after output-driven layout shrink
## L047: Keep scrolled-up readers in place during output-driven reconstruction

- Product contract: a reader who scrolled up in native history is not moved to the top of the scrollback when a transient layout region shrinks without user input, such as the task list collapsing when a reply finishes. Tailing readers stay at the bottom, and input-driven reconstructions keep the L034 complete-viewport and unique-history behavior (#426).
- Product contract: a reader who scrolled up in native history is not moved to the top of the scrollback when the document changes without user input in a way that needs reconstruction: a transient layout region shrinks (the task list collapsing when a reply finishes), text already in native history changes or is removed, the document shrinks past the previous viewport, or a footer taller than a small screen refreshes. Tailing readers stay at the bottom, and input-driven reconstructions keep the L034 complete-viewport and unique-history behavior (#426).
- Cause: L034 clears with ED 3 and replays the document. Hosts such as xterm.js keep their scrolled state while scrollback is rebuilt, so ED 3 moves the viewport to line 0 and the replay does not move it down again. No output sequence can restore the host's scroll offset.
- Minimal difference: `tui.ts` calls a protected `onUserInput()` hook when an input chunk contains a key or paste. The chunk is split into control sequences; focus, window, cursor-position, device-attribute, device-status, kitty-flag and mode reports, OSC/DCS/APC strings, SGR mouse reports and key releases do not count, so a key sharing a chunk with reports still does. In `tui-main-screen.ts`, a shrink in an unknown or changed layout reconstructs immediately only within one second of user input, which covers input-driven closes; otherwise it uses L038 padding and marks a deferred replay. Only user input after the deferral, a settled resize replay or `stop()` runs the deferred L034 reconstruction. Resize keeps its existing delayed replay, and changed historical text still reconstructs immediately.
- Tradeoff: after an output-driven layout shrink, blank rows can separate history from the screen until the next key. The one-second input window is a heuristic: a reader who scrolls up within it while a layout shrink lands can still be moved. A host that does not scroll to the bottom on input can still be moved to the top by the deferred replay. Legacy Shift+F3-style keys encoded as `CSI 1;n R` are indistinguishable from cursor position reports and do not trigger the replay. A resize that settles while the reader is scrolled up still moves the viewport to the top.
- Evidence: `tui-engine-local-deltas.test.ts` scrolls xterm up, collapses the Tasks section without input, and asserts no ED 3, an unchanged scroll position and unique history, then exact history after the next key; a tailing reader stays at the bottom when output ends and the task list collapses; recent input reconstructs immediately; resize keeps its replay; each report kind and a report-only mixed chunk do not trigger the replay, while keys, Escape and a paste mixed with reports do; and the deferred replay runs before stop. The shrink and input cases emit ED 3 or fail without the change, and cursor position and mixed report chunks counted as input under whole-chunk matching. Native Windows Terminal and ConPTY acceptance remain separate.
- Minimal difference: `tui.ts` calls a protected `onUserInput()` hook when an input chunk contains a key or paste. The chunk is split into control sequences; focus, window, cursor-position, device-attribute, device-status, kitty-flag and mode reports, OSC/DCS/APC strings, SGR mouse reports and key releases do not count, so a key sharing a chunk with reports still does. In `tui-main-screen.ts`, a shrink in an unknown or changed layout reconstructs immediately only within one second of user input, which covers input-driven closes; otherwise it uses L038 padding and marks a deferred replay. Without input in that window, the other L034 reconstructions also repaint the screen in place and mark a deferred replay: changed historical text repaints from the previous viewport origin, so growth in the same frame still enters native history, and a shrink that the padding cannot absorb (changed history, overlays, images, or no rows left in the previous viewport) paints the document's last rows. Only user input after the deferral, a settled resize replay or `stop()` runs the deferred L034 reconstruction. Resize keeps its existing delayed replay. `PI_DEBUG_REDRAW=1` log lines carry the process id, whether a reconstruction was deferred, and the first changed historical row with letters and digits masked.
- Tradeoff: after an output-driven layout shrink, blank rows can separate history from the screen until the next key. After other output-driven reconstructions, native history keeps stale, removed or duplicated rows above the screen until the next key; the screen itself is current. The one-second input window is a heuristic: a reader who scrolls up within it while a layout shrink lands can still be moved. A host that does not scroll to the bottom on input can still be moved to the top by the deferred replay. Legacy Shift+F3-style keys encoded as `CSI 1;n R` are indistinguishable from cursor position reports and do not trigger the replay. A resize that settles while the reader is scrolled up still moves the viewport to the top.
- Evidence: `tui-engine-local-deltas.test.ts` scrolls xterm up and, without input, changes a scrollback row, removes the previous run-duration note from a real append-only transcript, shrinks the document by more than a screen, and refreshes a footer taller than a five-row screen, asserting no ED 3, an unchanged scroll position and a current screen, then exact history after the next key; existing reconstruction cases deliver the next key before asserting exact history. It also scrolls xterm up, collapses the Tasks section without input, and asserts no ED 3, an unchanged scroll position and unique history, then exact history after the next key; a tailing reader stays at the bottom when output ends and the task list collapses; recent input reconstructs immediately; resize keeps its replay; each report kind and a report-only mixed chunk do not trigger the replay, while keys, Escape and a paste mixed with reports do; and the deferred replay runs before stop. The shrink and input cases emit ED 3 or fail without the change, and cursor position and mixed report chunks counted as input under whole-chunk matching. Native Windows Terminal and ConPTY acceptance remain separate.
- Product side: the transcript no longer removes one-time feedback (the run-duration note, a finished ephemeral `!` shell block, a stale run error) once settled output follows it, which was the common trigger of changed historical text; this fallback covers the remaining causes, such as the welcome header's live status on screens too small to hold the welcome surface.
- Removal condition: the selected Pi baseline preserves the host scroll position through history reconstruction, or MCode stops reconstructing native history outside user input.
Loading
Loading