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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## Unreleased

### Fixed

- Long-running commands no longer animate the Visual Progress rainbow bar
forever. The OSC 133 Shell Integration fallback published at
`CommandExecuted` used to stay in an indeterminate Running state until
`CommandFinished`, so development servers (FastAPI/uvicorn, Spring Boot,
Node.js), Alternate Screen applications (k9s, vim, top, htop), `tail -f`,
`kubectl port-forward`, and similar intentionally long-running processes
looped the comet animation for their whole lifetime. The fallback is now a
bounded one-shot launch indication: the comet makes one traversal (the
existing 1,800 ms cycle, driven by a one-shot compositor batch rather than
a timer or polling loop) and then the overlay hides and releases its
animation resources. The expiration is command-generation scoped, so stale
completions cannot affect a newer command, re-entering the Alternate Screen
or rehydrating a pane cannot replay a consumed launch, and an expired
fallback cannot resurrect after a CLI provider or explicit OSC 9;4 owner
clears. Explicit progress, recognized providers, short-lived commands, and
the success/error/cancelled result presentations keep their existing
behavior.


## 1.3.0-beta2 - 2026-08-04

Second beta of the Command Timeline release, fixing a failure-reporting
Expand Down
53 changes: 51 additions & 2 deletions docs/development/visual-progress-phase2.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,55 @@ Progress ownership follows this order:

Generic heuristic recognition cannot override explicit progress or an owned built-in provider. Clearing explicit progress reveals the current valid provider or shell fallback. A new semantic prompt resets provider ownership and bounded parser state for that pane.

## Bounded shell launch fallback

The generic OSC 133 running state is a bounded launch indication, not a report
of the whole execution. When `CommandExecuted` starts a command that has no
explicit progress and no recognized provider, the indeterminate comet makes one
traversal; the overlay then hides and releases its continuous animation
resources. The fallback is not intended to represent the entire execution
duration: a development server (FastAPI/uvicorn, Spring Boot, Node.js), a TUI
such as k9s, vim, or top, `tail -f`, `kubectl port-forward`, and every other
intentionally long-running process keeps printing output with no permanent
decorative animation. Explicit OSC 9;4 progress and recognized CLI providers
are unaffected: they remain active for their real progress lifecycle, and
determinate values, provider stages, success, error, and cancelled
presentations keep their existing semantics.

The one-shot is command-generation-scoped state in the progress state machine,
never a command-name or process-name heuristic:

- Every shell lifecycle transition advances a monotonic launch generation.
`CommandExecuted` opens a new generation and publishes the launch fallback
carrying it. A repeated `CommandExecuted` observation of the same
still-running command (alternate-screen churn, pane rehydration, reconnect
re-broadcast) neither restarts a consumed launch nor opens a new generation,
so entering or leaving the Alternate Screen, resizing, focus and visibility
changes, tab switches, zoom, and renderer recreation cannot replay it.
- The renderer bounds the launch with a launch clock: one one-shot compositor
animation on a private property set whose duration is the existing
1,800-millisecond indeterminate traversal. There is no timer, no polling
loop, and no CPU frame loop. The clock is decoupled from the visible comet,
so focus, geometry, and performance transitions cannot cut it short, and it
keeps running while a provider or explicit source owns the bar.
- When the clock completes, the state machine expires the fallback only if the
captured generation is still current and the stored shell state is still the
running launch; stale completions from an earlier command are inert. The
expiration publishes a silent Hidden snapshot with Running status, so
accessibility announces neither a fake success nor a fake cancellation, and
a later provider or explicit-progress clear cannot resurrect the expired
fallback.
- `CommandFinished` supersedes the launch immediately. Short commands keep
their existing completion, error, and cancellation presentations, and a
long-running command may still present its real result long after the
launch expired.

Reduced Motion, High Contrast, and degraded static tiers keep the same logical
one-shot lifecycle: the static launch presentation is bounded by the same
clock without requiring continuous animation. Only the solid XAML fallback
tier, where the compositor is unavailable, retains the pre-existing behavior
of ending the fallback at the next shell lifecycle transition.

## Rainbow Arc Weld renderer

The WinTerm-owned renderer is under `src/winterm/VisualProgress/`. Its renderer-independent state and constants are separated from the small Pane integration boundary so timing, status transitions, degradation, and resource budgets can be tested without XAML.
Expand All @@ -54,7 +103,7 @@ The centralized geometry uses a 10-DIP horizontal inset, an 8-DIP bottom inset,

The rainbow is a coherent red-to-orange-to-yellow-to-green-to-cyan-to-blue-to-violet-to-magenta gradient. Its cached brush moves on a 2,000-millisecond cycle and is never rebuilt per frame. Determinate updates normally interpolate for 220 milliseconds. A real regression uses an intentional 240-millisecond phase-reset transition; the renderer does not invent a monotonic value. Zero percent keeps the welding head inside the track, while the fill remains clipped and the bloom drawing space remains available at 100 percent.

Indeterminate progress uses a welding-head comet with a continuous tail covering 25 percent of the track. It traverses the track in 1,800 milliseconds, fades cleanly at the right edge, and reappears at the left without showing a fabricated percentage.
Indeterminate progress uses a welding-head comet with a continuous tail covering 25 percent of the track. It traverses the track in 1,800 milliseconds, fades cleanly at the right edge, and reappears at the left without showing a fabricated percentage. Explicit and provider indeterminate progress repeat the traversal continuously; the Shell Integration launch fallback runs it once and parks off-track, transparent, until the launch clock publishes the Hidden snapshot that ends the launch.

### Status presentations

Expand Down Expand Up @@ -213,7 +262,7 @@ winTerm pane:
.\scripts\winterm\invoke-visual-progress-smoke.ps1
```

The script uses only PowerShell output and OSC sequences. It requires no Docker, Node, Python, Maven, Gradle, provider CLI, network access, or download. It exercises determinate values at 0, 1, 50, 99, and 100 percent; a real regression; indeterminate, waiting, success, error, cancellation, and clear; and sanitized carriage-return samples for every built-in provider and the generic fallback.
The script uses only PowerShell output and OSC sequences. It requires no Docker, Node, Python, Maven, Gradle, provider CLI, network access, or download. It exercises determinate values at 0, 1, 50, 99, and 100 percent; a real regression; indeterminate, waiting, success, error, cancellation, and clear; the one-shot launch fallback for a long-running command, including alternate-screen entry and re-entry that must not replay it; and sanitized carriage-return samples for every built-in provider and the generic fallback.

For replacement testing, first run with
`visualProgress.replaceRecognizedOutput` false and confirm every synthetic line
Expand Down
47 changes: 47 additions & 0 deletions scripts/winterm/invoke-visual-progress-smoke.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ function Send-Osc
Wait-DemoStep
}

function Send-ControlSequence
{
param(
[Parameter(Mandatory)]
[string]$Payload
)

[Console]::Write("$([char]27)$Payload")
Wait-DemoStep
}

function Write-TransientFrame
{
param(
Expand Down Expand Up @@ -180,6 +191,16 @@ Visual Progress manual checks:
- Minimize, switch tabs, or deactivate the window to verify animation pauses or simplifies.
- Rerun after disabling Windows animations to verify the static Reduced Motion fallback.
- Rerun with a Windows contrast theme to verify the solid High Contrast fallback.
- For real long-running processes (a FastAPI/uvicorn dev server, a Spring Boot service, a Node.js dev
server, `tail -f`, or `kubectl port-forward`), the launch animation plays one traversal and then
disappears; later log output must not bring it back, and stopping the process still shows the
normal terminal result presentation.
- Real Maven/Gradle progress (for example a Spring Boot build) stays owned by the provider; once only
the long-running application remains, the expired launch fallback must not reappear.
- For Alternate Screen applications (k9s, vim, top, htop), the launch animation appears at most once;
entering, leaving, and re-entering the Alternate Screen, resizing, or switching tabs must not
replay it, and exiting still produces the normal final state when available.
- A short command keeps its current behavior: completion supersedes the launch animation immediately.
'@

foreach ($payload in @('9;4;1;0', '9;4;1;1', '9;4;1;50', '9;4;1;99', '9;4;1;100'))
Expand Down Expand Up @@ -228,6 +249,32 @@ Visual Progress manual checks:
Send-Osc '133;A'
Send-Osc '9;4;0'

Write-Host 'Visual Progress fixture: long-running command one-shot launch'
Write-Host ' Expected: the launch animation plays one traversal, then the overlay disappears while output continues.'
Send-Osc '133;B'
Send-Osc '133;C'
for ($tick = 1; $tick -le 4; $tick++)
{
Write-Host "synthetic long-running service output tick $tick (no progress overlay expected after the first traversal)"
Wait-DemoStep
}
Write-Host 'Visual Progress fixture: the long-running command still reports its real result'
Send-Osc '133;D;0'
Send-Osc '133;A'

Write-Host 'Visual Progress fixture: alternate screen during a long-running command'
Write-Host ' Expected: entering, leaving, and re-entering the alternate screen does not replay the launch animation.'
Send-Osc '133;B'
Send-Osc '133;C'
Send-ControlSequence '[?1049h'
Write-Host 'synthetic alternate-screen application frame'
Send-ControlSequence '[?1049l'
Send-ControlSequence '[?1049h'
Write-Host 'synthetic alternate-screen application frame after re-entry'
Send-ControlSequence '[?1049l'
Send-Osc '133;D;0'
Send-Osc '133;A'

Invoke-ProviderFixtures
Invoke-BoundedSoak

Expand Down
53 changes: 53 additions & 0 deletions scripts/winterm/test-visual-progress.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,20 @@ try
}
Assert-NotMatches $model 'std::wstring(?!_view)|std::string(?!_view)|winrt::hstring' 'Normalized progress state text-retention boundary'

foreach ($required in @(
'uint64_t launchGeneration{}',
'launchGeneration == other.launchGeneration',
'std::optional<ProgressSnapshot> ExpireShellLaunch(const uint64_t generation) noexcept',
'generation != _shellLaunchGeneration || _shellLaunchExpired',
'_beginShellLaunchScope',
'_resetShellLaunchScope',
'HiddenSnapshot(ProgressStatus::Running)'
))
{
Assert-Contains $model $required 'Bounded one-shot shell launch fallback policy'
}
Assert-Matches $model '(?s)case ShellLifecycleState::CommandExecuted:.*?if \(_shellLifecycle == ShellLifecycleState::CommandExecuted\)\s*\{\s*break;\s*\}' 'Idempotent CommandExecuted re-broadcast boundary'

$providers = @(
'DockerPull',
'DockerBuildKit',
Expand Down Expand Up @@ -477,6 +491,24 @@ try
}
Assert-NotMatches $renderer '_coreWindow\s*\.\s*(?:VisibilityChanged|Activated)\s*\(' 'Authoritative XAML-Islands host-window lifecycle boundary'

foreach ($required in @(
'using LaunchExpiredCallback = std::function<void(uint64_t)>',
'_synchronizeLaunchClock',
'_startLaunchClock',
'_completeLaunchClock',
'_clearLaunchClock',
'_launchClockProperties.StartAnimation(L"Progress", _launchClockAnimation)',
'_launchClockAnimation.Duration(_timeSpan(RainbowArcVisualConstants::IndeterminateCycleDuration))',
'_cometTailAnimation.IterationCount(1)',
'_cometHeadAnimation.IterationCount(1)',
'_cometHeadOpacityAnimation.IterationCount(1)'
))
{
Assert-Contains $renderer $required 'Bounded one-shot shell launch clock'
}
Assert-Matches $renderer '(?s)const auto oneShotLaunch = _snapshot\.source == ProgressSource::ShellIntegration &&\s*_snapshot\.launchGeneration != 0;' 'One-shot launch comet source discrimination'
Assert-NotContains $renderer '1800' 'Launch timeout shares the indeterminate traversal constant instead of a raw duration'

$paneVisualStart = $source.PaneCpp.IndexOf('void Pane::_SetVisualProgressEnabled', [System.StringComparison]::Ordinal)
$paneVisualEnd = $source.PaneCpp.IndexOf('void Pane::_UpdatePaneHeader', $paneVisualStart, [System.StringComparison]::Ordinal)
if ($paneVisualStart -lt 0 -or $paneVisualEnd -le $paneVisualStart)
Expand Down Expand Up @@ -750,6 +782,9 @@ try
Assert-Contains $taskbarProgress 'ApplyTaskbar' 'OSC 9;4 taskbar-state progress remains active'
Assert-NotContains $taskbarProgress '_visualProgressRecognizeCliProgress' 'OSC 9;4 independence from CLI recognition setting'

Assert-Matches $pane '(?s)RainbowArcRenderer::TryCreate\(.*?_OnVisualProgressRendererFault\(\);.*?\[weakThis\]\(const uint64_t launchGeneration\).*?_ExpireVisualProgressShellLaunch\(launchGeneration\);' 'Weak one-shot launch expiration callback registration'
Assert-Matches $pane '(?s)void Pane::_ExpireVisualProgressShellLaunch.*?ExpireShellLaunch\(launchGeneration\).*?_QueueVisualProgressUpdate' 'Launch expiration routed through the state machine and UI mailbox'

$terminalPage = $source.TerminalPageCpp
$registerStart = $terminalPage.IndexOf('void TerminalPage::_RegisterTabEvents', [System.StringComparison]::Ordinal)
$registerEnd = $terminalPage.IndexOf('void TerminalPage::_UnZoomIfNeeded', $registerStart, [System.StringComparison]::Ordinal)
Expand Down Expand Up @@ -1162,6 +1197,10 @@ try
'SuppressDuplicateState',
'ProviderProgressPrecedesShellLifecycle',
'StandardProgressPrecedesProviderAndFallsBack',
'ShellLaunchFallbackIsOneShotPerCommand',
'ShellLaunchExpirationIgnoresStaleGenerations',
'ExpiredShellLaunchDoesNotResurrectAfterOwnershipClears',
'ShellLaunchInvalidationOnResetDisableAndClose',
'ProviderStatePackingContainsOnlyStructuralFields',
'RecognitionClassifiesProvidersAndGenericFallback',
'RecognitionHandlesFragmentationAndMalformedInput',
Expand Down Expand Up @@ -1278,6 +1317,14 @@ try
'[ValidateRange(0, 10000)]',
'SoakIterations',
'synthetic summary; must remain visible',
'long-running command one-shot launch',
'alternate screen during a long-running command',
"Send-ControlSequence '[?1049h'",
"Send-ControlSequence '[?1049l'",
'launch animation plays one traversal',
'expired launch fallback must not reappear',
'appears at most once',
'completion supersedes the launch animation immediately',
'No files or external commands were used.'
))
{
Expand Down Expand Up @@ -1317,6 +1364,12 @@ try
'generic output is never suppressible',
'alternate-screen output',
'lifecycle-generation changes',
'## Bounded shell launch fallback',
'bounded launch indication',
'not intended to represent the entire execution',
'launch clock',
'command-generation-scoped state',
'cannot resurrect the expired',
'does not upload or persist terminal content',
'WINTERM_DISABLE_VISUAL_PROGRESS=1',
'invoke-visual-progress-smoke.ps1',
Expand Down
30 changes: 30 additions & 0 deletions src/cascadia/TerminalApp/Pane.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2572,6 +2572,12 @@ void Pane::_CreateVisualProgressOverlay()
{
pane->_OnVisualProgressRendererFault();
}
},
[weakThis](const uint64_t launchGeneration) {
if (const auto pane = weakThis.lock())
{
pane->_ExpireVisualProgressShellLaunch(launchGeneration);
}
});
const auto rendererReady = _visualProgressRenderer && !_visualProgressRenderer->Faulted();
_visualProgressRendererReady.store(rendererReady, std::memory_order_release);
Expand Down Expand Up @@ -2687,6 +2693,30 @@ void Pane::_UpdateVisualProgressFromProvider()
}
}

// Invoked on the UI thread when the renderer's one-shot launch clock
// completes. The state machine decides under its own lock whether the
// captured generation still identifies the current command; the resulting
// snapshot is queued through the mailbox like every other progress update,
// so no state-machine lock is ever held while renderer or XAML code runs.
void Pane::_ExpireVisualProgressShellLaunch(const uint64_t launchGeneration) noexcept
{
if (!_visualProgressEnabled.load(std::memory_order_acquire))
{
return;
}
try
{
if (const auto snapshot = _visualProgressState.ExpireShellLaunch(launchGeneration))
{
_QueueVisualProgressUpdate(*snapshot);
}
}
catch (...)
{
LOG_CAUGHT_EXCEPTION();
}
}

void Pane::_ConfigureVisualProgressRecognition() noexcept
{
try
Expand Down
1 change: 1 addition & 0 deletions src/cascadia/TerminalApp/Pane.h
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ class Pane : public std::enable_shared_from_this<Pane>
void _UpdateVisualProgressFromTaskbar();
void _UpdateVisualProgressFromShellIntegration();
void _UpdateVisualProgressFromProvider();
void _ExpireVisualProgressShellLaunch(uint64_t launchGeneration) noexcept;
void _ConfigureVisualProgressRecognition() noexcept;
void _QueueVisualProgressUpdate(const winTerm::VisualProgress::ProgressSnapshot& snapshot);
void _ScheduleVisualProgressUpdate();
Expand Down
Loading