From 08afcea737e21ca2d8b7f83704efe94601215c3f Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Mon, 3 Aug 2026 23:52:04 +0800 Subject: [PATCH 1/3] Complete Command Timeline Phase 3 entry actions Adds load, copy, and jump actions to the pane-owned Command Timeline overlay introduced in Phase 2, without adding persistence, an output cache, or any path that can execute a command. - Add a pure C++ CommandTimelineActionModel that decides load, copy, and jump eligibility from the stable selected CommandId, tracks the loaded command, and advances an execution generation so a completion from a retired command is detectable and discarded. - Load the selected command onto the focused pane input with Enter or a single click. The payload is filtered for control codes only, CarriageReturnNewline is deliberately not applied, no carriage return is appended, and SendInput targets this pane connection, so the load never executes, never reads the Windows clipboard, and is never forwarded by input broadcast. - Refuse a multi-line load when the shell has not enabled bracketed paste, and require a confirming Enter above 1024 characters. Escape cancels a pending confirmation before it closes the overlay. - Add Space to jump the viewport to the selected command native mark and a per-entry context menu with copy command, copy output, and jump to output. Ctrl+C copies the selected command while the Timeline owns the keyboard. - Resolve output on demand through Terminal::ResolveCommandTimelineOutput for an explicit copy action only. Output is never cached, indexed, or retained. - Release loaded-input state when the loaded command is evicted, and keep every action bound to the stable CommandId rather than the XAML row. - Advance engineering version surfaces to 1.2.3/1.2.3.0 for checkpoint tag v1.2.3, and extend test-command-timeline.ps1 with guards that fail if the load path gains a submission character or a clipboard read. Public Latest remains v1.2.0. Search, filtering, and public commandTimeline settings remain Phase 4 scope. --- CHANGELOG.md | 45 +++ docs/current-progress.md | 85 ++--- docs/development/command-timeline-phase3.md | 134 +++++++ docs/user/command-timeline.md | 105 ++++++ scripts/winterm/package-shell-assets.ps1 | 4 +- scripts/winterm/test-command-timeline.ps1 | 88 ++++- scripts/winterm/test-visual-progress.ps1 | 40 +- scripts/winterm/test.ps1 | 2 +- scripts/winterm/verify-branding.ps1 | 2 +- scripts/winterm/verify-version.ps1 | 18 +- .../winTerm.Shell/winTerm.Shell.psd1 | 2 +- .../winTerm.Shell/winTerm.Shell.psm1 | 2 +- shell/shared/version.json | 4 +- .../Package-winTerm.appxmanifest | 2 +- src/cascadia/TerminalControl/ControlCore.cpp | 128 +++++++ src/cascadia/TerminalControl/ControlCore.h | 6 + .../Resources/en-US/Resources.resw | 18 + src/cascadia/TerminalControl/TermControl.cpp | 356 ++++++++++++++++-- src/cascadia/TerminalControl/TermControl.h | 9 + src/cascadia/TerminalControl/TermControl.xaml | 10 + src/cascadia/TerminalCore/Terminal.cpp | 35 ++ src/cascadia/TerminalCore/Terminal.hpp | 2 + .../CommandTimelineTests.cpp | 273 ++++++++++++++ .../WindowsTerminal/WindowsTerminal.rc | 8 +- src/cascadia/wt/wt.rc | 8 +- .../winterm-shim/winterm-shim.rc | 8 +- src/winterm/Branding/ReleaseMetadata.h | 2 +- src/winterm/Branding/version.json | 8 +- .../CommandTimeline/CommandTimelineModel.cpp | 165 ++++++++ .../CommandTimeline/CommandTimelineModel.h | 73 ++++ .../Workspaces/Model/WorkspaceDescriptor.h | 2 +- .../Persistence/WorkspaceSerializer.cpp | 2 +- 32 files changed, 1523 insertions(+), 123 deletions(-) create mode 100644 docs/development/command-timeline-phase3.md create mode 100644 docs/user/command-timeline.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 294a4f82e..6037ed87e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## 1.2.3 - 2026-08-03 + +### Added + +- Added Command Timeline Phase 3 entry actions. Enter and a single click load + the selected command onto the focused pane's input line, Space jumps the + viewport to that command's native mark, and Ctrl+C copies the selected + command text while the Timeline owns the keyboard. +- Added a per-entry context menu with copy command, copy output, and jump to + output. Every action resolves through the stable pane-scoped `CommandId`, so + eviction, reflow, or a list rebuild between the right-click and the + invocation can never retarget the action to a different command. +- Added a pure C++ `CommandTimelineActionModel` that decides load eligibility, + tracks the loaded command, and advances an execution generation. A completion + that belongs to a retired generation is detectable and discarded, which is + what protects the loaded-input state from a late-completing command. +- Added on-demand output resolution. Output is read straight from the terminal + buffer only when the user explicitly asks to copy it, and is never cached, + indexed, or retained by the Timeline. + +### Changed + +- A Timeline load never executes. The payload is filtered for control codes + only, the `CarriageReturnNewline` paste filter is deliberately not applied, + no carriage return is ever appended, and the text is sent straight to this + pane's connection, so the Windows clipboard is never read and input broadcast + cannot forward the load to another pane. +- A multi-line command is refused outright when the shell has not enabled + bracketed paste, because the embedded line breaks would otherwise be consumed + as command submissions. With bracketed paste the command loads literally. +- A load above 1024 characters asks for confirmation before it is placed on the + input line; Escape cancels the pending confirmation before it closes the + overlay. +- Advanced engineering application and PowerShell module versions to `1.2.3`, + package/file versions to `1.2.3.0`, and the intended checkpoint tag to + `v1.2.3`; workspace, docking, shell, theme, update-manifest, package identity, + and signing-policy versions remain unchanged. + +### Checkpoint status + +- `v1.2.3` is an engineering checkpoint for Command Timeline Phase 3, not a + public GitHub Release. GitHub Latest and README public downloads remain on + v1.2.0. Search, filtering, and the public Command Timeline settings remain + reserved for Phase 4. + ## 1.2.2 - 2026-08-02 ### Added diff --git a/docs/current-progress.md b/docs/current-progress.md index 169176603..1eb04e553 100644 --- a/docs/current-progress.md +++ b/docs/current-progress.md @@ -1,22 +1,21 @@ # Current development progress -Last updated: 2026-08-02 +Last updated: 2026-08-03 ## Repository state - Branch: `feature/command-timeline-v1.3.0` -- Starting commit: `abb581a2a` (`main` after Command Timeline Phase 1 and the - label-gated CI/test-hang follow-up) +- Starting commit: `395f9becd` (`main` after Command Timeline Phase 2) - Microsoft Terminal upstream revision: `1cea42d433253d95c4487a3037db48197b5e72f4` -- Engineering application and PowerShell module version: `1.2.2` -- Engineering package version: `1.2.2.0` -- Intended checkpoint tag: `v1.2.2` +- Engineering application and PowerShell module version: `1.2.3` +- Engineering package version: `1.2.3.0` +- Intended checkpoint tag: `v1.2.3` - Final Command Timeline release target: `v1.3.0` - Current public Latest: `v1.2.0` - Supported target: Windows 11 x64 -`v1.2.2` is a development checkpoint, not a distributable release. The README +`v1.2.3` is a development checkpoint, not a distributable release. The README and GitHub Latest continue to identify v1.2.0 as the public Visual Progress release. Checkpoint tags v1.2.1 through v1.2.4 run quick validation only and are explicitly excluded from full build, installer packaging, asset publication, @@ -24,44 +23,46 @@ and GitHub Release jobs. ## Implemented in the working tree -- Retained the Command Timeline Phase 1 pane-owned index, bounded command-text - cache, stable IDs, native mark identity, incremental OSC 133 lifecycle, warm - bootstrap, and clear/eviction/reflow cleanup as the only history data source. -- Added a pane-owned pure C++ navigation and presentation model. It restores a - valid selected ID, native anchor, and visual slot; defaults to the latest - command; chooses the nearest surviving ID after removal; and materializes - only the visible rows required by the pane. -- Added a TermControl left-side handle and overlay layered over the terminal - surface. It does not change terminal layout, padding, swap-chain or PTY size, - and its closed state leaves all other terminal pointer input untouched. -- Added Up/Down one-entry movement, page-edge one-in/one-out viewport movement, - Left/Right current-page edge selection, hover/click-only selection, Escape - close, and focused-pane toggle routing through the existing action system. -- Added pane-local wheel/trackpad delta accumulation and a lifetime-safe settle - timer. Partial deltas are accumulated, direction reversal cancels unfinished - motion, full thresholds move complete rows, and hide/close stop the timer and - clear UI-only entries. -- Added localized list/list-item accessibility, open/close handle names, - command-plus-status names, non-color status glyphs, High Contrast-aware theme - resources, and a Reduced Motion-safe path without a continuous animation. -- Remapped canonical defaults to `Ctrl+Tab` (Timeline), `Ctrl+T` (next tab), - `Ctrl+Shift+T` (previous tab), and `Ctrl+Alt+T` (new tab), while retaining - user-defined keybinding precedence and existing IME/AltGr ordering. -- Advanced authoritative engineering version surfaces to `1.2.2`/`1.2.2.0`, - fixed the README Windows CI badge, added v1.2.1 and v1.2.2 root changelog - entries, and established the permanent root changelog/Wiki ledger policy. -- Kept Phase 3 out of scope: entry click and hover only select. They do not - insert, paste, copy, execute, close, or jump to command output. +- Retained the Phase 1 pane-owned index and the Phase 2 navigation model, + overlay, wheel accumulation, and accessibility surface unchanged as the only + history data source and presentation path. +- Added a pane-owned pure C++ `CommandTimelineActionModel`. It decides whether + a load, copy, or jump is possible from the stable selected `CommandId`, and + never resolves output, reads the clipboard, or produces a payload containing + a carriage return. +- Added Enter and single-click load onto the focused pane's input line. The + payload is filtered for control codes only, `CarriageReturnNewline` is + deliberately not applied, no carriage return is appended, and `SendInput` + targets this pane's connection, so the load can never execute, never reads + the Windows clipboard, and is never forwarded by input broadcast. +- Added multi-line and large-load protection: a multi-line command is refused + when the shell has not enabled bracketed paste, and a load above 1024 + characters requires a confirming Enter. Escape cancels a pending confirmation + before it closes the overlay. +- Added Space to jump the viewport to the selected command's native mark, and a + per-entry context menu with copy command, copy output, and jump to output. + Ctrl+C copies the selected command while the Timeline owns the keyboard. +- Added on-demand output resolution through + `Terminal::ResolveCommandTimelineOutput`. Output is read from the buffer only + for an explicit copy action and is never cached, indexed, or retained. +- Added `loadedCommandId` plus execution-generation tracking. `CommandStart` + retires the loaded state, `IsCurrentGeneration` detects a late completion from + a retired command, and `ReconcileLoadedInput` releases loaded-input state when + the loaded command is evicted. +- Advanced authoritative engineering version surfaces to `1.2.3`/`1.2.3.0` and + added the v1.2.3 root changelog entry. +- Kept Phase 4 out of scope: there is no search box, no filtering, and no + public `commandTimeline.*` settings yet. ## Validation state -Phase 2 validation requires the focused Command Timeline model/control tests, -Settings Model shortcut tests, TerminalApp action routing checks, XML/XAML/JSON -and PowerShell parsing, version/checkpoint guards, repository Smoke validation, -the smallest affected native projects, README badge verification, and GitHub -quick PR validation. Record exact results in the Draft PR and final task report; -do not treat this document as evidence for a command that did not run. +Phase 3 validation requires the focused Command Timeline model/control tests, +the extended `test-command-timeline.ps1` source and privacy boundaries, version +and branding verification, release/CI classification guards, shell integration +checks, repository Smoke validation, the smallest affected native projects, and +GitHub quick PR validation. Record exact results in the Draft PR and final task +report; do not treat this document as evidence for a command that did not run. -The annotated `v1.2.2` checkpoint tag must point to the final commit that passes +The annotated `v1.2.3` checkpoint tag must point to the final commit that passes those gates. Its tag workflow must run checkpoint quick validation only and must not create a GitHub Release or update Latest. diff --git a/docs/development/command-timeline-phase3.md b/docs/development/command-timeline-phase3.md new file mode 100644 index 000000000..1e429a947 --- /dev/null +++ b/docs/development/command-timeline-phase3.md @@ -0,0 +1,134 @@ +# Command Timeline Phase 3 — entry actions + +Phase 3 turns the read-only Phase 2 overlay into something you can act on. It +adds loading a command onto the input line, copying a command or its output, +and jumping to a command's output, without introducing persistence, an output +cache, or any automatic execution. + +Engineering checkpoint: `1.2.3` / `1.2.3.0`, tag `v1.2.3`. This is not a public +release; GitHub Latest stays on v1.2.0. + +## Scope + +| In scope | Out of scope | +| --- | --- | +| Load selected command into the pane input | Executing a command | +| Copy command text | Persisting history | +| Copy command output, resolved on demand | Caching or indexing output | +| Jump viewport to a command's native mark | Searching output | +| Per-entry context menu | Search and filtering (Phase 4) | +| Execution-generation race protection | Public settings (Phase 4) | + +## Layering + +Phase 3 keeps the same three-layer split as Phase 1 and 2. + +- `src/winterm/CommandTimeline/CommandTimelineModel.h/.cpp` — + `CommandTimelineActionModel` is pure C++ with no WinRT or buffer access. It + decides whether an action is possible and returns a `CommandActionRequest` + describing it. It never resolves output, never touches the clipboard, and + never produces a payload containing a carriage return. +- `ControlCore` — owns the pane's action model instance, applies the request + against the live terminal, and is the only place that reaches the connection, + the clipboard event, or the buffer. +- `TermControl` — routes keyboard, click, and context-menu input into the core + and renders the resulting status text. + +## Load semantics + +`ControlCore::LoadCommandTimelineCommand` is the only load path. + +1. The payload is filtered with `FilterStringForPaste(text, ControlCodes)`. + `CarriageReturnNewline` is deliberately **not** requested: converting a + newline into a carriage return would submit the command rather than load it. +2. No carriage return is ever appended. +3. When the shell has bracketed paste enabled the payload is wrapped in + `ESC [200~` / `ESC [201~` so the shell treats it as literal text. +4. The payload goes to `SendInput`, which writes to this pane's connection + only. The Windows clipboard is never read, and input broadcast has no + opportunity to forward the load to another pane. + +### Multi-line and large loads + +| Condition | Result | +| --- | --- | +| Command text is empty | `CommandTextUnavailable` | +| Multi-line, bracketed paste **off** | `MultilineUnsafe` — refused | +| Multi-line, bracketed paste **on** | Loads literally | +| Longer than 1024 characters | `ConfirmationRequired` — Enter again to confirm | + +Refusing a multi-line load without bracketed paste is stricter than clipboard +paste, which only warns. The reasoning matches the upstream note on GH#13014: +without bracketing, embedded line breaks are indistinguishable from the user +pressing Enter, and the Timeline must never cause execution. + +A pending confirmation is cleared by Escape, by moving the selection, and by +closing the overlay, so a stale confirmation can never apply to a different +command. + +## Stable identity + +Every action resolves through `viewState.selectedCommandId`, a stable +pane-scoped `CommandId`, never through the XAML row index. The context menu +re-selects the slot and then acts on whatever `CommandId` that slot resolves +to at that moment. `ActionCopyResolvesStableCommandIdNotRowIndex` covers the +case where an eviction shifts every row index by one between preparing and +re-preparing a request. + +## Execution generation + +`CommandTimelineViewState` carries `loadedCommandId`, `loadedIntoInput`, and +`executionGeneration`. + +- `NotifyLoaded` records the loaded command and increments the generation. +- `NotifyExecutionStarted` fires on the OSC 133 `CommandStart` lifecycle event, + clears the loaded state, and increments the generation again. +- `IsCurrentGeneration(viewState, generation)` reports whether work that began + at an earlier generation is still relevant. + +`ResolveCommandTimelineOutput` checks the generation before reading the buffer, +so a completion belonging to a retired command cannot deliver output attributed +to the current one. + +`ReconcileLoadedInput` drops the loaded state when the loaded command has been +evicted, so loaded-input state never points at a removed command. + +## Output resolution + +Output is resolved only by an explicit copy-output action: + +`Terminal::ResolveCommandTimelineOutput(identity)` locates the mark by identity +via `FindCommandTimelineMarkExtents`, then reads +`GetPlainText(commandEnd, outputEnd)`. Nothing is stored. Both output actions +require a live native range; a stale range reports `OutputUnavailable`. + +The Timeline still holds only bounded command text (4096 characters per entry, +`DefaultMaxCachedCommandText`). There is no output field anywhere in the model, +and `test-command-timeline.ps1` fails the build if one appears. + +## Keyboard + +| Key | Behavior | +| --- | --- | +| Up / Down | Move selection by one entry | +| Left / Right | Select first / last entry on the current page | +| Enter | Load selected command; never executes | +| Space | Jump viewport to the selected command's output | +| Ctrl+C | Copy selected command text | +| Escape | Cancel a pending confirmation, otherwise close | + +`Ctrl+Tab` still toggles the Timeline, and user-defined key bindings keep +precedence over these bare keys because `_TryHandleKeyBinding` runs first. +Key-down and key-up are tracked in `_commandTimelineConsumedKeys` so a consumed +key never reaches the shell on release. + +## Validation + +- `scripts/winterm/test-command-timeline.ps1` — source, input, action, + accessibility, privacy, and lifecycle boundaries, including a guard that + fails if the load path gains `CarriageReturnNewline`, a carriage-return + append, or a clipboard read. +- `src/cascadia/UnitTests_Control/CommandTimelineTests.cpp` — the `Action*` + tests cover load preparation, missing command text, multi-line refusal, + large-load confirmation, generation tracking, late-completion detection, + eviction release, stable-identity resolution, and output availability. diff --git a/docs/user/command-timeline.md b/docs/user/command-timeline.md new file mode 100644 index 000000000..394a8d7ed --- /dev/null +++ b/docs/user/command-timeline.md @@ -0,0 +1,105 @@ +# Command Timeline + +The Command Timeline is a per-pane list of the commands that pane has run. Open +it with `Ctrl+Tab`, or with the small handle on the left edge of the terminal. +Use it to find an earlier command and put it back on your input line without +retyping it. + +The Timeline is an in-development feature. It ships in engineering checkpoint +builds; the current public release is v1.2.0. + +## What it shows + +Each row is one command, with a status glyph and a status word: + +| Status | Meaning | +| --- | --- | +| Running | The command is still running | +| Succeeded | The shell reported exit code 0 | +| Failed | The shell reported a non-zero exit code | +| Cancelled | The command was interrupted | +| Unknown | The shell did not report a trustworthy result | + +Status is never conveyed by color alone, and a result is only shown as +succeeded or failed when the shell actually reported it. If the shell's command +boundaries were incomplete, the row reads Unknown rather than guessing. + +## Shell requirements + +The Timeline reads command boundaries from OSC 133 shell integration. It never +guesses where a prompt or output begins. + +| Situation | What you see | +| --- | --- | +| Shell integration is working, no commands yet | `No commands yet` | +| Shell does not report complete command boundaries | `Command timeline unavailable` | + +PowerShell with winTerm shell integration reports full boundaries. `cmd.exe` +does not, and winTerm does not add a prompt parser for it, so the Timeline +stays unavailable there rather than showing untrustworthy results. + +## Keyboard + +| Key | Action | +| --- | --- | +| `Ctrl+Tab` | Toggle the Timeline for the focused pane | +| `Up` / `Down` | Move the selection by one command | +| `Left` / `Right` | Select the first / last command on the current page | +| `Enter` | Load the selected command onto the input line | +| `Space` | Scroll the terminal to that command's output | +| `Ctrl+C` | Copy the selected command text | +| `Escape` | Close the Timeline | + +Your own key bindings take precedence over these. If you have bound one of +these keys yourself, your binding wins. + +## Loading a command + +Press `Enter`, or click a row once, to put the selected command on the input +line. Hovering a row only moves the selection; it does not load anything. + +**A load never runs the command.** winTerm places the text on the input line +and stops there. You review it and press Enter yourself. No carriage return is +ever sent as part of the load. + +A load also does not use the Windows clipboard, and it only ever reaches the +pane you loaded it from — broadcast input does not forward it to other panes. + +Two cases are handled specially: + +- **Multi-line commands.** If your shell has bracketed paste enabled, the whole + command loads literally. If it does not, winTerm refuses the load and says + so, because without bracketing the embedded line breaks would be read as you + pressing Enter and the command would run. +- **Long commands.** Above 1024 characters, winTerm asks you to press `Enter` + once more before loading. `Escape` cancels that confirmation without closing + the Timeline. + +## Copying and jumping + +Right-click a row for: + +- **Copy command** — copies the command text. +- **Copy output** — reads that command's output from the terminal buffer at + that moment and copies it. +- **Jump to output** — scrolls the terminal to where that command's output + begins. + +Output is only ever read when you explicitly ask for it. winTerm does not keep +a copy of command output. + +## Privacy and limits + +- Command text lives only in memory, only for the pane that ran it, and only + while that pane is open. Closing the pane discards it. +- Nothing is written to disk. There is no command history file or database. +- Command output is never cached, indexed, or searched. +- Nothing is sent anywhere. The Timeline writes no telemetry and logs no + commands, output, or paths. +- The clipboard is only written when you explicitly choose a copy action, and + is never read. +- Each command's stored text is capped at 4096 characters. +- Each pane has its own independent Timeline. + +Commands scrolled out of the terminal's scrollback are dropped from the +Timeline too, so the list never outlives the buffer it describes. diff --git a/scripts/winterm/package-shell-assets.ps1 b/scripts/winterm/package-shell-assets.ps1 index 19468a6d3..803e18a94 100644 --- a/scripts/winterm/package-shell-assets.ps1 +++ b/scripts/winterm/package-shell-assets.ps1 @@ -32,9 +32,9 @@ foreach ($relativePath in $sourceAssets) } $version = Get-Content -LiteralPath (Join-Path $repositoryRoot 'shell\shared\version.json') -Raw | ConvertFrom-Json -if ($version.moduleVersion -ne '1.2.2' -or +if ($version.moduleVersion -ne '1.2.3' -or $version.modulePrerelease -ne '' -or - $version.applicationVersion -ne '1.2.2' -or + $version.applicationVersion -ne '1.2.3' -or $version.protocolVersion -ne 1) { throw 'The winTerm Shell asset version metadata is invalid.' diff --git a/scripts/winterm/test-command-timeline.ps1 b/scripts/winterm/test-command-timeline.ps1 index 81f1de0c9..e320ca2da 100644 --- a/scripts/winterm/test-command-timeline.ps1 +++ b/scripts/winterm/test-command-timeline.ps1 @@ -62,6 +62,23 @@ if ($modelHeader.Contains('commandOutput') -or $modelHeader.Contains('cachedOutp throw 'The visible Command Timeline model must never contain command output.' } +Assert-Contains -Content $modelHeader -Values @( + 'class CommandTimelineActionModel final', + 'enum class CommandActionKind', + 'CommandActionKind::LoadIntoInput', + 'struct CommandLoadPolicy', + 'NotifyExecutionStarted', + 'IsCurrentGeneration', + 'ReconcileLoadedInput' +) -Failure 'The pure Command Timeline entry-action model is incomplete.' +Assert-Contains -Content $modelSource -Values @( + 'CommandActionStatus::MultilineUnsafe', + 'CommandActionStatus::ConfirmationRequired', + 'CommandActionStatus::OutputUnavailable', + '++viewState.executionGeneration', + 'viewState.loadedCommandId.reset()' +) -Failure 'Command Timeline load, confirmation, or execution-generation semantics are incomplete.' + Assert-Contains -Content $controlCoreHeader -Values @( 'CommandTimelineNavigationModel _commandTimelineNavigation', 'OpenCommandTimeline', @@ -76,6 +93,46 @@ Assert-Contains -Content $controlCoreSource -Values @( '_commandTimelineNavigation.Close()' ) -Failure 'ControlCore does not project Phase 1 data incrementally or clean it up.' +Assert-Contains -Content $controlCoreHeader -Values @( + 'CommandTimelineActionModel _commandTimelineActions', + 'PrepareCommandTimelineAction', + 'LoadCommandTimelineCommand', + 'ResolveCommandTimelineOutput', + 'JumpToCommandTimelineOutput', + 'CopyCommandTimelineText' +) -Failure 'ControlCore does not expose the pane-local Timeline entry actions.' +Assert-Contains -Content $controlCoreSource -Values @( + 'FilterStringForPaste(request.commandText, ControlCodes)', + '_commandTimelineActions.NotifyLoaded', + '_commandTimelineActions.NotifyExecutionStarted', + '_commandTimelineActions.ReconcileLoadedInput', + '_terminal->ResolveCommandTimelineOutput(request.nativeMarkId)' +) -Failure 'Timeline load, generation tracking, or on-demand output resolution is incomplete.' + +# A Timeline load must never submit the command. Requesting the +# CarriageReturnNewline paste filter, or appending a carriage return to the +# payload, would turn a load into an execution. +$loadStart = $controlCoreSource.IndexOf('bool ControlCore::LoadCommandTimelineCommand', [StringComparison]::Ordinal) +if ($loadStart -lt 0) { + throw 'ControlCore::LoadCommandTimelineCommand is missing.' +} +$loadEnd = $controlCoreSource.IndexOf("`n }", $loadStart, [StringComparison]::Ordinal) +$loadBody = $controlCoreSource.Substring($loadStart, $loadEnd - $loadStart) +# Compare against code only. The body documents why these constructs are +# excluded, so the prose must not trip the guard. +$loadCode = [regex]::Replace($loadBody, '//.*', '') +foreach ($forbidden in @('CarriageReturnNewline', 'append(L"\r")', "append(L'\r')", 'L"\r\n"')) { + if ($loadCode.Contains($forbidden)) { + throw "The Timeline load path must never submit the command. Found '$forbidden'." + } +} +if ($loadCode.Contains('clipboard::') -or $loadCode.Contains('Clipboard::GetContent')) { + throw 'The Timeline load path must never read the Windows clipboard.' +} +if (-not $loadCode.Contains('SendInput(payload)')) { + throw 'The Timeline load must target this pane connection directly.' +} + $overlayStart = $termControlXaml.IndexOf('$null).Trim() - Assert-Condition ($LASTEXITCODE -eq 0 -and $tag -eq $version.tag) 'Checked-out commit is exactly tagged v1.2.2' + Assert-Condition ($LASTEXITCODE -eq 0 -and $tag -eq $version.tag) 'Checked-out commit is exactly tagged v1.2.3' } Write-Host 'winTerm version consistency verification passed.' -ForegroundColor Green diff --git a/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 b/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 index ee8b86373..72cb6335e 100644 --- a/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 +++ b/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'winTerm.Shell.psm1' - ModuleVersion = '1.2.2' + ModuleVersion = '1.2.3' GUID = 'f65cd8f4-5d25-4a2a-a0d4-58df1ab3dc5a' Author = 'winTerm contributors' CompanyName = 'winTerm' diff --git a/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 b/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 index 1eeac02d3..3aa544d60 100644 --- a/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 +++ b/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 @@ -3,7 +3,7 @@ Set-StrictMode -Version Latest -$script:WinTermModuleVersion = '1.2.2' +$script:WinTermModuleVersion = '1.2.3' $script:WinTermProtocolVersion = 1 $script:WinTermIntegrationEnabled = $false $script:WinTermPromptWrapped = $false diff --git a/shell/shared/version.json b/shell/shared/version.json index 3795291dd..fd6e1946a 100644 --- a/shell/shared/version.json +++ b/shell/shared/version.json @@ -1,6 +1,6 @@ { - "applicationVersion": "1.2.2", - "moduleVersion": "1.2.2", + "applicationVersion": "1.2.3", + "moduleVersion": "1.2.3", "modulePrerelease": "", "protocolVersion": 1 } diff --git a/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest b/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest index c2f16e6e2..640a6cc99 100644 --- a/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest +++ b/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest @@ -18,7 +18,7 @@ + Version="1.2.3.0" /> winTerm diff --git a/src/cascadia/TerminalControl/ControlCore.cpp b/src/cascadia/TerminalControl/ControlCore.cpp index fc32cea4d..1a0e34ab4 100644 --- a/src/cascadia/TerminalControl/ControlCore.cpp +++ b/src/cascadia/TerminalControl/ControlCore.cpp @@ -1732,6 +1732,125 @@ namespace winrt::Microsoft::Terminal::Control::implementation _commandTimelineNavigation.Close(); } + winTerm::CommandTimeline::CommandActionRequest ControlCore::PrepareCommandTimelineAction( + const winTerm::CommandTimeline::CommandActionKind kind) + { + const auto bracketedPaste = BracketedPasteEnabled(); + + const auto lock = _terminal->LockForWriting(); + _ensureCommandTimelineBootstrap(); + _commandTimelineActions.ReconcileLoadedInput(_commandTimelineIndex->Entries(), _commandTimelineViewState); + + const winTerm::CommandTimeline::CommandLoadPolicy policy{ + .largeLoadCharacterThreshold = 1024, + .bracketedPasteEnabled = bracketedPaste, + .warnOnMultilineLoad = false, + .warnOnLargeLoad = true, + }; + return _commandTimelineActions.Prepare(kind, + _commandTimelineIndex->Entries(), + _commandTimelineViewState, + policy); + } + + // Places a Timeline command on the pane's input line. This never reads the + // Windows clipboard, never appends a carriage return, and only ever targets + // this pane's connection, so it cannot execute the command and cannot be + // redirected by input broadcast. + bool ControlCore::LoadCommandTimelineCommand(const winTerm::CommandTimeline::CommandActionRequest& request) + { + using namespace ::Microsoft::Console::Utils; + + if (request.kind != winTerm::CommandTimeline::CommandActionKind::LoadIntoInput || + !request.Actionable() || + request.commandText.empty()) + { + return false; + } + + // Only control codes are stripped. CarriageReturnNewline is deliberately + // not requested: converting a newline to a carriage return would submit + // the command instead of loading it. + auto payload = FilterStringForPaste(request.commandText, ControlCodes); + if (payload.empty()) + { + return false; + } + + if (BracketedPasteEnabled()) + { + payload.insert(0, L"\x1b[200~"); + payload.append(L"\x1b[201~"); + } + + // The connection may block, so the terminal lock must not be held here. + SendInput(payload); + + const auto lock = _terminal->LockForWriting(); + _commandTimelineActions.NotifyLoaded(_commandTimelineViewState, request); + _terminal->TrySnapOnInput(); + return true; + } + + // Resolves one command's output straight from the terminal buffer. Nothing + // is cached: the Timeline only ever holds command text. + std::wstring ControlCore::ResolveCommandTimelineOutput(const winTerm::CommandTimeline::CommandActionRequest& request) + { + if (request.kind != winTerm::CommandTimeline::CommandActionKind::CopyOutput || !request.Actionable()) + { + return {}; + } + + const auto lock = _terminal->LockForWriting(); + if (!winTerm::CommandTimeline::CommandTimelineActionModel::IsCurrentGeneration(_commandTimelineViewState, + request.executionGeneration)) + { + return {}; + } + return _terminal->ResolveCommandTimelineOutput(request.nativeMarkId); + } + + bool ControlCore::JumpToCommandTimelineOutput(const winTerm::CommandTimeline::CommandActionRequest& request) + { + if (request.kind != winTerm::CommandTimeline::CommandActionKind::JumpToOutput || !request.Actionable()) + { + return false; + } + + const auto viewHeight = ViewHeight(); + const auto bufferSize = BufferHeight(); + til::CoordType target{}; + { + const auto lock = _terminal->LockForWriting(); + const auto mark = _terminal->FindCommandTimelineMarkExtents(request.nativeMarkId); + if (!mark.has_value()) + { + return false; + } + target = til::coalesce_value(mark->commandEnd, mark->end).y; + UserScrollViewport(target); + } + + _terminalScrollPositionChanged(target, viewHeight, bufferSize); + return true; + } + + // The only path that lets the Command Timeline reach the clipboard, and it + // is reached only from an explicit copy action. Plain text only: the + // Timeline never produces HTML or RTF payloads. + bool ControlCore::CopyCommandTimelineText(const std::wstring& text) + { + if (text.empty()) + { + return false; + } + + WriteToClipboard.raise( + *this, + winrt::make(winrt::hstring{ text }, std::string{}, std::string{})); + return true; + } + int ControlCore::ScrollOffset() { const auto lock = _terminal->LockForReading(); @@ -1871,6 +1990,15 @@ namespace winrt::Microsoft::Terminal::Control::implementation .trustedExitCode = trustedExitCode, .timestamp = std::chrono::system_clock::now(), }); + + // The pane began running a command, so anything the Timeline had + // placed on the input line is now consumed. Advancing the execution + // generation here is what lets a completion that belongs to the + // previous command be recognized as late and discarded. + if (kind == winTerm::CommandTimeline::LifecycleEventKind::CommandStart) + { + _commandTimelineActions.NotifyExecutionStarted(_commandTimelineViewState); + } } if (_commandTimelineIndex->Revision() != timelineRevisionBefore) diff --git a/src/cascadia/TerminalControl/ControlCore.h b/src/cascadia/TerminalControl/ControlCore.h index ed3aeb4e3..b4fb58dd4 100644 --- a/src/cascadia/TerminalControl/ControlCore.h +++ b/src/cascadia/TerminalControl/ControlCore.h @@ -183,6 +183,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation winTerm::CommandTimeline::CommandTimelinePresentationSnapshot ScrollCommandTimeline(int wheelDelta); void SettleCommandTimelineWheel(); void CloseCommandTimelineOverlay(); + winTerm::CommandTimeline::CommandActionRequest PrepareCommandTimelineAction(winTerm::CommandTimeline::CommandActionKind kind); + bool LoadCommandTimelineCommand(const winTerm::CommandTimeline::CommandActionRequest& request); + std::wstring ResolveCommandTimelineOutput(const winTerm::CommandTimeline::CommandActionRequest& request); + bool JumpToCommandTimelineOutput(const winTerm::CommandTimeline::CommandActionRequest& request); + bool CopyCommandTimelineText(const std::wstring& text); hstring Title(); Windows::Foundation::IReference TabColor() noexcept; @@ -457,6 +462,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation std::unique_ptr _visualProgressRecognition; std::unique_ptr _commandTimelineIndex; winTerm::CommandTimeline::CommandTimelineNavigationModel _commandTimelineNavigation; + winTerm::CommandTimeline::CommandTimelineActionModel _commandTimelineActions; winTerm::CommandTimeline::CommandTimelineViewState _commandTimelineViewState; std::atomic _visualProgressProviderState{}; std::atomic _visualProgressProviderGeneration{}; diff --git a/src/cascadia/TerminalControl/Resources/en-US/Resources.resw b/src/cascadia/TerminalControl/Resources/en-US/Resources.resw index 1915d0b98..141414d3d 100644 --- a/src/cascadia/TerminalControl/Resources/en-US/Resources.resw +++ b/src/cascadia/TerminalControl/Resources/en-US/Resources.resw @@ -376,4 +376,22 @@ Unknown + + Copy command + + + Copy output + + + Jump to output + + + Output is no longer available + + + This shell cannot load a multi-line command safely + + + Press Enter again to load this command + diff --git a/src/cascadia/TerminalControl/TermControl.cpp b/src/cascadia/TerminalControl/TermControl.cpp index c03eaa748..53ae239e3 100644 --- a/src/cascadia/TerminalControl/TermControl.cpp +++ b/src/cascadia/TerminalControl/TermControl.cpp @@ -2234,34 +2234,46 @@ namespace winrt::Microsoft::Terminal::Control::implementation return true; } - if (!_commandTimelineOpen || - modifiers.IsCtrlPressed() || modifiers.IsAltPressed() || - modifiers.IsShiftPressed() || modifiers.IsWinPressed()) + if (!_commandTimelineOpen || modifiers.IsAltPressed() || modifiers.IsWinPressed()) { return false; } - std::optional action; - switch (vkey) + // Ctrl+C copies the selected command while the Timeline owns the + // keyboard. Every other modifier combination is left to the existing + // key binding and PTY paths. + const auto copyCommandChord = modifiers.IsCtrlPressed() && !modifiers.IsShiftPressed() && vkey == 'C'; + if (!copyCommandChord && (modifiers.IsCtrlPressed() || modifiers.IsShiftPressed())) { - case VK_UP: - action = winTerm::CommandTimeline::NavigationAction::Previous; - break; - case VK_DOWN: - action = winTerm::CommandTimeline::NavigationAction::Next; - break; - case VK_LEFT: - action = winTerm::CommandTimeline::NavigationAction::PageFirst; - break; - case VK_RIGHT: - action = winTerm::CommandTimeline::NavigationAction::PageLast; - break; - case VK_ESCAPE: - break; - default: return false; } + std::optional action; + if (!copyCommandChord) + { + switch (vkey) + { + case VK_UP: + action = winTerm::CommandTimeline::NavigationAction::Previous; + break; + case VK_DOWN: + action = winTerm::CommandTimeline::NavigationAction::Next; + break; + case VK_LEFT: + action = winTerm::CommandTimeline::NavigationAction::PageFirst; + break; + case VK_RIGHT: + action = winTerm::CommandTimeline::NavigationAction::PageLast; + break; + case VK_ESCAPE: + case VK_RETURN: + case VK_SPACE: + break; + default: + return false; + } + } + if (vkey < _commandTimelineConsumedKeys.size()) { _commandTimelineConsumedKeys[vkey] = true; @@ -2271,14 +2283,40 @@ namespace winrt::Microsoft::Terminal::Control::implementation return true; } - if (vkey == VK_ESCAPE) + if (copyCommandChord) + { + _copyCommandTimelineCommand(); + return true; + } + + switch (vkey) { + case VK_ESCAPE: + // A pending load confirmation is cancelled before the overlay + // itself closes, so Escape never discards more than one step. + if (_commandTimelinePendingLoad.has_value()) + { + _clearCommandTimelinePendingLoad(); + _setCommandTimelineStatus({}); + return true; + } _closeCommandTimeline(true); return true; + case VK_RETURN: + _loadCommandTimelineSelection(); + return true; + case VK_SPACE: + _jumpToCommandTimelineOutput(); + return true; + default: + break; } try { + // Moving the selection invalidates any confirmation that was still + // waiting on the previously selected command. + _clearCommandTimelinePendingLoad(); const auto presentation = get_self(_core)->NavigateCommandTimeline(*action); _renderCommandTimeline(presentation); } @@ -2290,6 +2328,255 @@ namespace winrt::Microsoft::Terminal::Control::implementation return true; } + // Places the selected command on this pane's input line. The Timeline never + // executes it: no carriage return is ever appended, the Windows clipboard is + // never read, and only this pane's connection is targeted, so input + // broadcast cannot forward the load to another pane. + bool TermControl::_loadCommandTimelineSelection() + { + if (!_commandTimelineOpen || _IsClosing()) + { + return false; + } + + using namespace winTerm::CommandTimeline; + + try + { + auto core = get_self(_core); + auto request = _commandTimelinePendingLoad.has_value() ? + CommandTimelineActionModel::Confirm(*_commandTimelinePendingLoad) : + core->PrepareCommandTimelineAction(CommandActionKind::LoadIntoInput); + _clearCommandTimelinePendingLoad(); + + switch (request.status) + { + case CommandActionStatus::Ready: + break; + case CommandActionStatus::ConfirmationRequired: + // Hold the prepared request so the next Enter confirms it. + _commandTimelinePendingLoad = request; + _setCommandTimelineStatus(RS_(L"CommandTimelineConfirmLoad")); + return false; + case CommandActionStatus::MultilineUnsafe: + _setCommandTimelineStatus(RS_(L"CommandTimelineMultilineBlocked")); + return false; + case CommandActionStatus::CommandTextUnavailable: + _setCommandTimelineStatus(RS_(L"CommandTimelineCommandUnavailable")); + return false; + default: + return false; + } + + if (!core->LoadCommandTimelineCommand(request)) + { + _setCommandTimelineStatus(RS_(L"CommandTimelineCommandUnavailable")); + return false; + } + + _closeCommandTimeline(true); + return true; + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _closeCommandTimeline(true); + return false; + } + } + + bool TermControl::_jumpToCommandTimelineOutput() + { + if (!_commandTimelineOpen || _IsClosing()) + { + return false; + } + + using namespace winTerm::CommandTimeline; + + try + { + auto core = get_self(_core); + const auto request = core->PrepareCommandTimelineAction(CommandActionKind::JumpToOutput); + if (!request.Actionable() || !core->JumpToCommandTimelineOutput(request)) + { + _setCommandTimelineStatus(RS_(L"CommandTimelineOutputUnavailable")); + return false; + } + _setCommandTimelineStatus({}); + return true; + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _closeCommandTimeline(true); + return false; + } + } + + bool TermControl::_copyCommandTimelineCommand() + { + if (!_commandTimelineOpen || _IsClosing()) + { + return false; + } + + using namespace winTerm::CommandTimeline; + + try + { + auto core = get_self(_core); + const auto request = core->PrepareCommandTimelineAction(CommandActionKind::CopyCommand); + if (!request.Actionable() || !core->CopyCommandTimelineText(request.commandText)) + { + _setCommandTimelineStatus(RS_(L"CommandTimelineCommandUnavailable")); + return false; + } + _setCommandTimelineStatus({}); + return true; + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _closeCommandTimeline(true); + return false; + } + } + + // Output is resolved from the terminal buffer only at this point, for this + // one command. Nothing about the output is retained afterwards. + bool TermControl::_copyCommandTimelineOutput() + { + if (!_commandTimelineOpen || _IsClosing()) + { + return false; + } + + using namespace winTerm::CommandTimeline; + + try + { + auto core = get_self(_core); + const auto request = core->PrepareCommandTimelineAction(CommandActionKind::CopyOutput); + if (!request.Actionable()) + { + _setCommandTimelineStatus(RS_(L"CommandTimelineOutputUnavailable")); + return false; + } + + const auto output = core->ResolveCommandTimelineOutput(request); + if (output.empty() || !core->CopyCommandTimelineText(output)) + { + _setCommandTimelineStatus(RS_(L"CommandTimelineOutputUnavailable")); + return false; + } + _setCommandTimelineStatus({}); + return true; + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _closeCommandTimeline(true); + return false; + } + } + + void TermControl::_setCommandTimelineStatus(const winrt::hstring& status) + { + if (_IsClosing()) + { + return; + } + + const auto text = CommandTimelineStatusText(); + text.Text(status); + text.Visibility(status.empty() ? Visibility::Collapsed : Visibility::Visible); + } + + void TermControl::_clearCommandTimelinePendingLoad() + { + _commandTimelinePendingLoad.reset(); + } + + // Builds the per-entry context menu. Every action resolves through the + // model's stable CommandId rather than the XAML row, so a list rebuild + // between the right-click and the invocation cannot retarget the action. + void TermControl::_CommandTimelineItemContextRequested(const Windows::UI::Xaml::UIElement& sender, + const Input::ContextRequestedEventArgs& args) + { + if (_IsClosing() || !_commandTimelineOpen) + { + return; + } + + const auto list = CommandTimelineList(); + uint32_t slot{}; + if (!list.Items().IndexOf(sender, slot)) + { + return; + } + + try + { + _clearCommandTimelinePendingLoad(); + const auto updated = get_self(_core)->SelectCommandTimelineVisibleEntry(slot); + _renderCommandTimeline(updated); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _closeCommandTimeline(true); + return; + } + + // _renderCommandTimeline rebuilds the rows, so the flyout must attach to + // the element that now occupies this slot. + if (slot >= list.Items().Size()) + { + return; + } + const auto target = list.Items().GetAt(slot).try_as(); + if (!target) + { + return; + } + + Controls::MenuFlyout flyout; + + Controls::MenuFlyoutItem copyCommand; + copyCommand.Text(RS_(L"CommandTimelineCopyCommand")); + copyCommand.Click([weakThis = get_weak()](const auto&, const auto&) { + if (auto control{ weakThis.get() }) + { + control->_copyCommandTimelineCommand(); + } + }); + flyout.Items().Append(copyCommand); + + Controls::MenuFlyoutItem copyOutput; + copyOutput.Text(RS_(L"CommandTimelineCopyOutput")); + copyOutput.Click([weakThis = get_weak()](const auto&, const auto&) { + if (auto control{ weakThis.get() }) + { + control->_copyCommandTimelineOutput(); + } + }); + flyout.Items().Append(copyOutput); + + Controls::MenuFlyoutItem jumpToOutput; + jumpToOutput.Text(RS_(L"CommandTimelineJumpToOutput")); + jumpToOutput.Click([weakThis = get_weak()](const auto&, const auto&) { + if (auto control{ weakThis.get() }) + { + control->_jumpToCommandTimelineOutput(); + } + }); + flyout.Items().Append(jumpToOutput); + + flyout.ShowAt(target); + args.Handled(true); + } + bool TermControl::_tryHandleCommandTimelineWheel(const Windows::Foundation::Point& position, const int delta) { @@ -2521,6 +2808,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation { try { + control->_clearCommandTimelinePendingLoad(); const auto updated = get_self(control->_core)->SelectCommandTimelineVisibleEntry(slot); control->_renderCommandTimeline(updated); } @@ -2531,6 +2819,29 @@ namespace winrt::Microsoft::Terminal::Control::implementation } } }); + + // A single click loads the command onto the input line. Hover above + // only moves the selection, so pointing at an entry never loads it. + item.Tapped([weakThis = get_weak(), slot](const auto&, const auto& tapped) { + if (auto control{ weakThis.get() }; + control && !control->_IsClosing() && control->_commandTimelineOpen) + { + try + { + const auto updated = get_self(control->_core)->SelectCommandTimelineVisibleEntry(slot); + control->_renderCommandTimeline(updated); + control->_loadCommandTimelineSelection(); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + control->_closeCommandTimeline(true); + } + } + tapped.Handled(true); + }); + + item.ContextRequested({ get_weak(), &TermControl::_CommandTimelineItemContextRequested }); list.Items().Append(item); } @@ -2563,11 +2874,14 @@ namespace winrt::Microsoft::Terminal::Control::implementation } _commandTimelineOpen = false; + _clearCommandTimelinePendingLoad(); _updatingCommandTimelineSelection = true; CommandTimelineList().Items().Clear(); CommandTimelineList().SelectedIndex(-1); _updatingCommandTimelineSelection = false; CommandTimelineEmptyText().Visibility(Visibility::Collapsed); + CommandTimelineStatusText().Text({}); + CommandTimelineStatusText().Visibility(Visibility::Collapsed); CommandTimelineOverlay().Visibility(Visibility::Collapsed); Windows::UI::Xaml::Automation::AutomationProperties::SetName(CommandTimelineHandle(), RS_(L"CommandTimelineOpen")); Controls::ToolTipService::SetToolTip(CommandTimelineHandle(), box_value(RS_(L"CommandTimelineOpen"))); diff --git a/src/cascadia/TerminalControl/TermControl.h b/src/cascadia/TerminalControl/TermControl.h index b75eff1c9..e1a95de00 100644 --- a/src/cascadia/TerminalControl/TermControl.h +++ b/src/cascadia/TerminalControl/TermControl.h @@ -323,6 +323,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation bool _commandTimelineOpen{ false }; bool _updatingCommandTimelineSelection{ false }; std::array _commandTimelineConsumedKeys{}; + std::optional _commandTimelinePendingLoad; winrt::Windows::UI::Composition::ScalarKeyFrameAnimation _bellLightAnimation{ nullptr }; winrt::Windows::UI::Composition::ScalarKeyFrameAnimation _bellDarkAnimation{ nullptr }; @@ -393,7 +394,15 @@ namespace winrt::Microsoft::Terminal::Control::implementation void _CommandTimelineWheelSettled(const Windows::Foundation::IInspectable& sender, const Windows::Foundation::IInspectable& e); void _ScrollbarChangeHandler(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Controls::Primitives::RangeBaseValueChangedEventArgs& e); + void _CommandTimelineItemContextRequested(const Windows::UI::Xaml::UIElement& sender, const Windows::UI::Xaml::Input::ContextRequestedEventArgs& e); + bool _tryHandleCommandTimelineKey(WORD vkey, ::Microsoft::Terminal::Core::ControlKeyStates modifiers, bool keyDown); + bool _loadCommandTimelineSelection(); + bool _jumpToCommandTimelineOutput(); + bool _copyCommandTimelineCommand(); + bool _copyCommandTimelineOutput(); + void _setCommandTimelineStatus(const winrt::hstring& status); + void _clearCommandTimelinePendingLoad(); bool _tryHandleCommandTimelineWheel(const Windows::Foundation::Point& position, int delta); bool _isPointOverCommandTimeline(const Windows::Foundation::Point& position) noexcept; size_t _commandTimelineVisibleCapacity() noexcept; diff --git a/src/cascadia/TerminalControl/TermControl.xaml b/src/cascadia/TerminalControl/TermControl.xaml index 0fef93263..d13148a86 100644 --- a/src/cascadia/TerminalControl/TermControl.xaml +++ b/src/cascadia/TerminalControl/TermControl.xaml @@ -1380,6 +1380,7 @@ + + + diff --git a/src/cascadia/TerminalCore/Terminal.cpp b/src/cascadia/TerminalCore/Terminal.cpp index 0ab172bab..13aee09d2 100644 --- a/src/cascadia/TerminalCore/Terminal.cpp +++ b/src/cascadia/TerminalCore/Terminal.cpp @@ -1570,6 +1570,41 @@ std::wstring Terminal::CurrentCommandTimelineCommand() const return _inAltBuffer() ? std::wstring{} : _mainBuffer->CurrentCommandTimelineCommand(); } +// Resolves a Command Timeline native mark identity back to its current buffer +// extents. Identities are already assigned while the Timeline index is built, +// so this only re-reads the existing assignment and never invents a range. +std::optional Terminal::FindCommandTimelineMarkExtents(const uint64_t identity) +{ + if (_inAltBuffer() || identity == 0) + { + return std::nullopt; + } + + for (const auto& mark : _mainBuffer->GetMarkExtents()) + { + if (_mainBuffer->EnsureCommandTimelineMarkIdentity(mark.start.y) == identity) + { + return mark; + } + } + return std::nullopt; +} + +// Reads the output of a single Command Timeline entry directly out of the +// terminal buffer. The Timeline keeps no output cache, so this is only called +// when the user explicitly asks for the output of one command. +std::wstring Terminal::ResolveCommandTimelineOutput(const uint64_t identity) +{ + const auto mark = FindCommandTimelineMarkExtents(identity); + if (!mark.has_value() || !mark->HasOutput()) + { + return {}; + } + + const auto start = til::coalesce_value(mark->commandEnd, mark->end); + return _mainBuffer->GetPlainText(start, *mark->outputEnd); +} + std::vector Terminal::ConsumeInvalidatedCommandTimelineMarkIdentities() { return _mainBuffer->ConsumeInvalidatedCommandTimelineMarkIdentities(); diff --git a/src/cascadia/TerminalCore/Terminal.hpp b/src/cascadia/TerminalCore/Terminal.hpp index b1ab1386d..904c6b482 100644 --- a/src/cascadia/TerminalCore/Terminal.hpp +++ b/src/cascadia/TerminalCore/Terminal.hpp @@ -142,6 +142,8 @@ class Microsoft::Terminal::Core::Terminal final : uint64_t GetCurrentCommandTimelineMarkIdentity() const noexcept; uint64_t GetCommandTimelineMarkRevision() const noexcept; std::wstring CurrentCommandTimelineCommand() const; + std::optional FindCommandTimelineMarkExtents(uint64_t identity); + std::wstring ResolveCommandTimelineOutput(uint64_t identity); std::vector ConsumeInvalidatedCommandTimelineMarkIdentities(); std::optional> ConsumeReflowCommandTimelineMarkIdentities(); diff --git a/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp b/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp index 934821679..7ad2a0447 100644 --- a/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp +++ b/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp @@ -40,6 +40,15 @@ namespace ControlUnitTests TEST_METHOD(NavigationReconcilesRemovalReflowAndNewCommands); TEST_METHOD(NavigationStateIsPaneLocalAndRepeatedCloseIsSafe); TEST_METHOD(NavigationVisibleProjectionIsBoundedAndNeutralizesLimitedResults); + TEST_METHOD(ActionLoadPreparesSelectedCommandWithoutExecuting); + TEST_METHOD(ActionLoadRejectsMissingCommandText); + TEST_METHOD(ActionLoadRefusesMultilineWithoutBracketedPaste); + TEST_METHOD(ActionLoadConfirmsLargeCommandBeforeReady); + TEST_METHOD(ActionLoadTracksExecutionGeneration); + TEST_METHOD(ActionLateCompletionIsDetectedAfterExecutionStart); + TEST_METHOD(ActionLoadedInputIsReleasedOnEviction); + TEST_METHOD(ActionCopyResolvesStableCommandIdNotRowIndex); + TEST_METHOD(ActionOutputRequiresLiveNativeRangeAndIsNeverCached); TEST_CLASS_SETUP(ModuleSetup) { @@ -661,4 +670,268 @@ namespace ControlUnitTests static_cast(presentation.visibleEntries[1].executionResult)); VERIFY_ARE_EQUAL(uint64_t{ 10 }, presentation.visibleEntries[1].id.sequence); } + + void CommandTimelineTests::ActionLoadPreparesSelectedCommandWithoutExecuting() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"git status", 0); + Execute(index, 2, revision, L"cargo build", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 4); + navigation.Navigate(NavigationAction::Previous, index.Entries(), viewState, index.Capability()); + + CommandTimelineActionModel actions; + const auto request = actions.Prepare(CommandActionKind::LoadIntoInput, + index.Entries(), + viewState, + CommandLoadPolicy{}); + + VERIFY_ARE_EQUAL(static_cast(CommandActionStatus::Ready), static_cast(request.status)); + VERIFY_IS_TRUE(request.Actionable()); + VERIFY_ARE_EQUAL(std::wstring{ L"git status" }, request.commandText); + VERIFY_ARE_EQUAL(uint64_t{ 1 }, request.id.sequence); + // The prepared payload must never carry a submission character. + VERIFY_ARE_EQUAL(std::wstring::npos, request.commandText.find_first_of(L"\r\n")); + VERIFY_IS_FALSE(request.multiline); + } + + void CommandTimelineTests::ActionLoadRejectsMissingCommandText() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + index.ProcessLifecycle(Update(LifecycleEventKind::Prompt, 1, ++revision)); + index.ProcessLifecycle(Update(LifecycleEventKind::CommandStart, 1, ++revision)); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 4); + + CommandTimelineActionModel actions; + const auto load = actions.Prepare(CommandActionKind::LoadIntoInput, index.Entries(), viewState, CommandLoadPolicy{}); + VERIFY_ARE_EQUAL(static_cast(CommandActionStatus::CommandTextUnavailable), static_cast(load.status)); + VERIFY_IS_FALSE(load.Actionable()); + + const auto copy = actions.Prepare(CommandActionKind::CopyCommand, index.Entries(), viewState, CommandLoadPolicy{}); + VERIFY_ARE_EQUAL(static_cast(CommandActionStatus::CommandTextUnavailable), static_cast(copy.status)); + + // An empty timeline cannot produce an actionable request at all. + CommandTimelineViewState emptyView; + const auto none = actions.Prepare(CommandActionKind::LoadIntoInput, {}, emptyView, CommandLoadPolicy{}); + VERIFY_ARE_EQUAL(static_cast(CommandActionStatus::NoSelection), static_cast(none.status)); + } + + void CommandTimelineTests::ActionLoadRefusesMultilineWithoutBracketedPaste() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"for i in 1 2 3\ndo echo $i\ndone", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 4); + + CommandTimelineActionModel actions; + VERIFY_IS_TRUE(CommandTimelineActionModel::IsMultiline(L"a\nb")); + VERIFY_IS_FALSE(CommandTimelineActionModel::IsMultiline(L"a b")); + + const CommandLoadPolicy unbracketed{ .bracketedPasteEnabled = false }; + const auto refused = actions.Prepare(CommandActionKind::LoadIntoInput, index.Entries(), viewState, unbracketed); + VERIFY_ARE_EQUAL(static_cast(CommandActionStatus::MultilineUnsafe), static_cast(refused.status)); + VERIFY_IS_FALSE(refused.Actionable()); + // A refused request must not become actionable by confirming it. + VERIFY_IS_FALSE(CommandTimelineActionModel::Confirm(refused).Actionable()); + + const CommandLoadPolicy bracketed{ .bracketedPasteEnabled = true, .warnOnMultilineLoad = false }; + const auto allowed = actions.Prepare(CommandActionKind::LoadIntoInput, index.Entries(), viewState, bracketed); + VERIFY_ARE_EQUAL(static_cast(CommandActionStatus::Ready), static_cast(allowed.status)); + VERIFY_IS_TRUE(allowed.multiline); + } + + void CommandTimelineTests::ActionLoadConfirmsLargeCommandBeforeReady() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, std::wstring(2000, L'x'), 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 4); + + CommandTimelineActionModel actions; + const CommandLoadPolicy policy{ .largeLoadCharacterThreshold = 1024, .bracketedPasteEnabled = true }; + const auto pending = actions.Prepare(CommandActionKind::LoadIntoInput, index.Entries(), viewState, policy); + VERIFY_ARE_EQUAL(static_cast(CommandActionStatus::ConfirmationRequired), static_cast(pending.status)); + VERIFY_IS_FALSE(pending.Actionable()); + VERIFY_ARE_EQUAL(size_t{ 2000 }, pending.characterCount); + + const auto confirmed = CommandTimelineActionModel::Confirm(pending); + VERIFY_IS_TRUE(confirmed.Actionable()); + VERIFY_ARE_EQUAL(confirmed.id, pending.id); + + // Below the threshold no confirmation is asked for. + const CommandLoadPolicy generous{ .largeLoadCharacterThreshold = 4096, .bracketedPasteEnabled = true }; + VERIFY_IS_TRUE(actions.Prepare(CommandActionKind::LoadIntoInput, index.Entries(), viewState, generous).Actionable()); + } + + void CommandTimelineTests::ActionLoadTracksExecutionGeneration() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"echo one", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 4); + + CommandTimelineActionModel actions; + VERIFY_IS_FALSE(viewState.loadedIntoInput); + VERIFY_ARE_EQUAL(uint64_t{ 0 }, viewState.executionGeneration); + + const auto request = actions.Prepare(CommandActionKind::LoadIntoInput, index.Entries(), viewState, CommandLoadPolicy{}); + actions.NotifyLoaded(viewState, request); + + VERIFY_IS_TRUE(viewState.loadedIntoInput); + VERIFY_IS_TRUE(viewState.loadedCommandId.has_value()); + VERIFY_ARE_EQUAL(request.id, *viewState.loadedCommandId); + VERIFY_ARE_EQUAL(uint64_t{ 1 }, viewState.executionGeneration); + + // A non-load request must never mark the input as loaded. + CommandTimelineViewState other = viewState; + const auto copy = actions.Prepare(CommandActionKind::CopyCommand, index.Entries(), viewState, CommandLoadPolicy{}); + actions.NotifyLoaded(other, copy); + VERIFY_ARE_EQUAL(viewState.executionGeneration, other.executionGeneration); + } + + void CommandTimelineTests::ActionLateCompletionIsDetectedAfterExecutionStart() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"sleep 30", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 4); + + CommandTimelineActionModel actions; + const auto request = actions.Prepare(CommandActionKind::LoadIntoInput, index.Entries(), viewState, CommandLoadPolicy{}); + actions.NotifyLoaded(viewState, request); + + const auto inFlightGeneration = viewState.executionGeneration; + VERIFY_IS_TRUE(CommandTimelineActionModel::IsCurrentGeneration(viewState, inFlightGeneration)); + + // The pane starts running a command; the earlier generation is retired. + actions.NotifyExecutionStarted(viewState); + VERIFY_IS_FALSE(CommandTimelineActionModel::IsCurrentGeneration(viewState, inFlightGeneration)); + VERIFY_IS_TRUE(CommandTimelineActionModel::IsCurrentGeneration(viewState, viewState.executionGeneration)); + VERIFY_IS_FALSE(viewState.loadedIntoInput); + VERIFY_IS_FALSE(viewState.loadedCommandId.has_value()); + } + + void CommandTimelineTests::ActionLoadedInputIsReleasedOnEviction() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"first", 0); + Execute(index, 2, revision, L"second", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 4); + navigation.Navigate(NavigationAction::Previous, index.Entries(), viewState, index.Capability()); + + CommandTimelineActionModel actions; + const auto request = actions.Prepare(CommandActionKind::LoadIntoInput, index.Entries(), viewState, CommandLoadPolicy{}); + actions.NotifyLoaded(viewState, request); + VERIFY_IS_TRUE(viewState.loadedIntoInput); + + // Still present after an unrelated reconcile. + actions.ReconcileLoadedInput(index.Entries(), viewState); + VERIFY_IS_TRUE(viewState.loadedIntoInput); + + const std::array evicted{ 1 }; + index.InvalidateNativeMarks(evicted, ++revision); + actions.ReconcileLoadedInput(index.Entries(), viewState); + VERIFY_IS_FALSE(viewState.loadedIntoInput); + VERIFY_IS_FALSE(viewState.loadedCommandId.has_value()); + + // A request prepared against the evicted command is no longer actionable. + navigation.Reconcile(index.Entries(), viewState, index.Capability(), 4); + const auto after = actions.Prepare(CommandActionKind::LoadIntoInput, index.Entries(), viewState, CommandLoadPolicy{}); + VERIFY_ARE_NOT_EQUAL(request.id.sequence, after.id.sequence); + } + + void CommandTimelineTests::ActionCopyResolvesStableCommandIdNotRowIndex() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"alpha", 0); + Execute(index, 2, revision, L"beta", 0); + Execute(index, 3, revision, L"gamma", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 3); + navigation.SelectVisibleEntry(1, index.Entries(), viewState, index.Capability()); + + CommandTimelineActionModel actions; + const auto before = actions.Prepare(CommandActionKind::CopyCommand, index.Entries(), viewState, CommandLoadPolicy{}); + VERIFY_ARE_EQUAL(std::wstring{ L"beta" }, before.commandText); + VERIFY_ARE_EQUAL(uint64_t{ 2 }, before.id.sequence); + + // Dropping the first entry shifts every row index by one. The selection + // must still resolve to the same command, not to whatever now sits in + // the old row. + const std::array evicted{ 1 }; + index.InvalidateNativeMarks(evicted, ++revision); + navigation.Reconcile(index.Entries(), viewState, index.Capability(), 3); + + const auto after = actions.Prepare(CommandActionKind::CopyCommand, index.Entries(), viewState, CommandLoadPolicy{}); + VERIFY_ARE_EQUAL(before.id, after.id); + VERIFY_ARE_EQUAL(std::wstring{ L"beta" }, after.commandText); + } + + void CommandTimelineTests::ActionOutputRequiresLiveNativeRangeAndIsNeverCached() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 7, revision, L"ls -la", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 4); + + CommandTimelineActionModel actions; + const auto copyOutput = actions.Prepare(CommandActionKind::CopyOutput, index.Entries(), viewState, CommandLoadPolicy{}); + VERIFY_IS_TRUE(copyOutput.Actionable()); + VERIFY_ARE_EQUAL(uint64_t{ 7 }, copyOutput.nativeMarkId); + // The request only carries the identity needed to resolve output later. + // No output text is ever produced or retained by the model. + VERIFY_ARE_EQUAL(std::wstring{ L"ls -la" }, copyOutput.commandText); + + const auto jump = actions.Prepare(CommandActionKind::JumpToOutput, index.Entries(), viewState, CommandLoadPolicy{}); + VERIFY_IS_TRUE(jump.Actionable()); + VERIFY_ARE_EQUAL(uint64_t{ 7 }, jump.nativeMarkId); + + // A stale native range disables both output actions. + auto entries = index.Entries(); + entries.front().nativeRangeValid = false; + const auto staleCopy = actions.Prepare(CommandActionKind::CopyOutput, entries, viewState, CommandLoadPolicy{}); + const auto staleJump = actions.Prepare(CommandActionKind::JumpToOutput, entries, viewState, CommandLoadPolicy{}); + VERIFY_ARE_EQUAL(static_cast(CommandActionStatus::OutputUnavailable), static_cast(staleCopy.status)); + VERIFY_ARE_EQUAL(static_cast(CommandActionStatus::OutputUnavailable), static_cast(staleJump.status)); + + // A command that never reached its output stage cannot copy output. + CommandTimelineIndex pending{ PaneTwo }; + uint64_t pendingRevision = 0; + pending.ProcessLifecycle(Update(LifecycleEventKind::Prompt, 1, ++pendingRevision)); + pending.ProcessLifecycle(Update(LifecycleEventKind::CommandStart, 1, ++pendingRevision)); + CommandTimelineNavigationModel pendingNavigation; + CommandTimelineViewState pendingView; + pendingNavigation.Open(pending.Entries(), pendingView, pending.Capability(), 4); + const auto pendingOutput = actions.Prepare(CommandActionKind::CopyOutput, pending.Entries(), pendingView, CommandLoadPolicy{}); + VERIFY_ARE_EQUAL(static_cast(CommandActionStatus::OutputUnavailable), static_cast(pendingOutput.status)); + } } diff --git a/src/cascadia/WindowsTerminal/WindowsTerminal.rc b/src/cascadia/WindowsTerminal/WindowsTerminal.rc index 550abffe8..bdb257cf0 100644 --- a/src/cascadia/WindowsTerminal/WindowsTerminal.rc +++ b/src/cascadia/WindowsTerminal/WindowsTerminal.rc @@ -83,8 +83,8 @@ IDI_APPICON_HC_WHITE ICON "..\\..\\..\\res\\terminal\\imag #if defined(WT_BRANDING_WINTERM) 1 VERSIONINFO - FILEVERSION 1,2,2,0 - PRODUCTVERSION 1,2,2,0 + FILEVERSION 1,2,3,0 + PRODUCTVERSION 1,2,3,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG @@ -101,12 +101,12 @@ BEGIN BEGIN VALUE "CompanyName", "helloThisWorld\0" VALUE "FileDescription", "winTerm Terminal Host\0" - VALUE "FileVersion", "1.2.2.0\0" + VALUE "FileVersion", "1.2.3.0\0" VALUE "InternalName", "WindowsTerminal\0" VALUE "LegalCopyright", "Copyright (c) winTerm contributors. Portions copyright Microsoft Corporation.\0" VALUE "OriginalFilename", "WindowsTerminal.exe\0" VALUE "ProductName", "winTerm\0" - VALUE "ProductVersion", "1.2.2\0" + VALUE "ProductVersion", "1.2.3\0" END END BLOCK "VarFileInfo" diff --git a/src/cascadia/wt/wt.rc b/src/cascadia/wt/wt.rc index 20842e059..e3149252f 100644 --- a/src/cascadia/wt/wt.rc +++ b/src/cascadia/wt/wt.rc @@ -58,8 +58,8 @@ IDI_APPICON ICON "..\\..\\..\\res\\terminal.ico" #if defined(WT_BRANDING_WINTERM) 1 VERSIONINFO - FILEVERSION 1,2,2,0 - PRODUCTVERSION 1,2,2,0 + FILEVERSION 1,2,3,0 + PRODUCTVERSION 1,2,3,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG @@ -76,12 +76,12 @@ BEGIN BEGIN VALUE "CompanyName", "helloThisWorld\0" VALUE "FileDescription", "winTerm Launcher\0" - VALUE "FileVersion", "1.2.2.0\0" + VALUE "FileVersion", "1.2.3.0\0" VALUE "InternalName", "winTerm\0" VALUE "LegalCopyright", "Copyright (c) winTerm contributors. Portions copyright Microsoft Corporation.\0" VALUE "OriginalFilename", "winTerm.exe\0" VALUE "ProductName", "winTerm\0" - VALUE "ProductVersion", "1.2.2\0" + VALUE "ProductVersion", "1.2.3\0" END END BLOCK "VarFileInfo" diff --git a/src/winterm-tools/winterm-shim/winterm-shim.rc b/src/winterm-tools/winterm-shim/winterm-shim.rc index 27edbde3a..f1a2454ab 100644 --- a/src/winterm-tools/winterm-shim/winterm-shim.rc +++ b/src/winterm-tools/winterm-shim/winterm-shim.rc @@ -4,8 +4,8 @@ #include 1 VERSIONINFO - FILEVERSION 1,2,2,0 - PRODUCTVERSION 1,2,2,0 + FILEVERSION 1,2,3,0 + PRODUCTVERSION 1,2,3,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG @@ -22,12 +22,12 @@ BEGIN BEGIN VALUE "CompanyName", "helloThisWorld\0" VALUE "FileDescription", "winTerm Shell Integration Helper\0" - VALUE "FileVersion", "1.2.2.0\0" + VALUE "FileVersion", "1.2.3.0\0" VALUE "InternalName", "winterm-shim\0" VALUE "LegalCopyright", "Copyright (c) winTerm contributors.\0" VALUE "OriginalFilename", "winterm-shim.exe\0" VALUE "ProductName", "winTerm\0" - VALUE "ProductVersion", "1.2.2\0" + VALUE "ProductVersion", "1.2.3\0" END END BLOCK "VarFileInfo" diff --git a/src/winterm/Branding/ReleaseMetadata.h b/src/winterm/Branding/ReleaseMetadata.h index 1d00f0058..da5a9deb8 100644 --- a/src/winterm/Branding/ReleaseMetadata.h +++ b/src/winterm/Branding/ReleaseMetadata.h @@ -25,7 +25,7 @@ namespace winTerm::Branding { inline constexpr std::wstring_view Publisher{ L"helloThisWorld" }; - inline constexpr std::wstring_view ApplicationVersion{ L"1.2.2" }; + inline constexpr std::wstring_view ApplicationVersion{ L"1.2.3" }; inline constexpr std::wstring_view ReleaseChannel{ L"Stable" }; inline constexpr std::wstring_view CommitSha{ WINTERM_BUILD_COMMIT_SHA }; inline constexpr std::wstring_view BuildTimestamp{ WINTERM_BUILD_TIMESTAMP }; diff --git a/src/winterm/Branding/version.json b/src/winterm/Branding/version.json index 3a444f805..c830a7075 100644 --- a/src/winterm/Branding/version.json +++ b/src/winterm/Branding/version.json @@ -1,10 +1,10 @@ { - "applicationVersion": "1.2.2", - "packageVersion": "1.2.2.0", - "moduleVersion": "1.2.2", + "applicationVersion": "1.2.3", + "packageVersion": "1.2.3.0", + "moduleVersion": "1.2.3", "modulePrerelease": "", "channel": "stable", - "tag": "v1.2.2", + "tag": "v1.2.3", "workspaceSchemaVersion": 2, "dockingModelVersion": 1, "shellProtocolVersion": 1, diff --git a/src/winterm/CommandTimeline/CommandTimelineModel.cpp b/src/winterm/CommandTimeline/CommandTimelineModel.cpp index f5bc87df6..68916846b 100644 --- a/src/winterm/CommandTimeline/CommandTimelineModel.cpp +++ b/src/winterm/CommandTimeline/CommandTimelineModel.cpp @@ -35,6 +35,7 @@ namespace winTerm::CommandTimeline selectedCommandId.reset(); visibleNativeAnchor.reset(); selectedVisualSlot.reset(); + loadedCommandId.reset(); loadedIntoInput = false; executionGeneration = 0; } @@ -399,6 +400,170 @@ namespace winTerm::CommandTimeline return entry.executionResult; } + bool CommandActionRequest::Actionable() const noexcept + { + return status == CommandActionStatus::Ready; + } + + bool CommandTimelineActionModel::IsMultiline(const std::wstring_view commandText) noexcept + { + return commandText.find_first_of(L"\r\n") != std::wstring_view::npos; + } + + const CommandTimelineEntry* CommandTimelineActionModel::_selected( + const std::span entries, + const CommandTimelineViewState& viewState) noexcept + { + if (!viewState.selectedCommandId.has_value()) + { + return nullptr; + } + + const auto found = std::find_if(entries.begin(), entries.end(), [&](const auto& entry) { + return entry.id == *viewState.selectedCommandId; + }); + return found == entries.end() ? nullptr : &*found; + } + + CommandActionRequest CommandTimelineActionModel::Prepare(const CommandActionKind kind, + const std::span entries, + const CommandTimelineViewState& viewState, + const CommandLoadPolicy& policy) const + { + CommandActionRequest request{ + .kind = kind, + .status = CommandActionStatus::NoSelection, + .executionGeneration = viewState.executionGeneration, + }; + + const auto* const entry = _selected(entries, viewState); + if (!entry) + { + return request; + } + + request.id = entry->id; + request.nativeMarkId = entry->nativeMarkId; + request.commandText = entry->cachedCommandText; + request.characterCount = entry->cachedCommandText.size(); + request.multiline = IsMultiline(entry->cachedCommandText); + + switch (kind) + { + case CommandActionKind::JumpToOutput: + // Jumping only needs a native mark that still resolves to a live + // range. The output text itself stays in the terminal buffer. + request.status = entry->nativeMarkId != 0 && entry->nativeRangeValid ? + CommandActionStatus::Ready : + CommandActionStatus::OutputUnavailable; + return request; + case CommandActionKind::CopyOutput: + // Output is resolved by the caller on demand. The Timeline holds no + // output cache, so this only reports whether a resolvable range + // still exists. + request.status = entry->nativeMarkId != 0 && entry->nativeRangeValid && + entry->lifecycleState != CommandLifecycleState::Command ? + CommandActionStatus::Ready : + CommandActionStatus::OutputUnavailable; + return request; + case CommandActionKind::CopyCommand: + request.status = entry->cachedCommandText.empty() ? + CommandActionStatus::CommandTextUnavailable : + CommandActionStatus::Ready; + return request; + case CommandActionKind::LoadIntoInput: + break; + } + + if (entry->cachedCommandText.empty()) + { + request.status = CommandActionStatus::CommandTextUnavailable; + return request; + } + + // A multiline command can only be placed on the input line safely when + // the shell brackets the paste. Without bracketed paste the embedded + // line breaks would be consumed as command submissions, which would + // execute the command the Timeline is only supposed to load. + if (request.multiline && !policy.bracketedPasteEnabled) + { + request.status = CommandActionStatus::MultilineUnsafe; + return request; + } + + const auto warnMultiline = policy.warnOnMultilineLoad && request.multiline; + const auto warnLarge = policy.warnOnLargeLoad && + request.characterCount > policy.largeLoadCharacterThreshold; + request.status = warnMultiline || warnLarge ? + CommandActionStatus::ConfirmationRequired : + CommandActionStatus::Ready; + return request; + } + + CommandActionRequest CommandTimelineActionModel::Confirm(CommandActionRequest request) noexcept + { + if (request.status == CommandActionStatus::ConfirmationRequired) + { + request.status = CommandActionStatus::Ready; + } + return request; + } + + void CommandTimelineActionModel::NotifyLoaded(CommandTimelineViewState& viewState, + const CommandActionRequest& request) noexcept + { + if (request.kind != CommandActionKind::LoadIntoInput || !request.Actionable()) + { + return; + } + + viewState.loadedCommandId = request.id; + viewState.loadedIntoInput = true; + ++viewState.executionGeneration; + } + + void CommandTimelineActionModel::NotifyExecutionStarted(CommandTimelineViewState& viewState) noexcept + { + // The pane started a new command, so any text the Timeline previously + // placed on the input line has been consumed. Retiring the generation + // here is what makes a late completion from the previous command + // detectable by IsCurrentGeneration. + viewState.loadedCommandId.reset(); + viewState.loadedIntoInput = false; + ++viewState.executionGeneration; + } + + void CommandTimelineActionModel::ReconcileLoadedInput(const std::span entries, + CommandTimelineViewState& viewState) noexcept + { + if (!viewState.loadedCommandId.has_value()) + { + viewState.loadedIntoInput = false; + return; + } + + const auto survives = std::any_of(entries.begin(), entries.end(), [&](const auto& entry) { + return entry.id == *viewState.loadedCommandId; + }); + if (!survives) + { + viewState.loadedCommandId.reset(); + viewState.loadedIntoInput = false; + } + } + + bool CommandTimelineActionModel::IsCurrentGeneration(const CommandTimelineViewState& viewState, + const uint64_t executionGeneration) noexcept + { + return viewState.executionGeneration == executionGeneration; + } + + void CommandTimelineActionModel::Reset(CommandTimelineViewState& viewState) noexcept + { + viewState.loadedCommandId.reset(); + viewState.loadedIntoInput = false; + } + std::wstring CommandTextCachePolicy::Apply(const std::wstring_view commandText) const { if (commandText.size() <= maxCommandTextLength) diff --git a/src/winterm/CommandTimeline/CommandTimelineModel.h b/src/winterm/CommandTimeline/CommandTimelineModel.h index 9cd05948d..465503095 100644 --- a/src/winterm/CommandTimeline/CommandTimelineModel.h +++ b/src/winterm/CommandTimeline/CommandTimelineModel.h @@ -79,6 +79,7 @@ namespace winTerm::CommandTimeline std::optional selectedCommandId; std::optional visibleNativeAnchor; std::optional selectedVisualSlot; + std::optional loadedCommandId; bool loadedIntoInput{ false }; uint64_t executionGeneration{}; @@ -184,6 +185,78 @@ namespace winTerm::CommandTimeline bool _wheelSettlePending{ false }; }; + enum class CommandActionKind : uint8_t + { + LoadIntoInput, + CopyCommand, + CopyOutput, + JumpToOutput, + }; + + enum class CommandActionStatus : uint8_t + { + Ready, + NoSelection, + CommandTextUnavailable, + OutputUnavailable, + ConfirmationRequired, + MultilineUnsafe, + }; + + // Describes how a Command Timeline entry may be loaded into the owning + // pane's input. The Timeline never reads the Windows clipboard, so the + // multiline and large-input protections that guard clipboard paste are + // reproduced here against the bounded command-text cache instead. + struct CommandLoadPolicy + { + size_t largeLoadCharacterThreshold{ 1024 }; + bool bracketedPasteEnabled{ false }; + bool warnOnMultilineLoad{ true }; + bool warnOnLargeLoad{ true }; + }; + + struct CommandActionRequest + { + CommandActionKind kind{ CommandActionKind::LoadIntoInput }; + CommandActionStatus status{ CommandActionStatus::NoSelection }; + CommandId id{}; + uint64_t nativeMarkId{}; + std::wstring commandText; + uint64_t executionGeneration{}; + size_t characterCount{}; + bool multiline{ false }; + + bool Actionable() const noexcept; + }; + + // Pure decision layer for the Phase 3 entry actions. It never resolves + // command output, never touches the clipboard, and never appends a + // carriage return, so a prepared load can only ever populate the shell's + // input line. + class CommandTimelineActionModel final + { + public: + CommandActionRequest Prepare(CommandActionKind kind, + std::span entries, + const CommandTimelineViewState& viewState, + const CommandLoadPolicy& policy) const; + static CommandActionRequest Confirm(CommandActionRequest request) noexcept; + + void NotifyLoaded(CommandTimelineViewState& viewState, const CommandActionRequest& request) noexcept; + void NotifyExecutionStarted(CommandTimelineViewState& viewState) noexcept; + void ReconcileLoadedInput(std::span entries, + CommandTimelineViewState& viewState) noexcept; + static bool IsCurrentGeneration(const CommandTimelineViewState& viewState, + uint64_t executionGeneration) noexcept; + void Reset(CommandTimelineViewState& viewState) noexcept; + + static bool IsMultiline(std::wstring_view commandText) noexcept; + + private: + static const CommandTimelineEntry* _selected(std::span entries, + const CommandTimelineViewState& viewState) noexcept; + }; + struct NativeMarkSnapshot { uint64_t nativeMarkId{}; diff --git a/src/winterm/Workspaces/Model/WorkspaceDescriptor.h b/src/winterm/Workspaces/Model/WorkspaceDescriptor.h index 2346fd267..b37c9c29e 100644 --- a/src/winterm/Workspaces/Model/WorkspaceDescriptor.h +++ b/src/winterm/Workspaces/Model/WorkspaceDescriptor.h @@ -219,7 +219,7 @@ namespace winTerm::Workspaces std::string createdAt; std::string updatedAt; WorkspaceSource source{ WorkspaceSource::User }; - std::string applicationVersion{ "1.2.2" }; + std::string applicationVersion{ "1.2.3" }; uint32_t protocolVersion{ 1 }; uint32_t dockingModelVersion{ DockingModelVersion }; WorkspaceStartupBehavior startupBehavior; diff --git a/src/winterm/Workspaces/Persistence/WorkspaceSerializer.cpp b/src/winterm/Workspaces/Persistence/WorkspaceSerializer.cpp index 214a6ce1d..023703f96 100644 --- a/src/winterm/Workspaces/Persistence/WorkspaceSerializer.cpp +++ b/src/winterm/Workspaces/Persistence/WorkspaceSerializer.cpp @@ -618,7 +618,7 @@ WorkspaceDescriptor WorkspaceSerializer::FromJson(const Json::Value& json, const throw std::runtime_error("The workspace source is not supported."); } workspace.source = *source; - workspace.applicationVersion = StringOrDefault(json, "applicationVersion", "1.2.2"); + workspace.applicationVersion = StringOrDefault(json, "applicationVersion", "1.2.3"); workspace.protocolVersion = UIntOrDefault(json, "protocolVersion", 1); workspace.dockingModelVersion = UIntOrDefault(json, "dockingModelVersion", DockingModelVersion); if (const auto& startup = json["startupBehavior"]; !startup.isNull()) From c1426996372c5af0ba442eebf5603bad15a9135e Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Tue, 4 Aug 2026 00:54:49 +0800 Subject: [PATCH 2/3] Complete Command Timeline Phase 4 stabilization Completes the in-memory Command Timeline feature surface: pane-local search, the two public settings, trustworthy shell degradation, and bounded history. No persistence, no output cache, and no telemetry are introduced. - Add pane-local search over each pane's bounded in-memory command text. The match is a literal case-insensitive substring search built on std::search and towlower; there is no regex, no fuzzy matching, no output search, and no terminal-buffer rescan. - Cap queries at 256 UTF-16 code units and truncate without leaving a lone surrogate, enforced in NormalizeCommandTimelineQuery and mirrored by MaxLength on the filter box. A query is never persisted. - Rework the navigation model to walk a filtered projection of indices while keeping stable CommandId identity. A still-matching command stays selected, a command that stops matching hands selection to the nearest surviving match, and a new command only takes the selection when it matches and the view was already following the latest command. - Route slash and Tab to the filter box and consume them before the PTY. Filter text never reaches the shell, Left/Right keep normal caret editing, and IME/TSF composition is left to the text box. Escape now clears a non-empty query before it closes the overlay. - Add the commandTimeline.enabled and commandTimeline.historyLimit global settings with defaults true and 500, a clamped 50-5000 range, JSON schema entries, and a Settings UI section under Appearance. An absent setting is not serialized back, so existing settings files need no migration. - Add bounded per-pane history with oldest-first eviction that applies to panes that already exist and to new panes. Raising the limit never resurrects an evicted command and sequence IDs are never reused. - Add four distinct empty states so an unsupported shell is never reported as simply having run no commands. - Report list item position and set size against the filtered result count, and keep localized accessible names, non-color status, High Contrast theme resources, and the Reduced Motion-safe no-animation path. - Advance engineering version surfaces to 1.2.4/1.2.4.0 for checkpoint tag v1.2.4, and extend test-command-timeline.ps1 with guards for search literalness, query bounds, settings defaults/range/schema, and the filter reading only cached command text. Public Latest remains v1.2.0. This builds on the Phase 3 branch and does not begin v1.3.0-alpha. --- CHANGELOG.md | 50 +++ doc/cascadia/profiles.schema.json | 12 + docs/current-progress.md | 91 ++-- docs/development/command-timeline-phase4.md | 182 ++++++++ docs/user/command-timeline.md | 52 ++- docs/user/keyboard-shortcuts.md | 26 ++ docs/user/privacy.md | 19 + scripts/winterm/package-shell-assets.ps1 | 4 +- scripts/winterm/test-command-timeline.ps1 | 119 +++++- scripts/winterm/test-visual-progress.ps1 | 40 +- scripts/winterm/test.ps1 | 2 +- scripts/winterm/verify-branding.ps1 | 2 +- scripts/winterm/verify-version.ps1 | 18 +- .../winTerm.Shell/winTerm.Shell.psd1 | 2 +- .../winTerm.Shell/winTerm.Shell.psm1 | 2 +- shell/shared/version.json | 4 +- .../Package-winTerm.appxmanifest | 2 +- src/cascadia/TerminalControl/ControlCore.cpp | 55 +++ src/cascadia/TerminalControl/ControlCore.h | 3 + .../TerminalControl/IControlSettings.idl | 2 + .../Resources/en-US/Resources.resw | 12 + src/cascadia/TerminalControl/TermControl.cpp | 185 +++++++- src/cascadia/TerminalControl/TermControl.h | 4 + src/cascadia/TerminalControl/TermControl.xaml | 16 +- .../TerminalSettings.cpp | 2 + .../GlobalAppearance.xaml | 22 + .../GlobalAppearanceViewModel.h | 2 + .../GlobalAppearanceViewModel.idl | 2 + .../Resources/en-US/Resources.resw | 15 + .../GlobalAppSettings.idl | 2 + .../TerminalSettingsModel/MTSMSettings.h | 2 + .../TerminalSettingsModel/defaults.json | 4 + .../CommandTimelineTests.cpp | 394 ++++++++++++++++++ .../SettingsModel.UnitTests.vcxproj | 2 + .../WinTermCommandTimelineTests.cpp | 132 ++++++ .../WindowsTerminal/WindowsTerminal.rc | 8 +- src/cascadia/inc/ControlProperties.h | 2 + src/cascadia/wt/wt.rc | 8 +- .../winterm-shim/winterm-shim.rc | 8 +- src/winterm/Branding/ReleaseMetadata.h | 2 +- src/winterm/Branding/version.json | 8 +- .../CommandTimeline/CommandTimelineModel.cpp | 371 ++++++++++++++--- .../CommandTimeline/CommandTimelineModel.h | 60 ++- .../Workspaces/Model/WorkspaceDescriptor.h | 2 +- .../Persistence/WorkspaceSerializer.cpp | 2 +- 45 files changed, 1770 insertions(+), 184 deletions(-) create mode 100644 docs/development/command-timeline-phase4.md create mode 100644 src/cascadia/UnitTests_SettingsModel/WinTermCommandTimelineTests.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 6037ed87e..3dac1744f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog +## 1.2.4 - 2026-08-04 + +### Added + +- Added pane-local Command Timeline search. `/` or Tab moves focus to the filter + box, typing filters the current pane's commands, Up and Down walk the filtered + results, and Enter loads the selected command without executing it. Neither + the focus keys nor the query text ever reach the PTY. +- Added the `commandTimeline.enabled` and `commandTimeline.historyLimit` global + settings, exposed under Settings → Appearance → Command timeline. Defaults are + `true` and `500`; the history limit accepts 50 through 5000 per pane. +- Added four distinct empty states so an unsupported shell is never reported as + simply having run no commands: waiting for shell integration, command timeline + unavailable, no commands yet, and no matching commands. +- Added bounded per-pane history with oldest-first eviction, plus deterministic + coverage for filtered navigation, wheel accumulation over the filtered + projection, surrogate-safe query truncation, eviction, and a 5000-entry + worst-case search. + +### Changed + +- Search is a literal, case-insensitive substring match over each pane's bounded + in-memory command text only. There is no regex, no fuzzy matching, no output + search, and no terminal-buffer rescan. +- Queries are capped at 256 UTF-16 code units and truncated without leaving a + lone surrogate. A query is never persisted: closing the overlay releases the + query, the filtered projection, and every materialized row. +- The filtered projection keeps stable `CommandId` identity. A command that + still matches stays selected, a command that stops matching hands selection to + the nearest surviving match, and a new command only takes the selection when it + matches and the view was already following the latest command. +- Escape now clears a non-empty query first and only closes the overlay once the + query is already empty. +- Lowering `commandTimeline.historyLimit` evicts oldest-first immediately on + panes that already exist. Raising it never resurrects an evicted command, and + sequence IDs are never reused. +- Disabling `commandTimeline.enabled` hides the left-side handle and closes an + overlay that is already open; the toggle shortcut no longer opens it. +- Advanced engineering application and PowerShell module versions to `1.2.4`, + package/file versions to `1.2.4.0`, and the intended checkpoint tag to + `v1.2.4`; workspace, docking, shell, theme, update-manifest, package identity, + and signing-policy versions remain unchanged. + +### Checkpoint status + +- `v1.2.4` is an engineering checkpoint for Command Timeline Phase 4, not a + public GitHub Release. GitHub Latest and README public downloads remain on + v1.2.0. There is still no persistent history, no output cache, and no + telemetry. + ## 1.2.3 - 2026-08-03 ### Added diff --git a/doc/cascadia/profiles.schema.json b/doc/cascadia/profiles.schema.json index bc2997a8a..1cc61ff3d 100644 --- a/doc/cascadia/profiles.schema.json +++ b/doc/cascadia/profiles.schema.json @@ -2378,6 +2378,18 @@ "description": "When set to true, a selection is immediately copied to your clipboard upon creation. When set to false, the selection persists and awaits further action.", "type": "boolean" }, + "commandTimeline.enabled": { + "default": true, + "description": "When set to true, the Command Timeline overlay and its left-side handle are available for each pane. When set to false, the handle is hidden and the toggle shortcut does not open the overlay.", + "type": "boolean" + }, + "commandTimeline.historyLimit": { + "default": 500, + "description": "The maximum number of commands each pane keeps in its in-memory Command Timeline. Lowering this evicts the oldest commands immediately. History is never written to disk.", + "maximum": 5000, + "minimum": 50, + "type": "integer" + }, "focusFollowMouse": { "default": false, "description": "When set to true, the terminal will focus the pane on mouse hover.", diff --git a/docs/current-progress.md b/docs/current-progress.md index 1eb04e553..32a9f9040 100644 --- a/docs/current-progress.md +++ b/docs/current-progress.md @@ -1,21 +1,22 @@ # Current development progress -Last updated: 2026-08-03 +Last updated: 2026-08-04 ## Repository state -- Branch: `feature/command-timeline-v1.3.0` -- Starting commit: `395f9becd` (`main` after Command Timeline Phase 2) +- Branch: `feature/command-timeline-v1.3.0-phase4` +- Base branch: `feature/command-timeline-v1.3.0` (Command Timeline Phase 3, + tag `v1.2.3`, pull request #29 awaiting owner merge) - Microsoft Terminal upstream revision: `1cea42d433253d95c4487a3037db48197b5e72f4` -- Engineering application and PowerShell module version: `1.2.3` -- Engineering package version: `1.2.3.0` -- Intended checkpoint tag: `v1.2.3` +- Engineering application and PowerShell module version: `1.2.4` +- Engineering package version: `1.2.4.0` +- Intended checkpoint tag: `v1.2.4` - Final Command Timeline release target: `v1.3.0` - Current public Latest: `v1.2.0` - Supported target: Windows 11 x64 -`v1.2.3` is a development checkpoint, not a distributable release. The README +`v1.2.4` is a development checkpoint, not a distributable release. The README and GitHub Latest continue to identify v1.2.0 as the public Visual Progress release. Checkpoint tags v1.2.1 through v1.2.4 run quick validation only and are explicitly excluded from full build, installer packaging, asset publication, @@ -23,46 +24,48 @@ and GitHub Release jobs. ## Implemented in the working tree -- Retained the Phase 1 pane-owned index and the Phase 2 navigation model, - overlay, wheel accumulation, and accessibility surface unchanged as the only - history data source and presentation path. -- Added a pane-owned pure C++ `CommandTimelineActionModel`. It decides whether - a load, copy, or jump is possible from the stable selected `CommandId`, and - never resolves output, reads the clipboard, or produces a payload containing - a carriage return. -- Added Enter and single-click load onto the focused pane's input line. The - payload is filtered for control codes only, `CarriageReturnNewline` is - deliberately not applied, no carriage return is appended, and `SendInput` - targets this pane's connection, so the load can never execute, never reads - the Windows clipboard, and is never forwarded by input broadcast. -- Added multi-line and large-load protection: a multi-line command is refused - when the shell has not enabled bracketed paste, and a load above 1024 - characters requires a confirming Enter. Escape cancels a pending confirmation - before it closes the overlay. -- Added Space to jump the viewport to the selected command's native mark, and a - per-entry context menu with copy command, copy output, and jump to output. - Ctrl+C copies the selected command while the Timeline owns the keyboard. -- Added on-demand output resolution through - `Terminal::ResolveCommandTimelineOutput`. Output is read from the buffer only - for an explicit copy action and is never cached, indexed, or retained. -- Added `loadedCommandId` plus execution-generation tracking. `CommandStart` - retires the loaded state, `IsCurrentGeneration` detects a late completion from - a retired command, and `ReconcileLoadedInput` releases loaded-input state when - the loaded command is evicted. -- Advanced authoritative engineering version surfaces to `1.2.3`/`1.2.3.0` and - added the v1.2.3 root changelog entry. -- Kept Phase 4 out of scope: there is no search box, no filtering, and no - public `commandTimeline.*` settings yet. +- Retained the Phase 1 index, the Phase 2 overlay and navigation model, and the + Phase 3 load/copy/jump entry actions unchanged in behavior. +- Added pane-local search over each pane's bounded in-memory command text. The + match is a literal case-insensitive substring search; there is no regex, no + fuzzy matching, no output search, and no terminal-buffer rescan. +- Added a 256 UTF-16 code-unit query cap that truncates without leaving a lone + surrogate, enforced in the model and mirrored by `MaxLength` on the filter box. +- Reworked the navigation model to walk a filtered projection while keeping + stable `CommandId` identity. A still-matching command stays selected, a command + that stops matching hands selection to the nearest surviving match, and a new + command only takes the selection when it matches and the view was already + following the latest command. +- Added `/` and Tab to focus the filter box. Both are consumed before the PTY, + and filter-box text never reaches the shell. Escape now clears a non-empty + query before it closes the overlay. +- Added the `commandTimeline.enabled` and `commandTimeline.historyLimit` global + settings with defaults `true` and `500`, a 50–5000 clamped range, JSON schema + entries, and a Settings UI section under Appearance. An absent setting is not + serialized back, so existing settings files need no migration. +- Added bounded per-pane history with oldest-first eviction that applies to + panes that already exist and to new panes. Raising the limit never resurrects + an evicted command and sequence IDs are never reused. +- Added four distinct empty states so an unsupported shell is never reported as + simply having run no commands. +- Made list item position and set size reflect the filtered result count, and + kept localized accessible names, non-color status, High Contrast theme + resources, and the Reduced Motion-safe no-animation path. +- Advanced authoritative engineering version surfaces to `1.2.4`/`1.2.4.0` and + added the v1.2.4 root changelog entry. +- Kept `v1.3.0-alpha` out of scope: no persistent history, no output cache, no + telemetry, and no public release work. ## Validation state -Phase 3 validation requires the focused Command Timeline model/control tests, -the extended `test-command-timeline.ps1` source and privacy boundaries, version -and branding verification, release/CI classification guards, shell integration -checks, repository Smoke validation, the smallest affected native projects, and -GitHub quick PR validation. Record exact results in the Draft PR and final task -report; do not treat this document as evidence for a command that did not run. +Phase 4 validation requires the focused Command Timeline model/control tests, +the Settings Model Command Timeline tests, the extended +`test-command-timeline.ps1` source, search, settings, and privacy boundaries, +version and branding verification, release/CI classification guards, repository +Smoke validation, the smallest affected native projects, and GitHub quick PR +validation. Record exact results in the Draft PR and final task report; do not +treat this document as evidence for a command that did not run. -The annotated `v1.2.3` checkpoint tag must point to the final commit that passes +The annotated `v1.2.4` checkpoint tag must point to the final commit that passes those gates. Its tag workflow must run checkpoint quick validation only and must not create a GitHub Release or update Latest. diff --git a/docs/development/command-timeline-phase4.md b/docs/development/command-timeline-phase4.md new file mode 100644 index 000000000..a90402399 --- /dev/null +++ b/docs/development/command-timeline-phase4.md @@ -0,0 +1,182 @@ +# Command Timeline Phase 4 — search, settings, and bounded history + +Phase 4 completes the in-memory Command Timeline feature surface: pane-local +search and filtering, the two public settings, trustworthy shell degradation +states, and bounded history with oldest-first eviction. + +Engineering checkpoint: `1.2.4` / `1.2.4.0`, tag `v1.2.4`. This is not a public +release; GitHub Latest stays on v1.2.0. + +## Scope + +| In scope | Out of scope | +| --- | --- | +| Pane-local literal search | Regex or fuzzy search | +| Filtered projection with stable identity | Output search or indexing | +| `commandTimeline.enabled` / `.historyLimit` | Persistent history | +| Settings UI under Appearance | Output cache | +| Four distinct empty states | Telemetry | +| Bounded history, oldest-first eviction | `v1.3.0-alpha` work | + +## Search semantics + +`CommandTimelineQueryMatches` is a case-insensitive literal substring search +implemented with `std::search` and `towlower`. It is deliberately not a regex +and deliberately not fuzzy, and `test-command-timeline.ps1` fails the build if +`std::regex`, `regex_search`, or a fuzzy matcher appears in the model. + +Filtering reads `entry.cachedCommandText` only — the same bounded 4096-character +cache Phase 1 established. Output is never consulted, and no terminal-buffer +scan is triggered. `_rebuildFilter` walks the existing in-memory index; a new +command is reconciled through the same incremental path that already existed. + +### Query bounds + +`NormalizeCommandTimelineQuery` caps the query at +`MaxCommandTimelineQueryLength` (256 UTF-16 code units). If the cut would land +between a high and low surrogate, the orphaned lead unit is dropped, so the +result is never a lone surrogate. The XAML `TextBox` also carries +`MaxLength="256"`, and the control writes the normalized value back into the box +when truncation shortens it. + +## Filtered projection + +The navigation model keeps `_filtered`, a vector of indices into the caller's +entries span, and navigates over *positions within `_filtered`* rather than over +raw entry indices. An empty query fills `_filtered` with every index, so the +unfiltered case walks exactly the same code path. + +Selection reconciliation: + +| Situation | Result | +| --- | --- | +| Selected command still matches | Stays selected | +| Selected command stops matching | Nearest surviving match (`_nearestPosition`) | +| Following latest, new command matches | New command becomes the selection | +| Following latest, new command does not match | Selection unchanged | +| Browsing older history, new command arrives | Selection unchanged | +| No results | Selected `CommandId` retained, nothing projected | + +Following-latest additionally requires that the newest command is itself in the +projection, which is what stops a non-matching new command from pulling the +selection anywhere. + +Every action still resolves through `viewState.selectedCommandId`, so filtered +navigation, hover, click, wheel, copy, load, jump, and the context menu all act +on the same stable command. + +Only `visibleCapacity` rows are ever materialized, whatever the size of the +history behind them. + +## Settings + +| Setting | Default | Range | Effect | +| --- | ---: | --- | --- | +| `commandTimeline.enabled` | `true` | — | Overlay and left-side handle | +| `commandTimeline.historyLimit` | `500` | 50–5000, integer | Per-pane history | + +Plumbing, in order: `MTSMSettings.h` (`MTSM_GLOBAL_SETTINGS`) → +`GlobalAppSettings.idl` → `ControlProperties.h` → `IControlSettings.idl` → +`TerminalSettings.cpp` → `ControlCore`. Defaults live in `defaults.json`; the +JSON schema in `doc/cascadia/profiles.schema.json` carries type, default, +`minimum`, and `maximum`. + +An absent setting is not written back on serialization, so an existing settings +file needs no migration. An out-of-range value is accepted by the parser and +clamped by `ClampCommandTimelineHistoryLimit`, so the runtime value is always +within 50–5000 rather than failing the whole settings load. + +`UpdateSettings` applies the limit to panes that already exist, and the index +constructor applies it to new panes. + +Disabling the feature hides the handle and closes an open overlay +(`_applyCommandTimelineEnabledSetting`), and `ToggleCommandTimeline` refuses to +open while disabled. + +## History limit and eviction + +`CommandTimelineIndex::_applyHistoryLimit` erases from the front until the +history fits, and runs on entry creation, on bootstrap, and on +`SetHistoryLimit`. Consequences, all covered by tests: + +- Lowering the limit evicts immediately, oldest first. +- Raising the limit never resurrects an evicted entry. +- `_nextSequence` only ever increases, so sequence IDs are never reused. +- `ReconcileLoadedInput` releases loaded-input state when the loaded command is + evicted. + +## Shell degradation + +`CommandTimelineEmptyState` distinguishes four cases so an unsupported shell is +never presented as an empty history: + +| State | Condition | Message | +| --- | --- | --- | +| `WaitingForShell` | Capability `Unknown`, no entries | Waiting for shell integration | +| `ShellUnsupported` | Capability `Limited` | Command timeline unavailable | +| `NoCommands` | Capability `Full`, no entries | No commands yet | +| `NoMatchingCommands` | Query non-empty, entries exist, no matches | No matching commands | + +No prompt parser, no heuristic output detection, and no ConPTY, VT parser, +TextBuffer, renderer, or shell protocol change. + +## Input isolation + +| Key | Timeline focus | Filter-box focus | +| --- | --- | --- | +| `/` | Focus filter box | Types `/` | +| Tab | Focus filter box | Types/moves per text box | +| Up / Down | Move selection | Move selection | +| Left / Right | Page edges | Caret editing | +| Enter | Load, never execute | Load, never execute | +| Escape | Cancel confirmation → clear query → close | Clear query → close | +| Ctrl+C | Copy selected command | Text box copy | + +`/` and Tab are consumed by `_tryHandleCommandTimelineKey`, so neither reaches +the PTY. Filter-box text never reaches the PTY because the `TextBox` owns the +input; `_CommandTimelineSearchKeyDown` claims only Up, Down, Enter, and Escape +and leaves everything else — including IME/TSF composition — to the text box. +`_commandTimelineConsumedKeys` still de-duplicates key-down/key-up so a consumed +key never leaks on release. + +`Ctrl+Tab` and user-defined key bindings keep precedence because +`_TryHandleKeyBinding` runs before the Timeline handler. + +## Accessibility + +- The filter box has a localized accessible name and placeholder. +- List item `PositionInSet` and `SizeOfSet` use the **filtered** result count, + so assistive technology announces a position within the matches. +- Empty states are localized resources, and status is never conveyed by color + alone. +- The overlay uses `{ThemeResource}` brushes for High Contrast and adds no + storyboard or continuous animation. +- Geometry is in device-independent pixels; the overlay changes no terminal + rows/columns, pane size, PTY size, swap-chain size, or padding. + +## Performance evidence + +`SearchStressAtMaximumHistoryLimit` builds a full 5000-entry history, then: + +- Filters with a query matching all 5000 — projection reports 5000 matches while + materializing exactly `visibleCapacity` (20) rows. +- Runs 25 passes of narrow → no-result → broad filtering, asserting the + projection and the materialized row count return to their expected values each + pass, so repeated filtering does not accumulate. +- Asserts cached command text stays within + `5000 * DefaultMaxCachedCommandText`. +- Closes and asserts the filtered projection is released. + +Measured result is recorded in the pull request rather than described as +"performs well". + +## Validation + +- `scripts/winterm/test-command-timeline.ps1` — extended with Phase 4 guards for + search literalness, query bounds, settings defaults/range/schema, Settings UI + presence, and the filter reading only cached command text. +- `src/cascadia/UnitTests_Control/CommandTimelineTests.cpp` — the `Search*`, + `HistoryLimit*`, and `ShellDegradation*` tests. +- `src/cascadia/UnitTests_SettingsModel/WinTermCommandTimelineTests.cpp` — + settings defaults, JSON round-trip, runtime clamping, and out-of-range + handling. diff --git a/docs/user/command-timeline.md b/docs/user/command-timeline.md index 394a8d7ed..33f77f6b0 100644 --- a/docs/user/command-timeline.md +++ b/docs/user/command-timeline.md @@ -31,8 +31,10 @@ guesses where a prompt or output begins. | Situation | What you see | | --- | --- | -| Shell integration is working, no commands yet | `No commands yet` | +| winTerm has not determined the shell's capability yet | `Waiting for shell integration` | | Shell does not report complete command boundaries | `Command timeline unavailable` | +| Shell integration is working, no commands yet | `No commands yet` | +| A filter is active and nothing matches | `No matching commands` | PowerShell with winTerm shell integration reports full boundaries. `cmd.exe` does not, and winTerm does not add a prompt parser for it, so the Timeline @@ -43,16 +45,56 @@ stays unavailable there rather than showing untrustworthy results. | Key | Action | | --- | --- | | `Ctrl+Tab` | Toggle the Timeline for the focused pane | +| `/` or `Tab` | Move focus to the filter box | | `Up` / `Down` | Move the selection by one command | | `Left` / `Right` | Select the first / last command on the current page | | `Enter` | Load the selected command onto the input line | | `Space` | Scroll the terminal to that command's output | | `Ctrl+C` | Copy the selected command text | -| `Escape` | Close the Timeline | +| `Escape` | Clear the filter, or close the Timeline if it is already empty | Your own key bindings take precedence over these. If you have bound one of these keys yourself, your binding wins. +Nothing you type while the Timeline has focus reaches the shell. `/` and `Tab` +move focus to the filter box instead of being sent, and the text you type into +the filter box stays in the filter box. + +## Filtering + +Press `/` or `Tab` and start typing to narrow the list to commands containing +what you typed. + +- Matching is a plain, case-insensitive substring match. `git` matches + `GIT status`. There are no wildcards, no regular expressions, and no fuzzy + matching — `g.*s` matches only the literal text `g.*s`. +- Only command text is searched. Command output is never searched. +- Queries are limited to 256 characters. +- The filter is not remembered. Closing the Timeline clears it. + +While filtering, `Up` and `Down` move between matches, and `Enter` still loads +the selected command without running it. If the command you had selected stops +matching, the closest remaining match is selected instead. If nothing matches, +you see `No matching commands` and your selection is left alone. + +## Settings + +Settings → Appearance → Command timeline: + +| Setting | Default | Range | +| --- | ---: | --- | +| Show command timeline (`commandTimeline.enabled`) | On | — | +| Commands remembered per pane (`commandTimeline.historyLimit`) | 500 | 50–5000 | + +Turning the Timeline off hides the handle and closes it if it is open; the +`Ctrl+Tab` shortcut stops opening it. + +Lowering the history limit discards the oldest commands immediately. Raising it +again does not bring them back. Each pane counts its own history separately. + +You do not need to edit your settings file for these to apply — existing panes +pick up the change straight away. + ## Loading a command Press `Enter`, or click a row once, to put the selected command on the input @@ -94,12 +136,14 @@ a copy of command output. while that pane is open. Closing the pane discards it. - Nothing is written to disk. There is no command history file or database. - Command output is never cached, indexed, or searched. +- Filter text is never saved and never leaves the pane. - Nothing is sent anywhere. The Timeline writes no telemetry and logs no - commands, output, or paths. + commands, output, paths, or filter text. - The clipboard is only written when you explicitly choose a copy action, and is never read. - Each command's stored text is capped at 4096 characters. -- Each pane has its own independent Timeline. +- Each pane keeps at most `commandTimeline.historyLimit` commands, and each + pane has its own independent Timeline. Commands scrolled out of the terminal's scrollback are dropped from the Timeline too, so the list never outlives the buffer it describes. diff --git a/docs/user/keyboard-shortcuts.md b/docs/user/keyboard-shortcuts.md index 312bf0b85..18f3259bc 100644 --- a/docs/user/keyboard-shortcuts.md +++ b/docs/user/keyboard-shortcuts.md @@ -24,3 +24,29 @@ while it owns pointer capture. Pane movement commands are not available in winTerm 1.1. A legacy custom `movePane` action can still be parsed safely but is disabled. + +## Command Timeline + +| Shortcut | Behavior | +| --- | --- | +| `Ctrl+Tab` | Toggle the Command Timeline for the focused pane | +| `Ctrl+T` | Next tab | +| `Ctrl+Shift+T` | Previous tab | +| `Ctrl+Alt+T` | Open new tab | + +While the Command Timeline is open: + +| Key | Behavior | +| --- | --- | +| `/` or `Tab` | Move focus to the filter box | +| `Up` / `Down` | Move the selection by one command | +| `Left` / `Right` | Select the first / last command on the current page | +| `Enter` | Load the selected command onto the input line; never runs it | +| `Space` | Scroll the terminal to that command's output | +| `Ctrl+C` | Copy the selected command text | +| `Escape` | Clear the filter, or close the Timeline if the filter is empty | + +Explicit user key bindings take precedence over all of these defaults. Keys the +Timeline consumes are not sent to the shell, and text typed into the filter box +never reaches the shell. See [Command Timeline](command-timeline.md) for the +full behavior. diff --git a/docs/user/privacy.md b/docs/user/privacy.md index 83bb83498..e2f5c246b 100644 --- a/docs/user/privacy.md +++ b/docs/user/privacy.md @@ -5,3 +5,22 @@ command text, terminal output, clipboard content, Workspace content, or general usage analytics. Visual Progress recognition is bounded, local, and in-memory; it does not upload or persist terminal content, and recognized-output replacement is off by default. + +## Command Timeline + +The [Command Timeline](command-timeline.md) keeps a per-pane list of commands +entirely in memory: + +- Command text lives only in the pane that ran it and only while that pane is + open. Nothing is written to disk; there is no history file or database. +- Command output is never cached, indexed, or searched. Output is read from the + terminal buffer only when you explicitly choose to copy it, and is not + retained afterwards. +- Filter text is never persisted and never leaves the pane. Closing the Timeline + releases it. +- Each command's stored text is capped at 4096 characters, and each pane keeps + at most `commandTimeline.historyLimit` commands (default 500, maximum 5000). +- The clipboard is written only by an explicit copy action and is never read. + Loading a command onto the input line does not use the clipboard. +- No telemetry is written, and no command, output, path, or filter text is + logged. diff --git a/scripts/winterm/package-shell-assets.ps1 b/scripts/winterm/package-shell-assets.ps1 index 803e18a94..bfe4703bf 100644 --- a/scripts/winterm/package-shell-assets.ps1 +++ b/scripts/winterm/package-shell-assets.ps1 @@ -32,9 +32,9 @@ foreach ($relativePath in $sourceAssets) } $version = Get-Content -LiteralPath (Join-Path $repositoryRoot 'shell\shared\version.json') -Raw | ConvertFrom-Json -if ($version.moduleVersion -ne '1.2.3' -or +if ($version.moduleVersion -ne '1.2.4' -or $version.modulePrerelease -ne '' -or - $version.applicationVersion -ne '1.2.3' -or + $version.applicationVersion -ne '1.2.4' -or $version.protocolVersion -ne 1) { throw 'The winTerm Shell asset version metadata is invalid.' diff --git a/scripts/winterm/test-command-timeline.ps1 b/scripts/winterm/test-command-timeline.ps1 index e320ca2da..55281d814 100644 --- a/scripts/winterm/test-command-timeline.ps1 +++ b/scripts/winterm/test-command-timeline.ps1 @@ -79,6 +79,51 @@ Assert-Contains -Content $modelSource -Values @( 'viewState.loadedCommandId.reset()' ) -Failure 'Command Timeline load, confirmation, or execution-generation semantics are incomplete.' +Assert-Contains -Content $modelHeader -Values @( + 'MaxCommandTimelineQueryLength = 256', + 'DefaultCommandTimelineHistoryLimit = 500', + 'MinCommandTimelineHistoryLimit = 50', + 'MaxCommandTimelineHistoryLimit = 5000', + 'NormalizeCommandTimelineQuery', + 'CommandTimelineQueryMatches', + 'ClampCommandTimelineHistoryLimit', + 'enum class CommandTimelineEmptyState', + 'SetHistoryLimit' +) -Failure 'Phase 4 search bounds, history limit, or degradation states are incomplete.' +Assert-Contains -Content $modelSource -Values @( + 'CommandTimelineEmptyState::NoMatchingCommands', + 'CommandTimelineEmptyState::ShellUnsupported', + 'CommandTimelineEmptyState::WaitingForShell', + '_rebuildFilter', + '_nearestPosition', + '_applyHistoryLimit' +) -Failure 'Phase 4 filtered projection or eviction semantics are incomplete.' + +# Search must be literal and case-insensitive over command text only. A regex +# engine, a fuzzy matcher, or an output field would each break the stated +# privacy and performance boundaries. Compare against code only: the model +# documents why these are excluded, and that prose must not trip the guard. +$modelCode = [regex]::Replace($modelSource, '//.*', '') + [regex]::Replace($modelHeader, '//.*', '') +foreach ($forbidden in @('std::regex', 'std::wregex', 'regex_search', 'fuzzy', 'Fuzzy')) { + if ($modelCode.Contains($forbidden)) { + throw "Command Timeline search must be literal. Found '$forbidden'." + } +} +$matchStart = $modelSource.IndexOf('bool CommandTimelineQueryMatches', [StringComparison]::Ordinal) +if ($matchStart -lt 0) { + throw 'CommandTimelineQueryMatches is missing.' +} +$matchBody = $modelSource.Substring($matchStart, $modelSource.IndexOf("`n }", $matchStart, [StringComparison]::Ordinal) - $matchStart) +if (-not $matchBody.Contains('std::search') -or -not $matchBody.Contains('towlower')) { + throw 'Command Timeline search must be a case-insensitive literal substring search.' +} + +$filterStart = $modelSource.IndexOf('void CommandTimelineNavigationModel::_rebuildFilter', [StringComparison]::Ordinal) +$filterBody = $modelSource.Substring($filterStart, $modelSource.IndexOf("`n }", $filterStart, [StringComparison]::Ordinal) - $filterStart) +if (-not $filterBody.Contains('cachedCommandText')) { + throw 'Command Timeline filtering must only read the bounded cached command text.' +} + Assert-Contains -Content $controlCoreHeader -Values @( 'CommandTimelineNavigationModel _commandTimelineNavigation', 'OpenCommandTimeline', @@ -225,6 +270,55 @@ Assert-Contains -Content $resources -Values @( 'CommandTimelineConfirmLoad' ) -Failure 'Timeline entry-action localized strings are missing.' +# Phase 4: search box, settings, and degradation states. +Assert-Contains -Content $resources -Values @( + 'CommandTimelineSearchBox.[using:Windows.UI.Xaml.Automation]AutomationProperties.Name', + 'CommandTimelineNoMatchingCommands', + 'CommandTimelineWaitingForShell' +) -Failure 'Timeline search accessibility name or empty-state strings are missing.' +Assert-Contains -Content $overlayXaml -Values @( + 'x:Name="CommandTimelineSearchBox"', + 'MaxLength="256"', + 'KeyDown="_CommandTimelineSearchKeyDown"', + 'TextChanged="_CommandTimelineSearchTextChanged"' +) -Failure 'The Timeline filter box is missing or unbounded.' +Assert-Contains -Content $termControlSource -Values @( + 'FilterCommandTimeline', + 'NormalizeCommandTimelineQuery', + '_focusCommandTimelineSearch()', + 'CommandTimelineSearchBox().Text({})', + 'CommandTimelineEmptyState::NoMatchingCommands', + 'presentation.filteredEntryCount', + '_applyCommandTimelineEnabledSetting' +) -Failure 'Timeline filtering, focus routing, cleanup, or enabled-setting handling is incomplete.' + +$settingsMacros = Read-Source 'src\cascadia\TerminalSettingsModel\MTSMSettings.h' +Assert-Contains -Content $settingsMacros -Values @( + 'X(bool, CommandTimelineEnabled, "commandTimeline.enabled", true)', + 'X(int32_t, CommandTimelineHistoryLimit, "commandTimeline.historyLimit", 500)' +) -Failure 'The public Command Timeline settings are not declared with their documented defaults.' +Assert-Contains -Content $defaults -Values @( + '"commandTimeline.enabled": true', + '"commandTimeline.historyLimit": 500' +) -Failure 'The Command Timeline defaults are missing from defaults.json.' + +$schema = Read-Source 'doc\cascadia\profiles.schema.json' +Assert-Contains -Content $schema -Values @( + '"commandTimeline.enabled"', + '"commandTimeline.historyLimit"', + '"maximum": 5000', + '"minimum": 50' +) -Failure 'The Command Timeline settings schema is missing its type, default, or range.' + +$settingsUi = Read-Source 'src\cascadia\TerminalSettingsEditor\GlobalAppearance.xaml' +Assert-Contains -Content $settingsUi -Values @( + 'x:Uid="Globals_CommandTimelineHeader"', + 'ViewModel.CommandTimelineEnabled', + 'ViewModel.CommandTimelineHistoryLimit', + 'Maximum="5000"', + 'Minimum="50"' +) -Failure 'The Settings UI does not expose the Command Timeline settings within their range.' + Assert-Contains -Content $controlTests -Values @( 'NavigationPageEdgesMoveOneInOneOut', 'NavigationWheelAccumulatesReversesAndSettles', @@ -235,8 +329,27 @@ Assert-Contains -Content $controlTests -Values @( 'ActionLateCompletionIsDetectedAfterExecutionStart', 'ActionLoadedInputIsReleasedOnEviction', 'ActionCopyResolvesStableCommandIdNotRowIndex', - 'ActionOutputRequiresLiveNativeRangeAndIsNeverCached' -) -Failure 'Deterministic Timeline navigation, action, pane isolation, cleanup, or warm-access tests are missing.' + 'ActionOutputRequiresLiveNativeRangeAndIsNeverCached', + 'SearchMatchesLiterallyAndCaseInsensitively', + 'SearchQueryTruncationIsSurrogateSafe', + 'SearchKeepsStableSelectionAcrossQueryChanges', + 'SearchSelectsNearestSurvivingMatch', + 'SearchNavigationAndWheelWalkFilteredProjection', + 'SearchNewCommandFollowsLatestOnlyWhenMatching', + 'ShellDegradationStatesAreDistinct', + 'HistoryLimitEvictsOldestFirstAndNeverResurrects', + 'HistoryLimitClampsToSupportedRange', + 'SearchCloseReleasesQueryAndProjection', + 'SearchStressAtMaximumHistoryLimit' +) -Failure 'Deterministic Timeline navigation, action, search, eviction, pane isolation, cleanup, or warm-access tests are missing.' + +$commandTimelineSettingsTests = Read-Source 'src\cascadia\UnitTests_SettingsModel\WinTermCommandTimelineTests.cpp' +Assert-Contains -Content $commandTimelineSettingsTests -Values @( + 'CommandTimelineSettingsUseStableDefaults', + 'CommandTimelineSettingsRoundTripThroughJson', + 'CommandTimelineHistoryLimitIsClampedAtRuntime', + 'CommandTimelineSettingsTolerateOutOfRangeAndOddValues' +) -Failure 'Command Timeline settings defaults, round-trip, range, and invalid-value tests are missing.' Assert-Contains -Content $settingsTests -Values @( 'CommandTimelineDefaultShortcutsAndUserOverride', 'ShortcutAction::ToggleCommandTimeline', @@ -245,4 +358,4 @@ Assert-Contains -Content $settingsTests -Values @( [xml](Read-Source 'src\cascadia\TerminalControl\TermControl.xaml') | Out-Null [xml](Read-Source 'src\cascadia\TerminalControl\Resources\en-US\Resources.resw') | Out-Null -Write-Host 'PASS: Command Timeline Phase 3 source, input, action, accessibility, privacy, and lifecycle boundaries' -ForegroundColor Green +Write-Host 'PASS: Command Timeline Phase 4 source, search, settings, input, action, accessibility, privacy, and lifecycle boundaries' -ForegroundColor Green diff --git a/scripts/winterm/test-visual-progress.ps1 b/scripts/winterm/test-visual-progress.ps1 index 289176059..e0d390884 100644 --- a/scripts/winterm/test-visual-progress.ps1 +++ b/scripts/winterm/test-visual-progress.ps1 @@ -1378,12 +1378,12 @@ try $version = $source.VersionMetadata | ConvertFrom-Json $expectedVersionValues = [ordered]@{ - applicationVersion = '1.2.3' - packageVersion = '1.2.3.0' - moduleVersion = '1.2.3' + applicationVersion = '1.2.4' + packageVersion = '1.2.4.0' + moduleVersion = '1.2.4' modulePrerelease = '' channel = 'stable' - tag = 'v1.2.3' + tag = 'v1.2.4' workspaceSchemaVersion = 2 dockingModelVersion = 1 shellProtocolVersion = 1 @@ -1398,39 +1398,39 @@ try } } $shellVersion = $source.ShellVersion | ConvertFrom-Json - if ($shellVersion.applicationVersion -ne '1.2.3' -or $shellVersion.moduleVersion -ne '1.2.3' -or $shellVersion.protocolVersion -ne 1) + if ($shellVersion.applicationVersion -ne '1.2.4' -or $shellVersion.moduleVersion -ne '1.2.4' -or $shellVersion.protocolVersion -ne 1) { - throw 'Shell version metadata does not match winTerm engineering checkpoint 1.2.3 with protocol version 1.' + throw 'Shell version metadata does not match winTerm engineering checkpoint 1.2.4 with protocol version 1.' } foreach ($surface in @( - @{ Content = $source.ReleaseMetadata; Value = 'ApplicationVersion{ L"1.2.3" }'; Description = 'About release metadata' }, - @{ Content = $source.PackageManifest; Value = 'Version="1.2.3.0"'; Description = 'MSIX package manifest' }, - @{ Content = $source.HostResource; Value = 'FILEVERSION 1,2,3,0'; Description = 'Terminal host file version' }, - @{ Content = $source.HostResource; Value = '"ProductVersion", "1.2.3\0"'; Description = 'Terminal host display version' }, - @{ Content = $source.ShimResource; Value = 'FILEVERSION 1,2,3,0'; Description = 'Shim file version' }, - @{ Content = $source.ShimResource; Value = '"ProductVersion", "1.2.3\0"'; Description = 'Shim display version' }, + @{ Content = $source.ReleaseMetadata; Value = 'ApplicationVersion{ L"1.2.4" }'; Description = 'About release metadata' }, + @{ Content = $source.PackageManifest; Value = 'Version="1.2.4.0"'; Description = 'MSIX package manifest' }, + @{ Content = $source.HostResource; Value = 'FILEVERSION 1,2,4,0'; Description = 'Terminal host file version' }, + @{ Content = $source.HostResource; Value = '"ProductVersion", "1.2.4\0"'; Description = 'Terminal host display version' }, + @{ Content = $source.ShimResource; Value = 'FILEVERSION 1,2,4,0'; Description = 'Shim file version' }, + @{ Content = $source.ShimResource; Value = '"ProductVersion", "1.2.4\0"'; Description = 'Shim display version' }, @{ Content = $source.CustomProps; Value = '1'; Description = 'Executable major version' }, @{ Content = $source.CustomProps; Value = '2'; Description = 'Executable minor version' }, - @{ Content = $source.ShellModuleManifest; Value = "ModuleVersion = '1.2.3'"; Description = 'PowerShell module manifest' }, - @{ Content = $source.ShellModule; Value = "`$script:WinTermModuleVersion = '1.2.3'"; Description = 'PowerShell module runtime' }, + @{ Content = $source.ShellModuleManifest; Value = "ModuleVersion = '1.2.4'"; Description = 'PowerShell module manifest' }, + @{ Content = $source.ShellModule; Value = "`$script:WinTermModuleVersion = '1.2.4'"; Description = 'PowerShell module runtime' }, @{ Content = $source.PackageShellAssets; Value = "'shell\shared\version.json'"; Description = 'Canonical shell version metadata packaging' }, - @{ Content = $source.WorkspaceSerializer; Value = '"1.2.3"'; Description = 'Workspace application-version fallback' } + @{ Content = $source.WorkspaceSerializer; Value = '"1.2.4"'; Description = 'Workspace application-version fallback' } )) { Assert-Contains $surface.Content $surface.Value $surface.Description } foreach ($required in @( - "applicationVersion -eq '1.2.3'", - "packageVersion -eq '1.2.3.0'", - "moduleVersion -eq '1.2.3'", - "tag -eq 'v1.2.3'", + "applicationVersion -eq '1.2.4'", + "packageVersion -eq '1.2.4.0'", + "moduleVersion -eq '1.2.4'", + "tag -eq 'v1.2.4'", "Workspace Schema version remains 2", "Docking Model version remains 1", "Shell Protocol version remains 1", "Theme Schema remains at version 1" )) { - Assert-Contains $source.VerifyVersion $required 'Authoritative v1.2.3 version validation surface' + Assert-Contains $source.VerifyVersion $required 'Authoritative v1.2.4 version validation surface' } $testBinary = Join-Path $root "bin\$Platform\$Configuration\UnitTests_SettingsModel\SettingsModel.Unit.Tests.dll" diff --git a/scripts/winterm/test.ps1 b/scripts/winterm/test.ps1 index 25eab6786..031c0feb7 100644 --- a/scripts/winterm/test.ps1 +++ b/scripts/winterm/test.ps1 @@ -292,7 +292,7 @@ function Test-ShellExperienceFoundations } $manifest = Import-PowerShellDataFile -LiteralPath $moduleManifest - if ($manifest.ModuleVersion -ne '1.2.3' -or + if ($manifest.ModuleVersion -ne '1.2.4' -or $manifest.PrivateData.PSData.Prerelease -ne '' -or $manifest.PowerShellVersion -ne '5.1') { diff --git a/scripts/winterm/verify-branding.ps1 b/scripts/winterm/verify-branding.ps1 index 7e7847993..2f49ef1ca 100644 --- a/scripts/winterm/verify-branding.ps1 +++ b/scripts/winterm/verify-branding.ps1 @@ -126,7 +126,7 @@ function Test-Manifest Test-Requirement -Condition ($null -ne $identity -and $identity.Name -eq 'HelloThisWorld.winTerm') -Message "$Path uses package identity HelloThisWorld.winTerm" Test-Requirement -Condition ($null -ne $identity -and $identity.Name -notmatch '^Microsoft\.') -Message "$Path does not use a Microsoft package name" Test-Requirement -Condition ($null -ne $identity -and $identity.Publisher -ceq $ExpectedPublisher) -Message "$Path uses the expected non-Microsoft publisher" - Test-Requirement -Condition ($null -ne $identity -and $identity.Version -eq '1.2.3.0') -Message "$Path uses package version 1.2.3.0" + Test-Requirement -Condition ($null -ne $identity -and $identity.Version -eq '1.2.4.0') -Message "$Path uses package version 1.2.4.0" Test-Requirement -Condition ($null -ne $properties -and $properties.DisplayName -eq 'winTerm') -Message "$Path package display name is winTerm" Test-Requirement -Condition ($null -ne $application -and $application.Id -eq 'winTerm') -Message "$Path application ID is winTerm" Test-Requirement -Condition ($null -ne $visualElements -and $visualElements.DisplayName -eq 'winTerm') -Message "$Path application display name is winTerm" diff --git a/scripts/winterm/verify-version.ps1 b/scripts/winterm/verify-version.ps1 index c2783be63..06d6865f8 100644 --- a/scripts/winterm/verify-version.ps1 +++ b/scripts/winterm/verify-version.ps1 @@ -49,12 +49,12 @@ try $versionPath = Join-Path $repositoryRoot 'src\winterm\Branding\version.json' $version = Get-Content -LiteralPath $versionPath -Raw | ConvertFrom-Json - Assert-Condition ($version.applicationVersion -eq '1.2.3') 'Application version is 1.2.3' - Assert-Condition ($version.packageVersion -eq '1.2.3.0') 'Package version is 1.2.3.0' - Assert-Condition ($version.moduleVersion -eq '1.2.3') 'PowerShell module version is 1.2.3' + Assert-Condition ($version.applicationVersion -eq '1.2.4') 'Application version is 1.2.4' + Assert-Condition ($version.packageVersion -eq '1.2.4.0') 'Package version is 1.2.4.0' + Assert-Condition ($version.moduleVersion -eq '1.2.4') 'PowerShell module version is 1.2.4' Assert-Condition ($version.modulePrerelease -eq '') 'PowerShell module has no prerelease suffix' Assert-Condition ($version.channel -eq 'stable') 'Release channel is stable' - Assert-Condition ($version.tag -eq 'v1.2.3') 'Engineering checkpoint tag is v1.2.3' + Assert-Condition ($version.tag -eq 'v1.2.4') 'Engineering checkpoint tag is v1.2.4' Assert-Condition ($version.workspaceSchemaVersion -eq 2) 'Workspace Schema version remains 2' Assert-Condition ($version.dockingModelVersion -eq 1) 'Docking Model version remains 1' Assert-Condition ($version.shellProtocolVersion -eq 1) 'Shell Protocol version remains 1' @@ -78,7 +78,7 @@ try $moduleManifest = Import-PowerShellDataFile -LiteralPath (Join-Path $repositoryRoot 'shell\powershell\winTerm.Shell\winTerm.Shell.psd1') Assert-Condition ($moduleManifest.ModuleVersion.ToString() -eq $version.moduleVersion) 'PowerShell manifest version matches release metadata' Assert-Condition ($moduleManifest.PrivateData.PSData.Prerelease -eq $version.modulePrerelease) 'PowerShell manifest prerelease matches release metadata' - Assert-Condition ((Get-Text 'shell\powershell\winTerm.Shell\winTerm.Shell.psm1').Contains("`$script:WinTermModuleVersion = '1.2.3'")) 'PowerShell module runtime version matches release metadata' + Assert-Condition ((Get-Text 'shell\powershell\winTerm.Shell\winTerm.Shell.psm1').Contains("`$script:WinTermModuleVersion = '1.2.4'")) 'PowerShell module runtime version matches release metadata' $shellVersion = Get-Text 'shell\shared\version.json' | ConvertFrom-Json Assert-Condition ($shellVersion.applicationVersion -eq $version.applicationVersion) 'Shell asset application version matches release metadata' @@ -86,7 +86,7 @@ try Assert-Condition ($shellVersion.protocolVersion -eq $version.shellProtocolVersion) 'Shell asset protocol version matches release metadata' $releaseHeader = Get-Text 'src\winterm\Branding\ReleaseMetadata.h' - Assert-Condition ($releaseHeader.Contains('ApplicationVersion{ L"1.2.3" }')) 'About metadata application version is 1.2.3' + Assert-Condition ($releaseHeader.Contains('ApplicationVersion{ L"1.2.4" }')) 'About metadata application version is 1.2.4' Assert-Condition ($releaseHeader.Contains('ReleaseChannel{ L"Stable" }')) 'About metadata channel is Stable' Assert-Condition ($releaseHeader.Contains($version.microsoftTerminalUpstreamRevision)) 'About metadata contains the Microsoft Terminal upstream revision' Assert-Condition ($releaseHeader.Contains('WorkspaceSchemaVersion{ 2 }')) 'About metadata contains Workspace Schema version 2' @@ -117,10 +117,10 @@ try Assert-Condition ((Get-Text 'src\winterm\Workspaces\Model\WorkspaceDescriptor.h').Contains('WorkspaceSchemaVersion{ 2 }')) 'Workspace model remains at Schema version 2' Assert-Condition ((Get-Text 'src\winterm\Workspaces\Model\WorkspaceDescriptor.h').Contains('DockingModelVersion{ 1 }')) 'Workspace model remains at Docking version 1' - Assert-Condition ((Get-Text 'src\winterm\Workspaces\Model\WorkspaceDescriptor.h').Contains('applicationVersion{ "1.2.3" }')) 'Workspace model application-version fallback is 1.2.3' + Assert-Condition ((Get-Text 'src\winterm\Workspaces\Model\WorkspaceDescriptor.h').Contains('applicationVersion{ "1.2.4" }')) 'Workspace model application-version fallback is 1.2.4' Assert-Condition ((Get-Text 'src\winterm\Shell\Protocol\ShellIntegrationProtocol.h').Contains('ShellProtocolVersion{ 1 }')) 'Shell protocol remains at version 1' Assert-Condition ((Get-Text 'src\winterm\Appearance\Themes\ThemeDescriptor.h').Contains('CurrentThemeSchemaVersion{ 1 }')) 'Theme Schema remains at version 1' - Assert-Condition ((Get-Text 'src\winterm\Workspaces\Persistence\WorkspaceSerializer.cpp').Contains('"1.2.3"')) 'Workspace serializer application-version fallback is 1.2.3' + Assert-Condition ((Get-Text 'src\winterm\Workspaces\Persistence\WorkspaceSerializer.cpp').Contains('"1.2.4"')) 'Workspace serializer application-version fallback is 1.2.4' $releaseWorkflow = Get-Text '.github\workflows\release.yml' Assert-Condition ($releaseWorkflow.Contains("- 'v*'")) 'Release workflow accepts version tags through a generic guarded trigger' @@ -135,7 +135,7 @@ try if ($RequireTag) { $tag = (& git describe --tags --exact-match 2>$null).Trim() - Assert-Condition ($LASTEXITCODE -eq 0 -and $tag -eq $version.tag) 'Checked-out commit is exactly tagged v1.2.3' + Assert-Condition ($LASTEXITCODE -eq 0 -and $tag -eq $version.tag) 'Checked-out commit is exactly tagged v1.2.4' } Write-Host 'winTerm version consistency verification passed.' -ForegroundColor Green diff --git a/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 b/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 index 72cb6335e..22b9bb95d 100644 --- a/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 +++ b/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'winTerm.Shell.psm1' - ModuleVersion = '1.2.3' + ModuleVersion = '1.2.4' GUID = 'f65cd8f4-5d25-4a2a-a0d4-58df1ab3dc5a' Author = 'winTerm contributors' CompanyName = 'winTerm' diff --git a/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 b/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 index 3aa544d60..c117e226f 100644 --- a/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 +++ b/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 @@ -3,7 +3,7 @@ Set-StrictMode -Version Latest -$script:WinTermModuleVersion = '1.2.3' +$script:WinTermModuleVersion = '1.2.4' $script:WinTermProtocolVersion = 1 $script:WinTermIntegrationEnabled = $false $script:WinTermPromptWrapped = $false diff --git a/shell/shared/version.json b/shell/shared/version.json index fd6e1946a..da2d0f242 100644 --- a/shell/shared/version.json +++ b/shell/shared/version.json @@ -1,6 +1,6 @@ { - "applicationVersion": "1.2.3", - "moduleVersion": "1.2.3", + "applicationVersion": "1.2.4", + "moduleVersion": "1.2.4", "modulePrerelease": "", "protocolVersion": 1 } diff --git a/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest b/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest index 640a6cc99..c7a8dd902 100644 --- a/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest +++ b/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest @@ -18,7 +18,7 @@ + Version="1.2.4.0" /> winTerm diff --git a/src/cascadia/TerminalControl/ControlCore.cpp b/src/cascadia/TerminalControl/ControlCore.cpp index 1a0e34ab4..9f23f9338 100644 --- a/src/cascadia/TerminalControl/ControlCore.cpp +++ b/src/cascadia/TerminalControl/ControlCore.cpp @@ -105,6 +105,10 @@ namespace winrt::Microsoft::Terminal::Control::implementation commandTimelineSessionId = ::Microsoft::Console::Utils::CreateGuid(); } _commandTimelineIndex = std::make_unique(commandTimelineSessionId); + // A new pane starts at the configured limit rather than the built-in + // default, so the setting applies to new and existing panes alike. + _commandTimelineIndex->SetHistoryLimit( + winTerm::CommandTimeline::ClampCommandTimelineHistoryLimit(settings.CommandTimelineHistoryLimit())); const auto commandline = settings.Commandline(); _commandTimelineIndex->SetCapabilityFallback( winTerm::CommandTimeline::InferCapabilityFallbackFromCommandline( @@ -975,6 +979,15 @@ namespace winrt::Microsoft::Terminal::Control::implementation const auto lock = _terminal->LockForWriting(); + // A settings change reaches panes that already exist, so a lowered + // history limit evicts here rather than only on the next new pane. + if (_commandTimelineIndex) + { + _commandTimelineIndex->SetHistoryLimit( + winTerm::CommandTimeline::ClampCommandTimelineHistoryLimit(_settings.CommandTimelineHistoryLimit())); + _commandTimelineActions.ReconcileLoadedInput(_commandTimelineIndex->Entries(), _commandTimelineViewState); + } + _builtinGlyphs = _settings.EnableBuiltinGlyphs(); _colorGlyphs = _settings.EnableColorGlyphs(); _cellWidth = CSSLengthPercentage::FromString(_settings.CellWidth().c_str()); @@ -1732,6 +1745,48 @@ namespace winrt::Microsoft::Terminal::Control::implementation _commandTimelineNavigation.Close(); } + // Applies the query to this pane's bounded command-text index only. It + // never reads output and never rescans the terminal buffer. + winTerm::CommandTimeline::CommandTimelinePresentationSnapshot ControlCore::FilterCommandTimeline( + const std::wstring_view query) + { + const auto lock = _terminal->LockForWriting(); + _ensureCommandTimelineBootstrap(); + return _commandTimelineNavigation.SetQuery( + query, + _commandTimelineIndex->Entries(), + _commandTimelineViewState, + _commandTimelineIndex->Capability()); + } + + // Pushes the current commandTimeline.historyLimit onto this pane's index. + // Lowering it evicts oldest-first immediately; raising it never resurrects + // an entry that was already dropped. + void ControlCore::ApplyCommandTimelineHistoryLimit() + { + const auto configured = winTerm::CommandTimeline::ClampCommandTimelineHistoryLimit( + _settings ? _settings.CommandTimelineHistoryLimit() : 0); + + const auto lock = _terminal->LockForWriting(); + if (!_commandTimelineIndex) + { + return; + } + + const auto revisionBefore = _commandTimelineIndex->Revision(); + _commandTimelineIndex->SetHistoryLimit(configured); + if (_commandTimelineIndex->Revision() != revisionBefore) + { + _commandTimelineActions.ReconcileLoadedInput(_commandTimelineIndex->Entries(), _commandTimelineViewState); + CommandTimelineChanged.raise(*this, nullptr); + } + } + + bool ControlCore::CommandTimelineEnabled() const + { + return _settings ? _settings.CommandTimelineEnabled() : true; + } + winTerm::CommandTimeline::CommandActionRequest ControlCore::PrepareCommandTimelineAction( const winTerm::CommandTimeline::CommandActionKind kind) { diff --git a/src/cascadia/TerminalControl/ControlCore.h b/src/cascadia/TerminalControl/ControlCore.h index b4fb58dd4..e24c5e2ee 100644 --- a/src/cascadia/TerminalControl/ControlCore.h +++ b/src/cascadia/TerminalControl/ControlCore.h @@ -188,6 +188,9 @@ namespace winrt::Microsoft::Terminal::Control::implementation std::wstring ResolveCommandTimelineOutput(const winTerm::CommandTimeline::CommandActionRequest& request); bool JumpToCommandTimelineOutput(const winTerm::CommandTimeline::CommandActionRequest& request); bool CopyCommandTimelineText(const std::wstring& text); + winTerm::CommandTimeline::CommandTimelinePresentationSnapshot FilterCommandTimeline(std::wstring_view query); + void ApplyCommandTimelineHistoryLimit(); + bool CommandTimelineEnabled() const; hstring Title(); Windows::Foundation::IReference TabColor() noexcept; diff --git a/src/cascadia/TerminalControl/IControlSettings.idl b/src/cascadia/TerminalControl/IControlSettings.idl index 19f0d70ec..c9b7e9ff9 100644 --- a/src/cascadia/TerminalControl/IControlSettings.idl +++ b/src/cascadia/TerminalControl/IControlSettings.idl @@ -57,6 +57,8 @@ namespace Microsoft.Terminal.Control Boolean FocusFollowMouse { get; }; Boolean ScrollToZoom { get; }; Boolean ScrollToChangeOpacity { get; }; + Boolean CommandTimelineEnabled { get; }; + Int32 CommandTimelineHistoryLimit { get; }; String Commandline { get; }; String StartingDirectory { get; }; diff --git a/src/cascadia/TerminalControl/Resources/en-US/Resources.resw b/src/cascadia/TerminalControl/Resources/en-US/Resources.resw index 141414d3d..de4b1842e 100644 --- a/src/cascadia/TerminalControl/Resources/en-US/Resources.resw +++ b/src/cascadia/TerminalControl/Resources/en-US/Resources.resw @@ -394,4 +394,16 @@ Press Enter again to load this command + + Filter commands + + + Filter commands + + + No matching commands + + + Waiting for shell integration + diff --git a/src/cascadia/TerminalControl/TermControl.cpp b/src/cascadia/TerminalControl/TermControl.cpp index 53ae239e3..9adc1af22 100644 --- a/src/cascadia/TerminalControl/TermControl.cpp +++ b/src/cascadia/TerminalControl/TermControl.cpp @@ -900,6 +900,10 @@ namespace winrt::Microsoft::Terminal::Control::implementation // Update our control settings _ApplyUISettings(); + + // Reaches panes that already exist, so toggling commandTimeline.enabled + // hides the handle and closes an open overlay immediately. + _applyCommandTimelineEnabledSetting(); } // Method Description: @@ -2268,6 +2272,8 @@ namespace winrt::Microsoft::Terminal::Control::implementation case VK_ESCAPE: case VK_RETURN: case VK_SPACE: + case VK_TAB: + case VK_OEM_2: break; default: return false; @@ -2291,15 +2297,26 @@ namespace winrt::Microsoft::Terminal::Control::implementation switch (vkey) { + case VK_TAB: + case VK_OEM_2: + // Slash and Tab move focus to the filter box. Both are consumed + // here so neither reaches the PTY. + _focusCommandTimelineSearch(); + return true; case VK_ESCAPE: - // A pending load confirmation is cancelled before the overlay - // itself closes, so Escape never discards more than one step. + // A pending load confirmation is cancelled first, then a non-empty + // query, and only then does the overlay close. if (_commandTimelinePendingLoad.has_value()) { _clearCommandTimelinePendingLoad(); _setCommandTimelineStatus({}); return true; } + if (!CommandTimelineSearchBox().Text().empty()) + { + CommandTimelineSearchBox().Text({}); + return true; + } _closeCommandTimeline(true); return true; case VK_RETURN: @@ -2498,6 +2515,126 @@ namespace winrt::Microsoft::Terminal::Control::implementation _commandTimelinePendingLoad.reset(); } + void TermControl::_focusCommandTimelineSearch() + { + if (_commandTimelineOpen && !_IsClosing()) + { + CommandTimelineSearchBox().Focus(FocusState::Programmatic); + } + } + + // Filtering runs entirely over this pane's bounded command-text index. The + // query itself is UI-only state and is never persisted. + void TermControl::_CommandTimelineSearchTextChanged(const IInspectable& /*sender*/, + const Controls::TextChangedEventArgs& /*args*/) + { + if (!_commandTimelineOpen || _IsClosing()) + { + return; + } + + try + { + // Changing the query invalidates a confirmation waiting on the + // previously selected command. + _clearCommandTimelinePendingLoad(); + _setCommandTimelineStatus({}); + + const auto box = CommandTimelineSearchBox(); + const std::wstring_view raw{ box.Text() }; + const auto normalized = winTerm::CommandTimeline::NormalizeCommandTimelineQuery(raw); + if (normalized.size() != raw.size()) + { + // Surrogate-safe truncation may shorten the text; reflect that + // back into the box without re-entering this handler's work. + _updatingCommandTimelineSelection = true; + box.Text(winrt::hstring{ normalized }); + box.SelectionStart(gsl::narrow_cast(normalized.size())); + _updatingCommandTimelineSelection = false; + } + + const auto presentation = get_self(_core)->FilterCommandTimeline(normalized); + _renderCommandTimeline(presentation); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _closeCommandTimeline(true); + } + } + + // Keys handled while the filter box owns focus. Everything not claimed here + // stays with the text box, so ordinary caret editing, selection, and IME + // composition behave normally and nothing reaches the PTY. + void TermControl::_CommandTimelineSearchKeyDown(const IInspectable& /*sender*/, + const Input::KeyRoutedEventArgs& args) + { + if (!_commandTimelineOpen || _IsClosing()) + { + return; + } + + switch (args.Key()) + { + case Windows::System::VirtualKey::Up: + get_self(_core)->NavigateCommandTimeline(winTerm::CommandTimeline::NavigationAction::Previous); + _refreshCommandTimeline(); + args.Handled(true); + return; + case Windows::System::VirtualKey::Down: + get_self(_core)->NavigateCommandTimeline(winTerm::CommandTimeline::NavigationAction::Next); + _refreshCommandTimeline(); + args.Handled(true); + return; + case Windows::System::VirtualKey::Enter: + _loadCommandTimelineSelection(); + args.Handled(true); + return; + case Windows::System::VirtualKey::Escape: + // Escape clears a non-empty query first and only closes the overlay + // once the query is already empty. + if (!CommandTimelineSearchBox().Text().empty()) + { + CommandTimelineSearchBox().Text({}); + } + else + { + _closeCommandTimeline(true); + } + args.Handled(true); + return; + default: + // Left/Right and every printable key stay with the text box. + return; + } + } + + // Honors commandTimeline.enabled. Disabling hides the handle and closes an + // overlay that is already open. + void TermControl::_applyCommandTimelineEnabledSetting() + { + if (_IsClosing()) + { + return; + } + + auto enabled = true; + try + { + enabled = get_self(_core)->CommandTimelineEnabled(); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + } + + CommandTimelineHandle().Visibility(enabled ? Visibility::Visible : Visibility::Collapsed); + if (!enabled && _commandTimelineOpen) + { + _closeCommandTimeline(true); + } + } + // Builds the per-entry context menu. Every action resolves through the // model's stable CommandId rather than the XAML row, so a list rebuild // between the right-click and the invocation cannot retarget the action. @@ -2734,11 +2871,24 @@ namespace winrt::Microsoft::Terminal::Control::implementation if (presentation.visibleEntries.empty()) { + using winTerm::CommandTimeline::CommandTimelineEmptyState; list.Visibility(Visibility::Collapsed); - CommandTimelineEmptyText().Text( - presentation.capability == winTerm::CommandTimeline::ShellIntegrationCapability::Limited ? - RS_(L"CommandTimelineUnavailable") : - RS_(L"CommandTimelineNoCommands")); + // Each state is distinct: an unsupported shell is never reported as + // simply having run no commands. + const auto emptyText = [&]() { + switch (presentation.emptyState) + { + case CommandTimelineEmptyState::NoMatchingCommands: + return RS_(L"CommandTimelineNoMatchingCommands"); + case CommandTimelineEmptyState::ShellUnsupported: + return RS_(L"CommandTimelineUnavailable"); + case CommandTimelineEmptyState::NoCommands: + return RS_(L"CommandTimelineNoCommands"); + default: + return RS_(L"CommandTimelineWaitingForShell"); + } + }(); + CommandTimelineEmptyText().Text(emptyText); CommandTimelineEmptyText().Visibility(Visibility::Visible); _updatingCommandTimelineSelection = false; return; @@ -2797,9 +2947,12 @@ namespace winrt::Microsoft::Terminal::Control::implementation Windows::UI::Xaml::Automation::AutomationProperties::SetPositionInSet( item, gsl::narrow_cast(presentation.firstVisibleIndex + slot + 1)); + // Position and set size describe the filtered result, so assistive + // technology announces "3 of 7 matches", not a position within the + // unfiltered history. Windows::UI::Xaml::Automation::AutomationProperties::SetSizeOfSet( item, - gsl::narrow_cast(presentation.totalEntryCount)); + gsl::narrow_cast(presentation.filteredEntryCount)); item.PointerEntered([weakThis = get_weak(), slot](const auto&, const auto&) { if (auto control{ weakThis.get() }; @@ -2876,6 +3029,9 @@ namespace winrt::Microsoft::Terminal::Control::implementation _commandTimelineOpen = false; _clearCommandTimelinePendingLoad(); _updatingCommandTimelineSelection = true; + // The query, the filtered projection, and every materialized row are + // released together; nothing about a search survives a close. + CommandTimelineSearchBox().Text({}); CommandTimelineList().Items().Clear(); CommandTimelineList().SelectedIndex(-1); _updatingCommandTimelineSelection = false; @@ -3359,6 +3515,21 @@ namespace winrt::Microsoft::Terminal::Control::implementation return true; } + // With commandTimeline.enabled off, the shortcut must not open the + // overlay at all. + try + { + if (!get_self(_core)->CommandTimelineEnabled()) + { + return false; + } + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + return false; + } + try { _commandTimelineOpen = true; diff --git a/src/cascadia/TerminalControl/TermControl.h b/src/cascadia/TerminalControl/TermControl.h index e1a95de00..6109eb63d 100644 --- a/src/cascadia/TerminalControl/TermControl.h +++ b/src/cascadia/TerminalControl/TermControl.h @@ -403,6 +403,10 @@ namespace winrt::Microsoft::Terminal::Control::implementation bool _copyCommandTimelineOutput(); void _setCommandTimelineStatus(const winrt::hstring& status); void _clearCommandTimelinePendingLoad(); + void _CommandTimelineSearchTextChanged(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Controls::TextChangedEventArgs& e); + void _CommandTimelineSearchKeyDown(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Input::KeyRoutedEventArgs& e); + void _focusCommandTimelineSearch(); + void _applyCommandTimelineEnabledSetting(); bool _tryHandleCommandTimelineWheel(const Windows::Foundation::Point& position, int delta); bool _isPointOverCommandTimeline(const Windows::Foundation::Point& position) noexcept; size_t _commandTimelineVisibleCapacity() noexcept; diff --git a/src/cascadia/TerminalControl/TermControl.xaml b/src/cascadia/TerminalControl/TermControl.xaml index d13148a86..25a498913 100644 --- a/src/cascadia/TerminalControl/TermControl.xaml +++ b/src/cascadia/TerminalControl/TermControl.xaml @@ -1383,10 +1383,18 @@ - + + + + + + + + + + + + + + diff --git a/src/cascadia/TerminalSettingsEditor/GlobalAppearanceViewModel.h b/src/cascadia/TerminalSettingsEditor/GlobalAppearanceViewModel.h index 723917eb9..ab0118ac0 100644 --- a/src/cascadia/TerminalSettingsEditor/GlobalAppearanceViewModel.h +++ b/src/cascadia/TerminalSettingsEditor/GlobalAppearanceViewModel.h @@ -51,6 +51,8 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation PERMANENT_OBSERVABLE_PROJECTED_SETTING(_GlobalSettings, VisualProgressEnabled); PERMANENT_OBSERVABLE_PROJECTED_SETTING(_GlobalSettings, VisualProgressRecognizeCliProgress); PERMANENT_OBSERVABLE_PROJECTED_SETTING(_GlobalSettings, VisualProgressReplaceRecognizedOutput); + PERMANENT_OBSERVABLE_PROJECTED_SETTING(_GlobalSettings, CommandTimelineEnabled); + PERMANENT_OBSERVABLE_PROJECTED_SETTING(_GlobalSettings, CommandTimelineHistoryLimit); private: Model::GlobalAppSettings _GlobalSettings; diff --git a/src/cascadia/TerminalSettingsEditor/GlobalAppearanceViewModel.idl b/src/cascadia/TerminalSettingsEditor/GlobalAppearanceViewModel.idl index 77358e818..0623884f4 100644 --- a/src/cascadia/TerminalSettingsEditor/GlobalAppearanceViewModel.idl +++ b/src/cascadia/TerminalSettingsEditor/GlobalAppearanceViewModel.idl @@ -49,5 +49,7 @@ namespace Microsoft.Terminal.Settings.Editor PERMANENT_OBSERVABLE_PROJECTED_SETTING(Boolean, VisualProgressEnabled); PERMANENT_OBSERVABLE_PROJECTED_SETTING(Boolean, VisualProgressRecognizeCliProgress); PERMANENT_OBSERVABLE_PROJECTED_SETTING(Boolean, VisualProgressReplaceRecognizedOutput); + PERMANENT_OBSERVABLE_PROJECTED_SETTING(Boolean, CommandTimelineEnabled); + PERMANENT_OBSERVABLE_PROJECTED_SETTING(Int32, CommandTimelineHistoryLimit); } } diff --git a/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw index e0439805e..00f52e940 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw @@ -2873,6 +2873,21 @@ Reset pane resizing to defaults + + Command timeline + + + Show command timeline + + + When enabled, each pane shows a handle that opens a list of the commands that pane has run. Requires shell integration. + + + Commands remembered per pane + + + How many commands each pane keeps in memory, between 50 and 5000. Lowering this discards the oldest commands immediately. Command history is never written to disk. + Visual progress diff --git a/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl b/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl index 1aeff7b38..8dd5d88a5 100644 --- a/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl +++ b/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl @@ -118,6 +118,8 @@ namespace Microsoft.Terminal.Settings.Model INHERITABLE_SETTING(WindowingMode, WindowingBehavior); INHERITABLE_SETTING(Boolean, TrimBlockSelection); INHERITABLE_SETTING(Boolean, DetectURLs); + INHERITABLE_SETTING(Boolean, CommandTimelineEnabled); + INHERITABLE_SETTING(Int32, CommandTimelineHistoryLimit); INHERITABLE_SETTING(Boolean, MinimizeToNotificationArea); INHERITABLE_SETTING(Boolean, AlwaysShowNotificationIcon); INHERITABLE_SETTING(IVector, DisabledProfileSources); diff --git a/src/cascadia/TerminalSettingsModel/MTSMSettings.h b/src/cascadia/TerminalSettingsModel/MTSMSettings.h index 4f8fc6bd9..984d9d518 100644 --- a/src/cascadia/TerminalSettingsModel/MTSMSettings.h +++ b/src/cascadia/TerminalSettingsModel/MTSMSettings.h @@ -41,6 +41,8 @@ Author(s): X(bool, UseBackgroundImageForWindow, "experimental.useBackgroundImageForWindow", false) \ X(bool, TrimBlockSelection, "trimBlockSelection", true) \ X(bool, DetectURLs, "experimental.detectURLs", true) \ + X(bool, CommandTimelineEnabled, "commandTimeline.enabled", true) \ + X(int32_t, CommandTimelineHistoryLimit, "commandTimeline.historyLimit", 500) \ X(bool, AlwaysShowTabs, "alwaysShowTabs", true) \ X(Model::NewTabPosition, NewTabPosition, "newTabPosition", Model::NewTabPosition::AfterLastTab) \ X(bool, ShowTitleInTitlebar, "showTerminalTitleInTitlebar", true) \ diff --git a/src/cascadia/TerminalSettingsModel/defaults.json b/src/cascadia/TerminalSettingsModel/defaults.json index be57538c9..fc62f990c 100644 --- a/src/cascadia/TerminalSettingsModel/defaults.json +++ b/src/cascadia/TerminalSettingsModel/defaults.json @@ -32,6 +32,10 @@ "focusFollowMouse": false, "minimizeToNotificationArea": false, "alwaysShowNotificationIcon": false, + + // Command timeline + "commandTimeline.enabled": true, + "commandTimeline.historyLimit": 500, "profiles": [ diff --git a/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp b/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp index 7ad2a0447..3f662afec 100644 --- a/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp +++ b/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp @@ -49,6 +49,18 @@ namespace ControlUnitTests TEST_METHOD(ActionLoadedInputIsReleasedOnEviction); TEST_METHOD(ActionCopyResolvesStableCommandIdNotRowIndex); TEST_METHOD(ActionOutputRequiresLiveNativeRangeAndIsNeverCached); + TEST_METHOD(SearchEmptyQueryProjectsEveryCommand); + TEST_METHOD(SearchMatchesLiterallyAndCaseInsensitively); + TEST_METHOD(SearchQueryTruncationIsSurrogateSafe); + TEST_METHOD(SearchKeepsStableSelectionAcrossQueryChanges); + TEST_METHOD(SearchSelectsNearestSurvivingMatch); + TEST_METHOD(SearchNavigationAndWheelWalkFilteredProjection); + TEST_METHOD(SearchNewCommandFollowsLatestOnlyWhenMatching); + TEST_METHOD(ShellDegradationStatesAreDistinct); + TEST_METHOD(HistoryLimitEvictsOldestFirstAndNeverResurrects); + TEST_METHOD(HistoryLimitClampsToSupportedRange); + TEST_METHOD(SearchCloseReleasesQueryAndProjection); + TEST_METHOD(SearchStressAtMaximumHistoryLimit); TEST_CLASS_SETUP(ModuleSetup) { @@ -934,4 +946,386 @@ namespace ControlUnitTests const auto pendingOutput = actions.Prepare(CommandActionKind::CopyOutput, pending.Entries(), pendingView, CommandLoadPolicy{}); VERIFY_ARE_EQUAL(static_cast(CommandActionStatus::OutputUnavailable), static_cast(pendingOutput.status)); } + + void CommandTimelineTests::SearchEmptyQueryProjectsEveryCommand() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"alpha", 0); + Execute(index, 2, revision, L"beta", 0); + Execute(index, 3, revision, L"gamma", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + const auto opened = navigation.Open(index.Entries(), viewState, index.Capability(), 10); + VERIFY_ARE_EQUAL(size_t{ 3 }, opened.filteredEntryCount); + VERIFY_IS_FALSE(opened.filtered); + + const auto cleared = navigation.SetQuery(L"", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 3 }, cleared.filteredEntryCount); + VERIFY_ARE_EQUAL(size_t{ 3 }, cleared.visibleEntries.size()); + VERIFY_IS_FALSE(cleared.filtered); + VERIFY_ARE_EQUAL(static_cast(CommandTimelineEmptyState::None), static_cast(cleared.emptyState)); + } + + void CommandTimelineTests::SearchMatchesLiterallyAndCaseInsensitively() + { + VERIFY_IS_TRUE(CommandTimelineQueryMatches(L"Git Status", L"git")); + VERIFY_IS_TRUE(CommandTimelineQueryMatches(L"git status", L"STATUS")); + VERIFY_IS_TRUE(CommandTimelineQueryMatches(L"aaaa", L"aaa")); + VERIFY_IS_TRUE(CommandTimelineQueryMatches(L"anything", L"")); + VERIFY_IS_FALSE(CommandTimelineQueryMatches(L"git", L"git status")); + // Literal matching only: regex and glob metacharacters are just text. + VERIFY_IS_FALSE(CommandTimelineQueryMatches(L"git status", L"g.*s")); + VERIFY_IS_FALSE(CommandTimelineQueryMatches(L"git status", L"gs")); + + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"GIT status", 0); + Execute(index, 2, revision, L"cargo build", 0); + Execute(index, 3, revision, L"git log", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 10); + const auto filtered = navigation.SetQuery(L"git", index.Entries(), viewState, index.Capability()); + + VERIFY_ARE_EQUAL(size_t{ 2 }, filtered.filteredEntryCount); + VERIFY_IS_TRUE(filtered.filtered); + VERIFY_ARE_EQUAL(size_t{ 3 }, filtered.totalEntryCount); + VERIFY_ARE_EQUAL(std::wstring{ L"GIT status" }, filtered.visibleEntries[0].commandText); + VERIFY_ARE_EQUAL(std::wstring{ L"git log" }, filtered.visibleEntries[1].commandText); + } + + void CommandTimelineTests::SearchQueryTruncationIsSurrogateSafe() + { + // Exactly at the boundary nothing is dropped. + const std::wstring atLimit(MaxCommandTimelineQueryLength, L'x'); + VERIFY_ARE_EQUAL(MaxCommandTimelineQueryLength, NormalizeCommandTimelineQuery(atLimit).size()); + + const std::wstring overLimit(MaxCommandTimelineQueryLength + 50, L'x'); + VERIFY_ARE_EQUAL(MaxCommandTimelineQueryLength, NormalizeCommandTimelineQuery(overLimit).size()); + + // A surrogate pair straddling the boundary must not be split. U+1F600 + // is encoded as the pair D83D DE00. + std::wstring straddling(MaxCommandTimelineQueryLength - 1, L'x'); + straddling.push_back(L'\xD83D'); + straddling.push_back(L'\xDE00'); + const auto truncated = NormalizeCommandTimelineQuery(straddling); + VERIFY_ARE_EQUAL(MaxCommandTimelineQueryLength - 1, truncated.size()); + VERIFY_IS_FALSE(truncated.back() >= 0xD800 && truncated.back() <= 0xDBFF); + + // A pair that ends before the boundary is preserved whole. + std::wstring safe(MaxCommandTimelineQueryLength - 2, L'y'); + safe.push_back(L'\xD83D'); + safe.push_back(L'\xDE00'); + safe.append(10, L'z'); + const auto kept = NormalizeCommandTimelineQuery(safe); + VERIFY_ARE_EQUAL(MaxCommandTimelineQueryLength, kept.size()); + VERIFY_ARE_EQUAL(L'\xDE00', kept[MaxCommandTimelineQueryLength - 1]); + + // Non-ASCII text still matches literally. + VERIFY_IS_TRUE(CommandTimelineQueryMatches(L"echo \x4F60\x597D", L"\x4F60\x597D")); + } + + void CommandTimelineTests::SearchKeepsStableSelectionAcrossQueryChanges() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"git status", 0); + Execute(index, 2, revision, L"cargo build", 0); + Execute(index, 3, revision, L"git log", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 10); + navigation.SelectVisibleEntry(0, index.Entries(), viewState, index.Capability()); + const auto selected = *viewState.selectedCommandId; + VERIFY_ARE_EQUAL(uint64_t{ 1 }, selected.sequence); + + // The selected command still matches, so it stays selected. + navigation.SetQuery(L"git", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(selected, *viewState.selectedCommandId); + + // Clearing the query must not move the selection either. + navigation.SetQuery(L"", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(selected, *viewState.selectedCommandId); + } + + void CommandTimelineTests::SearchSelectsNearestSurvivingMatch() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"git status", 0); + Execute(index, 2, revision, L"cargo build", 0); + Execute(index, 3, revision, L"git log", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 10); + navigation.SelectVisibleEntry(1, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 2 }, viewState.selectedCommandId->sequence); + + // "cargo build" no longer matches, so the nearest surviving match takes + // the selection rather than dropping to the bottom. + const auto filtered = navigation.SetQuery(L"git", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 2 }, filtered.filteredEntryCount); + VERIFY_ARE_EQUAL(uint64_t{ 1 }, viewState.selectedCommandId->sequence); + + // A query with no results leaves the selected command untouched. + const auto none = navigation.SetQuery(L"zzz", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 0 }, none.filteredEntryCount); + VERIFY_IS_TRUE(none.visibleEntries.empty()); + VERIFY_ARE_EQUAL(static_cast(CommandTimelineEmptyState::NoMatchingCommands), + static_cast(none.emptyState)); + VERIFY_ARE_EQUAL(uint64_t{ 1 }, viewState.selectedCommandId->sequence); + } + + void CommandTimelineTests::SearchNavigationAndWheelWalkFilteredProjection() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + for (uint64_t mark = 1; mark <= 10; ++mark) + { + // Odd marks match the query, even marks do not. + Execute(index, mark, revision, mark % 2 ? L"git command" : L"other command", 0); + } + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 3); + const auto filtered = navigation.SetQuery(L"git", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 5 }, filtered.filteredEntryCount); + VERIFY_ARE_EQUAL(uint64_t{ 9 }, viewState.selectedCommandId->sequence); + + // Up moves to the previous *match*, skipping the non-matching command. + navigation.Navigate(NavigationAction::Previous, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 7 }, viewState.selectedCommandId->sequence); + navigation.Navigate(NavigationAction::Previous, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 5 }, viewState.selectedCommandId->sequence); + navigation.Navigate(NavigationAction::Next, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 7 }, viewState.selectedCommandId->sequence); + + // Page edges stay inside the filtered viewport. + const auto pageFirst = navigation.Navigate(NavigationAction::PageFirst, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 0 }, pageFirst.selectedVisualSlot); + const auto pageLast = navigation.Navigate(NavigationAction::PageLast, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 2 }, pageLast.selectedVisualSlot); + + // Wheel accumulation also walks matches only. A partial delta moves + // nothing; a full notch moves exactly one match. + const auto beforeWheel = viewState.selectedCommandId->sequence; + navigation.ApplyWheelDelta(40, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(beforeWheel, viewState.selectedCommandId->sequence); + navigation.ApplyWheelDelta(80, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_NOT_EQUAL(beforeWheel, viewState.selectedCommandId->sequence); + VERIFY_ARE_EQUAL(uint64_t{ 1 }, viewState.selectedCommandId->sequence % 2); + } + + void CommandTimelineTests::SearchNewCommandFollowsLatestOnlyWhenMatching() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"git one", 0); + Execute(index, 2, revision, L"git two", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 10); + navigation.SetQuery(L"git", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 2 }, viewState.selectedCommandId->sequence); + + // Following latest: a new matching command becomes the selection. + Execute(index, 3, revision, L"git three", 0); + navigation.Reconcile(index.Entries(), viewState, index.Capability(), 10); + VERIFY_ARE_EQUAL(uint64_t{ 3 }, viewState.selectedCommandId->sequence); + + // A new command that does not match must not change the selection. + Execute(index, 4, revision, L"unrelated", 0); + const auto afterNonMatching = navigation.Reconcile(index.Entries(), viewState, index.Capability(), 10); + VERIFY_ARE_EQUAL(uint64_t{ 3 }, viewState.selectedCommandId->sequence); + VERIFY_ARE_EQUAL(size_t{ 3 }, afterNonMatching.filteredEntryCount); + + // Browsing older history: a new matching command must not yank the + // selection back to the bottom. + navigation.Navigate(NavigationAction::Previous, index.Entries(), viewState, index.Capability()); + const auto browsing = viewState.selectedCommandId->sequence; + VERIFY_ARE_EQUAL(uint64_t{ 2 }, browsing); + Execute(index, 5, revision, L"git five", 0); + navigation.Reconcile(index.Entries(), viewState, index.Capability(), 10); + VERIFY_ARE_EQUAL(browsing, viewState.selectedCommandId->sequence); + } + + void CommandTimelineTests::ShellDegradationStatesAreDistinct() + { + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + + // Capability not yet determined and nothing recorded. + const auto waiting = navigation.Open({}, viewState, ShellIntegrationCapability::Unknown, 5); + VERIFY_ARE_EQUAL(static_cast(CommandTimelineEmptyState::WaitingForShell), + static_cast(waiting.emptyState)); + + // Shell cannot report complete boundaries. + CommandTimelineNavigationModel limitedNavigation; + CommandTimelineViewState limitedView; + const auto unsupported = limitedNavigation.Open({}, limitedView, ShellIntegrationCapability::Limited, 5); + VERIFY_ARE_EQUAL(static_cast(CommandTimelineEmptyState::ShellUnsupported), + static_cast(unsupported.emptyState)); + + // Shell integration works but no command has run yet. + CommandTimelineNavigationModel fullNavigation; + CommandTimelineViewState fullView; + const auto noCommands = fullNavigation.Open({}, fullView, ShellIntegrationCapability::Full, 5); + VERIFY_ARE_EQUAL(static_cast(CommandTimelineEmptyState::NoCommands), + static_cast(noCommands.emptyState)); + + // Commands exist but the filter excludes all of them. + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"present", 0); + CommandTimelineNavigationModel filteredNavigation; + CommandTimelineViewState filteredView; + filteredNavigation.Open(index.Entries(), filteredView, index.Capability(), 5); + const auto noMatches = filteredNavigation.SetQuery(L"absent", index.Entries(), filteredView, index.Capability()); + VERIFY_ARE_EQUAL(static_cast(CommandTimelineEmptyState::NoMatchingCommands), + static_cast(noMatches.emptyState)); + } + + void CommandTimelineTests::HistoryLimitEvictsOldestFirstAndNeverResurrects() + { + CommandTimelineIndex index{ PaneOne }; + index.SetHistoryLimit(MinCommandTimelineHistoryLimit); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, index.HistoryLimit()); + + uint64_t revision = 0; + for (uint64_t mark = 1; mark <= 60; ++mark) + { + Execute(index, mark, revision, L"command " + std::to_wstring(mark), 0); + } + + // Bounded at the limit, oldest dropped first, newest retained. + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, index.Entries().size()); + VERIFY_ARE_EQUAL(uint64_t{ 11 }, index.Entries().front().id.sequence); + VERIFY_ARE_EQUAL(uint64_t{ 60 }, index.Entries().back().id.sequence); + + // Lowering the limit evicts immediately. + CommandTimelineIndex lowered{ PaneTwo }; + uint64_t loweredRevision = 0; + for (uint64_t mark = 1; mark <= 120; ++mark) + { + Execute(lowered, mark, loweredRevision, L"cmd", 0); + } + VERIFY_ARE_EQUAL(size_t{ 120 }, lowered.Entries().size()); + lowered.SetHistoryLimit(MinCommandTimelineHistoryLimit); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, lowered.Entries().size()); + VERIFY_ARE_EQUAL(uint64_t{ 71 }, lowered.Entries().front().id.sequence); + + // Raising the limit must not resurrect anything, and sequence numbers + // are never reissued. + lowered.SetHistoryLimit(1000); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, lowered.Entries().size()); + VERIFY_ARE_EQUAL(uint64_t{ 71 }, lowered.Entries().front().id.sequence); + VERIFY_ARE_EQUAL(uint64_t{ 121 }, lowered.NextSequence()); + Execute(lowered, 500, loweredRevision, L"after raise", 0); + VERIFY_ARE_EQUAL(uint64_t{ 121 }, lowered.Entries().back().id.sequence); + } + + void CommandTimelineTests::HistoryLimitClampsToSupportedRange() + { + VERIFY_ARE_EQUAL(DefaultCommandTimelineHistoryLimit, size_t{ 500 }); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, ClampCommandTimelineHistoryLimit(0)); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, ClampCommandTimelineHistoryLimit(-1)); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, ClampCommandTimelineHistoryLimit(49)); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, ClampCommandTimelineHistoryLimit(50)); + VERIFY_ARE_EQUAL(size_t{ 500 }, ClampCommandTimelineHistoryLimit(500)); + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, ClampCommandTimelineHistoryLimit(5000)); + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, ClampCommandTimelineHistoryLimit(999999)); + + // An out-of-range configured value is clamped rather than rejected. + CommandTimelineIndex index{ PaneOne }; + index.SetHistoryLimit(ClampCommandTimelineHistoryLimit(1)); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, index.HistoryLimit()); + index.SetHistoryLimit(ClampCommandTimelineHistoryLimit(100000)); + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, index.HistoryLimit()); + } + + void CommandTimelineTests::SearchCloseReleasesQueryAndProjection() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"git status", 0); + Execute(index, 2, revision, L"cargo build", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 5); + navigation.SetQuery(L"git", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(std::wstring{ L"git" }, navigation.Query()); + VERIFY_ARE_EQUAL(size_t{ 1 }, navigation.FilteredCount()); + + navigation.Close(); + VERIFY_IS_TRUE(navigation.Query().empty()); + VERIFY_ARE_EQUAL(size_t{ 0 }, navigation.FilteredCount()); + VERIFY_IS_FALSE(navigation.IsOpen()); + VERIFY_IS_FALSE(navigation.WheelSettlePending()); + VERIFY_ARE_EQUAL(0, navigation.WheelDeltaRemainder()); + + // Repeated close stays safe. + navigation.Close(); + VERIFY_IS_FALSE(navigation.IsOpen()); + + // Reopening starts unfiltered: a query is never persisted. + const auto reopened = navigation.Open(index.Entries(), viewState, index.Capability(), 5); + VERIFY_IS_TRUE(navigation.Query().empty()); + VERIFY_IS_FALSE(reopened.filtered); + VERIFY_ARE_EQUAL(size_t{ 2 }, reopened.filteredEntryCount); + } + + void CommandTimelineTests::SearchStressAtMaximumHistoryLimit() + { + CommandTimelineIndex index{ PaneOne }; + index.SetHistoryLimit(MaxCommandTimelineHistoryLimit); + + uint64_t revision = 0; + for (uint64_t mark = 1; mark <= MaxCommandTimelineHistoryLimit; ++mark) + { + Execute(index, mark, revision, L"command " + std::to_wstring(mark), 0); + } + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, index.Entries().size()); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + const auto capacity = size_t{ 20 }; + navigation.Open(index.Entries(), viewState, index.Capability(), capacity); + + // Worst case: a query that matches every entry. Only the visible rows + // are ever materialized. + const auto broad = navigation.SetQuery(L"command", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, broad.filteredEntryCount); + VERIFY_ARE_EQUAL(capacity, broad.visibleEntries.size()); + + // Repeated filtering must stay bounded and must not accumulate. + for (int pass = 0; pass < 25; ++pass) + { + const auto narrow = navigation.SetQuery(L"command 4242", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 1 }, narrow.filteredEntryCount); + VERIFY_ARE_EQUAL(size_t{ 1 }, narrow.visibleEntries.size()); + + const auto missing = navigation.SetQuery(L"no such command", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 0 }, missing.filteredEntryCount); + VERIFY_IS_TRUE(missing.visibleEntries.empty()); + + const auto all = navigation.SetQuery(L"", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, all.filteredEntryCount); + VERIFY_ARE_EQUAL(capacity, all.visibleEntries.size()); + } + + // Command text stays bounded and no output is ever cached. + VERIFY_IS_TRUE(index.CachedCommandTextCharacters() <= + MaxCommandTimelineHistoryLimit * DefaultMaxCachedCommandText); + + navigation.Close(); + VERIFY_ARE_EQUAL(size_t{ 0 }, navigation.FilteredCount()); + } } diff --git a/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj b/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj index dd3b87a37..6e811a283 100644 --- a/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj +++ b/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj @@ -50,9 +50,11 @@ + + Create diff --git a/src/cascadia/UnitTests_SettingsModel/WinTermCommandTimelineTests.cpp b/src/cascadia/UnitTests_SettingsModel/WinTermCommandTimelineTests.cpp new file mode 100644 index 000000000..39acf572c --- /dev/null +++ b/src/cascadia/UnitTests_SettingsModel/WinTermCommandTimelineTests.cpp @@ -0,0 +1,132 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" + +#include "../TerminalSettingsModel/GlobalAppSettings.h" +#include "../../winterm/CommandTimeline/CommandTimelineModel.h" + +using namespace WEX::TestExecution; +using namespace winTerm::CommandTimeline; +using namespace winrt::Microsoft::Terminal::Settings::Model; + +namespace SettingsModelUnitTests +{ + class WinTermCommandTimelineTests + { + TEST_CLASS(WinTermCommandTimelineTests); + + TEST_METHOD(CommandTimelineSettingsUseStableDefaults); + TEST_METHOD(CommandTimelineSettingsRoundTripThroughJson); + TEST_METHOD(CommandTimelineHistoryLimitIsClampedAtRuntime); + TEST_METHOD(CommandTimelineSettingsTolerateOutOfRangeAndOddValues); + + private: + static winrt::com_ptr FromJson(const Json::Value& json) + { + return implementation::GlobalAppSettings::FromJson(json); + } + }; + + void WinTermCommandTimelineTests::CommandTimelineSettingsUseStableDefaults() + { + Json::Value missing{ Json::objectValue }; + const auto defaults = FromJson(missing); + + VERIFY_IS_TRUE(defaults->CommandTimelineEnabled()); + VERIFY_ARE_EQUAL(500, defaults->CommandTimelineHistoryLimit()); + + // An absent setting must not be written back out, so an existing + // settings file needs no migration. + VERIFY_IS_FALSE(defaults->ToJson().isMember("commandTimeline.enabled")); + VERIFY_IS_FALSE(defaults->ToJson().isMember("commandTimeline.historyLimit")); + + // The documented default matches the model's own default. + VERIFY_ARE_EQUAL(DefaultCommandTimelineHistoryLimit, + static_cast(defaults->CommandTimelineHistoryLimit())); + } + + void WinTermCommandTimelineTests::CommandTimelineSettingsRoundTripThroughJson() + { + Json::Value json{ Json::objectValue }; + json["commandTimeline.enabled"] = false; + json["commandTimeline.historyLimit"] = 1200; + + const auto settings = FromJson(json); + VERIFY_IS_FALSE(settings->CommandTimelineEnabled()); + VERIFY_ARE_EQUAL(1200, settings->CommandTimelineHistoryLimit()); + + const auto serialized = settings->ToJson(); + VERIFY_IS_FALSE(serialized["commandTimeline.enabled"].asBool()); + VERIFY_ARE_EQUAL(1200, serialized["commandTimeline.historyLimit"].asInt()); + + // Reparsing the serialized form reproduces the same values. + const auto reparsed = FromJson(serialized); + VERIFY_IS_FALSE(reparsed->CommandTimelineEnabled()); + VERIFY_ARE_EQUAL(1200, reparsed->CommandTimelineHistoryLimit()); + + Json::Value enabled{ Json::objectValue }; + enabled["commandTimeline.enabled"] = true; + enabled["commandTimeline.historyLimit"] = 50; + const auto minimum = FromJson(enabled); + VERIFY_IS_TRUE(minimum->CommandTimelineEnabled()); + VERIFY_ARE_EQUAL(50, minimum->CommandTimelineHistoryLimit()); + } + + void WinTermCommandTimelineTests::CommandTimelineHistoryLimitIsClampedAtRuntime() + { + // Whatever the settings file says, the value the runtime uses is always + // inside the supported range. + for (const auto configured : { -5000, -1, 0, 1, 49 }) + { + Json::Value json{ Json::objectValue }; + json["commandTimeline.historyLimit"] = configured; + const auto settings = FromJson(json); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, + ClampCommandTimelineHistoryLimit(settings->CommandTimelineHistoryLimit())); + } + + for (const auto configured : { 5001, 100000 }) + { + Json::Value json{ Json::objectValue }; + json["commandTimeline.historyLimit"] = configured; + const auto settings = FromJson(json); + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, + ClampCommandTimelineHistoryLimit(settings->CommandTimelineHistoryLimit())); + } + + for (const auto configured : { 50, 500, 2500, 5000 }) + { + Json::Value json{ Json::objectValue }; + json["commandTimeline.historyLimit"] = configured; + const auto settings = FromJson(json); + VERIFY_ARE_EQUAL(static_cast(configured), + ClampCommandTimelineHistoryLimit(settings->CommandTimelineHistoryLimit())); + } + } + + void WinTermCommandTimelineTests::CommandTimelineSettingsTolerateOutOfRangeAndOddValues() + { + // An out-of-range value is accepted by the parser and clamped by the + // runtime rather than failing the whole settings load. + Json::Value extreme{ Json::objectValue }; + extreme["commandTimeline.historyLimit"] = 999999; + const auto settings = FromJson(extreme); + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, + ClampCommandTimelineHistoryLimit(settings->CommandTimelineHistoryLimit())); + + // A pane honors the clamped value: entries stay bounded even when the + // configured limit was nonsense. + CommandTimelineIndex index{ winrt::guid{} }; + index.SetHistoryLimit(ClampCommandTimelineHistoryLimit(settings->CommandTimelineHistoryLimit())); + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, index.HistoryLimit()); + + Json::Value tooSmall{ Json::objectValue }; + tooSmall["commandTimeline.historyLimit"] = 1; + // Note: `small` is a Windows SDK macro, so this cannot be named that. + const auto belowRange = FromJson(tooSmall); + CommandTimelineIndex bounded{ winrt::guid{} }; + bounded.SetHistoryLimit(ClampCommandTimelineHistoryLimit(belowRange->CommandTimelineHistoryLimit())); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, bounded.HistoryLimit()); + } +} diff --git a/src/cascadia/WindowsTerminal/WindowsTerminal.rc b/src/cascadia/WindowsTerminal/WindowsTerminal.rc index bdb257cf0..0e87afc96 100644 --- a/src/cascadia/WindowsTerminal/WindowsTerminal.rc +++ b/src/cascadia/WindowsTerminal/WindowsTerminal.rc @@ -83,8 +83,8 @@ IDI_APPICON_HC_WHITE ICON "..\\..\\..\\res\\terminal\\imag #if defined(WT_BRANDING_WINTERM) 1 VERSIONINFO - FILEVERSION 1,2,3,0 - PRODUCTVERSION 1,2,3,0 + FILEVERSION 1,2,4,0 + PRODUCTVERSION 1,2,4,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG @@ -101,12 +101,12 @@ BEGIN BEGIN VALUE "CompanyName", "helloThisWorld\0" VALUE "FileDescription", "winTerm Terminal Host\0" - VALUE "FileVersion", "1.2.3.0\0" + VALUE "FileVersion", "1.2.4.0\0" VALUE "InternalName", "WindowsTerminal\0" VALUE "LegalCopyright", "Copyright (c) winTerm contributors. Portions copyright Microsoft Corporation.\0" VALUE "OriginalFilename", "WindowsTerminal.exe\0" VALUE "ProductName", "winTerm\0" - VALUE "ProductVersion", "1.2.3\0" + VALUE "ProductVersion", "1.2.4\0" END END BLOCK "VarFileInfo" diff --git a/src/cascadia/inc/ControlProperties.h b/src/cascadia/inc/ControlProperties.h index 91ae71178..c9bab8cac 100644 --- a/src/cascadia/inc/ControlProperties.h +++ b/src/cascadia/inc/ControlProperties.h @@ -52,6 +52,8 @@ X(bool, AllowKittyKeyboardMode, true) \ X(winrt::hstring, StartingTitle) \ X(bool, DetectURLs, true) \ + X(bool, CommandTimelineEnabled, true) \ + X(int32_t, CommandTimelineHistoryLimit, 500) \ X(bool, AutoMarkPrompts) \ X(bool, RepositionCursorWithMouse, false) \ X(bool, RainbowSuggestions) \ diff --git a/src/cascadia/wt/wt.rc b/src/cascadia/wt/wt.rc index e3149252f..4985150f0 100644 --- a/src/cascadia/wt/wt.rc +++ b/src/cascadia/wt/wt.rc @@ -58,8 +58,8 @@ IDI_APPICON ICON "..\\..\\..\\res\\terminal.ico" #if defined(WT_BRANDING_WINTERM) 1 VERSIONINFO - FILEVERSION 1,2,3,0 - PRODUCTVERSION 1,2,3,0 + FILEVERSION 1,2,4,0 + PRODUCTVERSION 1,2,4,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG @@ -76,12 +76,12 @@ BEGIN BEGIN VALUE "CompanyName", "helloThisWorld\0" VALUE "FileDescription", "winTerm Launcher\0" - VALUE "FileVersion", "1.2.3.0\0" + VALUE "FileVersion", "1.2.4.0\0" VALUE "InternalName", "winTerm\0" VALUE "LegalCopyright", "Copyright (c) winTerm contributors. Portions copyright Microsoft Corporation.\0" VALUE "OriginalFilename", "winTerm.exe\0" VALUE "ProductName", "winTerm\0" - VALUE "ProductVersion", "1.2.3\0" + VALUE "ProductVersion", "1.2.4\0" END END BLOCK "VarFileInfo" diff --git a/src/winterm-tools/winterm-shim/winterm-shim.rc b/src/winterm-tools/winterm-shim/winterm-shim.rc index f1a2454ab..7f700a2b4 100644 --- a/src/winterm-tools/winterm-shim/winterm-shim.rc +++ b/src/winterm-tools/winterm-shim/winterm-shim.rc @@ -4,8 +4,8 @@ #include 1 VERSIONINFO - FILEVERSION 1,2,3,0 - PRODUCTVERSION 1,2,3,0 + FILEVERSION 1,2,4,0 + PRODUCTVERSION 1,2,4,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG @@ -22,12 +22,12 @@ BEGIN BEGIN VALUE "CompanyName", "helloThisWorld\0" VALUE "FileDescription", "winTerm Shell Integration Helper\0" - VALUE "FileVersion", "1.2.3.0\0" + VALUE "FileVersion", "1.2.4.0\0" VALUE "InternalName", "winterm-shim\0" VALUE "LegalCopyright", "Copyright (c) winTerm contributors.\0" VALUE "OriginalFilename", "winterm-shim.exe\0" VALUE "ProductName", "winTerm\0" - VALUE "ProductVersion", "1.2.3\0" + VALUE "ProductVersion", "1.2.4\0" END END BLOCK "VarFileInfo" diff --git a/src/winterm/Branding/ReleaseMetadata.h b/src/winterm/Branding/ReleaseMetadata.h index da5a9deb8..3768520ff 100644 --- a/src/winterm/Branding/ReleaseMetadata.h +++ b/src/winterm/Branding/ReleaseMetadata.h @@ -25,7 +25,7 @@ namespace winTerm::Branding { inline constexpr std::wstring_view Publisher{ L"helloThisWorld" }; - inline constexpr std::wstring_view ApplicationVersion{ L"1.2.3" }; + inline constexpr std::wstring_view ApplicationVersion{ L"1.2.4" }; inline constexpr std::wstring_view ReleaseChannel{ L"Stable" }; inline constexpr std::wstring_view CommitSha{ WINTERM_BUILD_COMMIT_SHA }; inline constexpr std::wstring_view BuildTimestamp{ WINTERM_BUILD_TIMESTAMP }; diff --git a/src/winterm/Branding/version.json b/src/winterm/Branding/version.json index c830a7075..d7f64f369 100644 --- a/src/winterm/Branding/version.json +++ b/src/winterm/Branding/version.json @@ -1,10 +1,10 @@ { - "applicationVersion": "1.2.3", - "packageVersion": "1.2.3.0", - "moduleVersion": "1.2.3", + "applicationVersion": "1.2.4", + "packageVersion": "1.2.4.0", + "moduleVersion": "1.2.4", "modulePrerelease": "", "channel": "stable", - "tag": "v1.2.3", + "tag": "v1.2.4", "workspaceSchemaVersion": 2, "dockingModelVersion": 1, "shellProtocolVersion": 1, diff --git a/src/winterm/CommandTimeline/CommandTimelineModel.cpp b/src/winterm/CommandTimeline/CommandTimelineModel.cpp index 68916846b..2a70e29d9 100644 --- a/src/winterm/CommandTimeline/CommandTimelineModel.cpp +++ b/src/winterm/CommandTimeline/CommandTimelineModel.cpp @@ -30,6 +30,56 @@ namespace winTerm::CommandTimeline } } + std::wstring NormalizeCommandTimelineQuery(const std::wstring_view query) + { + if (query.size() <= MaxCommandTimelineQueryLength) + { + return std::wstring{ query }; + } + + std::wstring result{ query.substr(0, MaxCommandTimelineQueryLength) }; + // Never cut a surrogate pair in half; drop the orphaned lead unit. + if (!result.empty() && IsHighSurrogate(result.back())) + { + result.pop_back(); + } + return result; + } + + bool CommandTimelineQueryMatches(const std::wstring_view commandText, + const std::wstring_view query) noexcept + { + if (query.empty()) + { + return true; + } + if (commandText.size() < query.size()) + { + return false; + } + + // Literal case-insensitive substring search. Deliberately not a regex + // and deliberately not fuzzy. + const auto found = std::search( + commandText.begin(), commandText.end(), query.begin(), query.end(), [](const wchar_t left, const wchar_t right) noexcept { + return std::towlower(left) == std::towlower(right); + }); + return found != commandText.end(); + } + + size_t ClampCommandTimelineHistoryLimit(const int64_t historyLimit) noexcept + { + if (historyLimit <= static_cast(MinCommandTimelineHistoryLimit)) + { + return MinCommandTimelineHistoryLimit; + } + if (historyLimit >= static_cast(MaxCommandTimelineHistoryLimit)) + { + return MaxCommandTimelineHistoryLimit; + } + return static_cast(historyLimit); + } + void CommandTimelineViewState::Reset() noexcept { selectedCommandId.reset(); @@ -77,7 +127,7 @@ namespace winTerm::CommandTimeline if (_open) { _reconcile(entries, viewState, false); - _moveSelection(action, entries); + _moveSelection(action); _syncViewState(entries, viewState); } return _snapshot(entries, capability); @@ -92,16 +142,39 @@ namespace winTerm::CommandTimeline if (_open) { _reconcile(entries, viewState, false); - const auto index = _firstVisibleIndex + visualSlot; - if (visualSlot < _visibleCapacity && index < entries.size()) + const auto position = _firstVisiblePosition + visualSlot; + if (visualSlot < _visibleCapacity && position < _filtered.size()) { - _selectedIndex = index; + _selectedPosition = position; _syncViewState(entries, viewState); } } return _snapshot(entries, capability); } + // Applying a query only changes which commands are projected. The selected + // command is preserved whenever it still matches, so typing never silently + // retargets an action. + CommandTimelinePresentationSnapshot CommandTimelineNavigationModel::SetQuery( + const std::wstring_view query, + const std::span entries, + CommandTimelineViewState& viewState, + const ShellIntegrationCapability capability) + { + auto normalized = NormalizeCommandTimelineQuery(query); + if (normalized != _query) + { + _query = std::move(normalized); + _wheelDeltaRemainder = 0; + } + + if (_open) + { + _reconcile(entries, viewState, false); + } + return _snapshot(entries, capability); + } + CommandTimelinePresentationSnapshot CommandTimelineNavigationModel::ApplyWheelDelta( const int delta, const std::span entries, @@ -115,6 +188,10 @@ namespace winTerm::CommandTimeline } _reconcile(entries, viewState, false); + if (_filtered.empty()) + { + return _snapshot(entries, capability); + } _wheelSettlePending = true; const auto accumulated = static_cast(_wheelDeltaRemainder) + delta; _wheelDeltaRemainder = gsl::narrow_cast(std::clamp( @@ -125,7 +202,7 @@ namespace winTerm::CommandTimeline while (_wheelDeltaRemainder >= deltaPerEntry) { _wheelDeltaRemainder -= deltaPerEntry; - if (!_moveSelection(NavigationAction::Previous, entries)) + if (!_moveSelection(NavigationAction::Previous)) { _wheelDeltaRemainder = 0; break; @@ -134,7 +211,7 @@ namespace winTerm::CommandTimeline while (_wheelDeltaRemainder <= -deltaPerEntry) { _wheelDeltaRemainder += deltaPerEntry; - if (!_moveSelection(NavigationAction::Next, entries)) + if (!_moveSelection(NavigationAction::Next)) { _wheelDeltaRemainder = 0; break; @@ -153,9 +230,15 @@ namespace winTerm::CommandTimeline void CommandTimelineNavigationModel::Close() noexcept { - _selectedIndex.reset(); + // Closing drops the query and the filtered projection along with the + // rest of the UI-only state; a query is never persisted. + _filtered.clear(); + _filtered.shrink_to_fit(); + _query.clear(); + _query.shrink_to_fit(); + _selectedPosition.reset(); _lastLatestCommandId.reset(); - _firstVisibleIndex = 0; + _firstVisiblePosition = 0; _visibleCapacity = 1; _wheelDeltaRemainder = 0; _open = false; @@ -182,88 +265,181 @@ namespace winTerm::CommandTimeline return _visibleCapacity; } + const std::wstring& CommandTimelineNavigationModel::Query() const noexcept + { + return _query; + } + + size_t CommandTimelineNavigationModel::FilteredCount() const noexcept + { + return _filtered.size(); + } + + void CommandTimelineNavigationModel::_rebuildFilter(const std::span entries) + { + _filtered.clear(); + if (_query.empty()) + { + _filtered.resize(entries.size()); + for (size_t index = 0; index < entries.size(); ++index) + { + _filtered[index] = index; + } + return; + } + + // Only the bounded cached command text is searched. Output is never + // consulted and the terminal buffer is never rescanned. + for (size_t index = 0; index < entries.size(); ++index) + { + if (CommandTimelineQueryMatches(entries[index].cachedCommandText, _query)) + { + _filtered.emplace_back(index); + } + } + } + + std::optional CommandTimelineNavigationModel::_positionOf(const size_t entryIndex) const noexcept + { + const auto found = std::lower_bound(_filtered.begin(), _filtered.end(), entryIndex); + if (found == _filtered.end() || *found != entryIndex) + { + return std::nullopt; + } + return gsl::narrow_cast(std::distance(_filtered.begin(), found)); + } + + // Chooses the surviving match closest to a command that is no longer in the + // projection, so a selection is never silently dropped to the bottom. + size_t CommandTimelineNavigationModel::_nearestPosition(const std::span entries, + const CommandId& id) const noexcept + { + if (_filtered.empty()) + { + return 0; + } + if (id.paneSessionId != entries[_filtered.front()].id.paneSessionId) + { + return _filtered.size() - 1; + } + + const auto next = std::lower_bound( + _filtered.begin(), _filtered.end(), id.sequence, [&](const size_t index, const uint64_t sequence) { + return entries[index].id.sequence < sequence; + }); + if (next == _filtered.begin()) + { + return 0; + } + if (next == _filtered.end()) + { + return _filtered.size() - 1; + } + + const auto nextPosition = gsl::narrow_cast(std::distance(_filtered.begin(), next)); + const auto previousPosition = nextPosition - 1; + const auto nextDistance = entries[*next].id.sequence - id.sequence; + const auto previousDistance = id.sequence - entries[_filtered[previousPosition]].id.sequence; + return previousDistance <= nextDistance ? previousPosition : nextPosition; + } + void CommandTimelineNavigationModel::_reconcile(const std::span entries, CommandTimelineViewState& viewState, const bool allowFollowLatest) { - if (entries.empty()) + _rebuildFilter(entries); + + if (_filtered.empty()) { - _selectedIndex.reset(); - _lastLatestCommandId.reset(); - _firstVisibleIndex = 0; + _selectedPosition.reset(); + _lastLatestCommandId = entries.empty() ? std::optional{} : entries.back().id; + _firstVisiblePosition = 0; _syncViewState(entries, viewState); return; } - const auto wasFollowingLatest = allowFollowLatest && - _lastLatestCommandId.has_value() && - viewState.selectedCommandId == _lastLatestCommandId; + // Following latest only applies when the newest command is itself part + // of the current projection. A new command that does not match the + // query must not pull the selection anywhere. + const auto latestPosition = _filtered.back(); + const auto followingLatest = allowFollowLatest && + _lastLatestCommandId.has_value() && + viewState.selectedCommandId == _lastLatestCommandId && + !entries.empty() && + entries[latestPosition].id == entries.back().id; + std::optional selected; - if (wasFollowingLatest) + if (followingLatest) { - selected = entries.size() - 1; + selected = _filtered.size() - 1; } else if (viewState.selectedCommandId.has_value()) { - selected = _findCommand(entries, *viewState.selectedCommandId); + if (const auto index = _findCommand(entries, *viewState.selectedCommandId)) + { + selected = _positionOf(*index); + } if (!selected.has_value()) { - selected = _findNearestCommand(entries, *viewState.selectedCommandId); + selected = _nearestPosition(entries, *viewState.selectedCommandId); } } else { - selected = entries.size() - 1; + selected = _filtered.size() - 1; } - _selectedIndex = selected; + _selectedPosition = selected; - const auto maxFirst = entries.size() > _visibleCapacity ? entries.size() - _visibleCapacity : 0; + const auto maxFirst = _filtered.size() > _visibleCapacity ? _filtered.size() - _visibleCapacity : 0; std::optional restoredAnchor; if (viewState.visibleNativeAnchor.has_value()) { - const auto anchor = std::find_if(entries.begin(), entries.end(), [&](const auto& entry) { - return entry.nativeMarkId == *viewState.visibleNativeAnchor; - }); - if (anchor != entries.end()) + for (size_t position = 0; position < _filtered.size(); ++position) { - restoredAnchor = gsl::narrow_cast(std::distance(entries.begin(), anchor)); + if (entries[_filtered[position]].nativeMarkId == *viewState.visibleNativeAnchor) + { + restoredAnchor = position; + break; + } } } if (restoredAnchor.has_value()) { - _firstVisibleIndex = std::min(*restoredAnchor, maxFirst); + _firstVisiblePosition = std::min(*restoredAnchor, maxFirst); } else { const auto desiredSlot = std::min(viewState.selectedVisualSlot.value_or(_visibleCapacity - 1), _visibleCapacity - 1); - _firstVisibleIndex = *_selectedIndex > desiredSlot ? *_selectedIndex - desiredSlot : 0; - _firstVisibleIndex = std::min(_firstVisibleIndex, maxFirst); + _firstVisiblePosition = *_selectedPosition > desiredSlot ? *_selectedPosition - desiredSlot : 0; + _firstVisiblePosition = std::min(_firstVisiblePosition, maxFirst); } - if (*_selectedIndex < _firstVisibleIndex) + if (*_selectedPosition < _firstVisiblePosition) { - _firstVisibleIndex = *_selectedIndex; + _firstVisiblePosition = *_selectedPosition; } - else if (*_selectedIndex >= _firstVisibleIndex + _visibleCapacity) + else if (*_selectedPosition >= _firstVisiblePosition + _visibleCapacity) { - _firstVisibleIndex = *_selectedIndex - _visibleCapacity + 1; + _firstVisiblePosition = *_selectedPosition - _visibleCapacity + 1; + } + _firstVisiblePosition = std::min(_firstVisiblePosition, maxFirst); + if (!entries.empty()) + { + _lastLatestCommandId = entries.back().id; } - _firstVisibleIndex = std::min(_firstVisibleIndex, maxFirst); - _lastLatestCommandId = entries.back().id; _syncViewState(entries, viewState); } - bool CommandTimelineNavigationModel::_moveSelection(const NavigationAction action, - const std::span entries) + bool CommandTimelineNavigationModel::_moveSelection(const NavigationAction action) { - if (!_selectedIndex.has_value() || entries.empty()) + if (!_selectedPosition.has_value() || _filtered.empty()) { return false; } - auto next = *_selectedIndex; + auto next = *_selectedPosition; switch (action) { case NavigationAction::Previous: @@ -274,28 +450,28 @@ namespace winTerm::CommandTimeline --next; break; case NavigationAction::Next: - if (next + 1 >= entries.size()) + if (next + 1 >= _filtered.size()) { return false; } ++next; break; case NavigationAction::PageFirst: - next = _firstVisibleIndex; + next = _firstVisiblePosition; break; case NavigationAction::PageLast: - next = std::min(entries.size(), _firstVisibleIndex + _visibleCapacity) - 1; + next = std::min(_filtered.size(), _firstVisiblePosition + _visibleCapacity) - 1; break; } - _selectedIndex = next; - if (next < _firstVisibleIndex) + _selectedPosition = next; + if (next < _firstVisiblePosition) { - _firstVisibleIndex = next; + _firstVisiblePosition = next; } - else if (next >= _firstVisibleIndex + _visibleCapacity) + else if (next >= _firstVisiblePosition + _visibleCapacity) { - _firstVisibleIndex = next - _visibleCapacity + 1; + _firstVisiblePosition = next - _visibleCapacity + 1; } return true; } @@ -303,47 +479,77 @@ namespace winTerm::CommandTimeline void CommandTimelineNavigationModel::_syncViewState(const std::span entries, CommandTimelineViewState& viewState) const { - if (!_selectedIndex.has_value() || entries.empty()) + if (!_selectedPosition.has_value() || _filtered.empty() || entries.empty()) { - viewState.selectedCommandId.reset(); + // A query with no results must not clear the selected command; the + // selection is only dropped when there are no entries at all. + if (entries.empty()) + { + viewState.selectedCommandId.reset(); + } viewState.visibleNativeAnchor.reset(); viewState.selectedVisualSlot.reset(); return; } - viewState.selectedCommandId = entries[*_selectedIndex].id; - viewState.visibleNativeAnchor = entries[_firstVisibleIndex].nativeMarkId; - viewState.selectedVisualSlot = *_selectedIndex - _firstVisibleIndex; + viewState.selectedCommandId = entries[_filtered[*_selectedPosition]].id; + viewState.visibleNativeAnchor = entries[_filtered[_firstVisiblePosition]].nativeMarkId; + viewState.selectedVisualSlot = *_selectedPosition - _firstVisiblePosition; } CommandTimelinePresentationSnapshot CommandTimelineNavigationModel::_snapshot( const std::span entries, const ShellIntegrationCapability capability) const { + const auto emptyState = [&]() noexcept { + if (!_filtered.empty()) + { + return CommandTimelineEmptyState::None; + } + if (!_query.empty() && !entries.empty()) + { + return CommandTimelineEmptyState::NoMatchingCommands; + } + switch (capability) + { + case ShellIntegrationCapability::Limited: + return CommandTimelineEmptyState::ShellUnsupported; + case ShellIntegrationCapability::Full: + return CommandTimelineEmptyState::NoCommands; + default: + return CommandTimelineEmptyState::WaitingForShell; + } + }(); + CommandTimelinePresentationSnapshot result{ .capability = capability, + .emptyState = emptyState, .totalEntryCount = entries.size(), - .firstVisibleIndex = _firstVisibleIndex, - .selectedVisualSlot = _selectedIndex.has_value() ? *_selectedIndex - _firstVisibleIndex : 0, + .filteredEntryCount = _filtered.size(), + .firstVisibleIndex = _firstVisiblePosition, + .selectedVisualSlot = _selectedPosition.has_value() ? *_selectedPosition - _firstVisiblePosition : 0, .wheelDeltaRemainder = _wheelDeltaRemainder, .open = _open, + .filtered = !_query.empty(), .wheelSettlePending = _wheelSettlePending, }; - if (!_open || entries.empty()) + if (!_open || _filtered.empty()) { return result; } - const auto end = std::min(entries.size(), _firstVisibleIndex + _visibleCapacity); - result.visibleEntries.reserve(end - _firstVisibleIndex); - for (auto index = _firstVisibleIndex; index < end; ++index) + // Only the rows that fit on screen are materialized, whatever the size + // of the history behind them. + const auto end = std::min(_filtered.size(), _firstVisiblePosition + _visibleCapacity); + result.visibleEntries.reserve(end - _firstVisiblePosition); + for (auto position = _firstVisiblePosition; position < end; ++position) { - const auto& entry = entries[index]; + const auto& entry = entries[_filtered[position]]; result.visibleEntries.emplace_back(CommandTimelineVisibleEntry{ .id = entry.id, .commandText = entry.cachedCommandText, .executionResult = _effectiveResult(entry), - .selected = _selectedIndex == index, + .selected = _selectedPosition == position, }); } return result; @@ -826,6 +1032,46 @@ namespace winTerm::CommandTimeline } } + void CommandTimelineIndex::SetHistoryLimit(const size_t historyLimit) + { + const auto clamped = ClampCommandTimelineHistoryLimit(gsl::narrow_cast(historyLimit)); + if (_closed || _historyLimit == clamped) + { + return; + } + + _historyLimit = clamped; + // Raising the limit must not resurrect anything: entries already + // dropped are gone, and sequence numbers are never reissued. + if (_applyHistoryLimit()) + { + _rebuildLookups(); + _incrementRevision(); + } + } + + // Drops the oldest entries first until the history fits the limit. + bool CommandTimelineIndex::_applyHistoryLimit() + { + if (_entries.size() <= _historyLimit) + { + return false; + } + + const auto excess = _entries.size() - _historyLimit; + for (size_t index = 0; index < excess; ++index) + { + _lifecycleByNativeMark.erase(_entries[index].nativeMarkId); + } + _entries.erase(_entries.begin(), _entries.begin() + gsl::narrow_cast(excess)); + return true; + } + + size_t CommandTimelineIndex::HistoryLimit() const noexcept + { + return _historyLimit; + } + void CommandTimelineIndex::Close() noexcept { if (_closed) @@ -937,6 +1183,8 @@ namespace winTerm::CommandTimeline .nativeRangeValid = true, .shellIntegrationCapability = _capability, }); + // The newest command is always kept; eviction takes from the front. + _applyHistoryLimit(); _rebuildLookups(); return _entries.back(); } @@ -1041,6 +1289,7 @@ namespace winTerm::CommandTimeline _nativeRevision = snapshot.nativeRevision; _bootstrapped = true; + changed = _applyHistoryLimit() || changed; _rebuildLookups(); if (changed) { diff --git a/src/winterm/CommandTimeline/CommandTimelineModel.h b/src/winterm/CommandTimeline/CommandTimelineModel.h index 465503095..dfb93214f 100644 --- a/src/winterm/CommandTimeline/CommandTimelineModel.h +++ b/src/winterm/CommandTimeline/CommandTimelineModel.h @@ -19,6 +19,25 @@ namespace winTerm::CommandTimeline { inline constexpr size_t DefaultMaxCachedCommandText = 4096; + // Phase 4 search and history bounds. The query cap is expressed in UTF-16 + // code units because that is what the input box and the cached command text + // are measured in. + inline constexpr size_t MaxCommandTimelineQueryLength = 256; + inline constexpr size_t DefaultCommandTimelineHistoryLimit = 500; + inline constexpr size_t MinCommandTimelineHistoryLimit = 50; + inline constexpr size_t MaxCommandTimelineHistoryLimit = 5000; + + // Truncates a query to MaxCommandTimelineQueryLength without ever leaving a + // lone surrogate behind. + std::wstring NormalizeCommandTimelineQuery(std::wstring_view query); + + // Literal, case-insensitive substring match. No regex, no fuzzy matching, + // and only ever applied to cached command text. + bool CommandTimelineQueryMatches(std::wstring_view commandText, std::wstring_view query) noexcept; + + // Clamps a configured history limit into the supported range. + size_t ClampCommandTimelineHistoryLimit(int64_t historyLimit) noexcept; + enum class CommandLifecycleState : uint8_t { Command, @@ -114,15 +133,30 @@ namespace winTerm::CommandTimeline bool selected{ false }; }; + // Which "nothing to show" message the overlay should present. These are + // distinct states on purpose: an unsupported shell must never be reported + // as simply having run no commands. + enum class CommandTimelineEmptyState : uint8_t + { + None, + WaitingForShell, + ShellUnsupported, + NoCommands, + NoMatchingCommands, + }; + struct CommandTimelinePresentationSnapshot { std::vector visibleEntries; ShellIntegrationCapability capability{ ShellIntegrationCapability::Unknown }; + CommandTimelineEmptyState emptyState{ CommandTimelineEmptyState::None }; size_t totalEntryCount{}; + size_t filteredEntryCount{}; size_t firstVisibleIndex{}; size_t selectedVisualSlot{}; int wheelDeltaRemainder{}; bool open{ false }; + bool filtered{ false }; bool wheelSettlePending{ false }; }; @@ -152,6 +186,10 @@ namespace winTerm::CommandTimeline CommandTimelineViewState& viewState, ShellIntegrationCapability capability, int deltaPerEntry = DefaultWheelDeltaPerEntry); + CommandTimelinePresentationSnapshot SetQuery(std::wstring_view query, + std::span entries, + CommandTimelineViewState& viewState, + ShellIntegrationCapability capability); void SettleWheel() noexcept; void Close() noexcept; @@ -159,26 +197,36 @@ namespace winTerm::CommandTimeline bool WheelSettlePending() const noexcept; int WheelDeltaRemainder() const noexcept; size_t VisibleCapacity() const noexcept; + const std::wstring& Query() const noexcept; + size_t FilteredCount() const noexcept; private: void _reconcile(std::span entries, CommandTimelineViewState& viewState, bool allowFollowLatest); - bool _moveSelection(NavigationAction action, - std::span entries); + void _rebuildFilter(std::span entries); + bool _moveSelection(NavigationAction action); void _syncViewState(std::span entries, CommandTimelineViewState& viewState) const; CommandTimelinePresentationSnapshot _snapshot(std::span entries, ShellIntegrationCapability capability) const; + std::optional _positionOf(size_t entryIndex) const noexcept; + size_t _nearestPosition(std::span entries, + const CommandId& id) const noexcept; static std::optional _findCommand(std::span entries, const CommandId& id) noexcept; static size_t _findNearestCommand(std::span entries, const CommandId& id) noexcept; static ExecutionResult _effectiveResult(const CommandTimelineEntry& entry) noexcept; - std::optional _selectedIndex; + // Positions into _filtered, which holds indices into the caller's + // entries span. An empty query keeps every entry, so the unfiltered + // case walks the same code path. + std::vector _filtered; + std::wstring _query; + std::optional _selectedPosition; std::optional _lastLatestCommandId; - size_t _firstVisibleIndex{}; + size_t _firstVisiblePosition{}; size_t _visibleCapacity{ 1 }; int _wheelDeltaRemainder{}; bool _open{ false }; @@ -306,6 +354,7 @@ namespace winTerm::CommandTimeline void ReconcileReflow(std::span survivingNativeMarkIds, uint64_t nativeRevision); void SetCapabilityFallback(ShellIntegrationCapability capability); + void SetHistoryLimit(size_t historyLimit); void Close() noexcept; const CommandTimelineEntry* Find(const CommandId& id) const noexcept; @@ -316,6 +365,7 @@ namespace winTerm::CommandTimeline uint64_t Revision() const noexcept; uint64_t NativeRevision() const noexcept; uint64_t NextSequence() const noexcept; + size_t HistoryLimit() const noexcept; size_t BootstrapScanCount() const noexcept; size_t CachedCommandTextCharacters() const noexcept; bool IsBootstrapped() const noexcept; @@ -328,6 +378,7 @@ namespace winTerm::CommandTimeline void _finishIncompleteCurrent(std::optional timestamp); void _observeLifecycleCapability(uint64_t nativeMarkId, LifecycleEventKind kind); void _refreshEntryCapabilities(); + bool _applyHistoryLimit(); void _rebuildLookups(); void _incrementRevision() noexcept; @@ -339,6 +390,7 @@ namespace winTerm::CommandTimeline std::unordered_map _lifecycleByNativeMark; std::optional _currentSequence; uint64_t _nextSequence{ 1 }; + size_t _historyLimit{ DefaultCommandTimelineHistoryLimit }; uint64_t _revision{}; uint64_t _nativeRevision{}; size_t _bootstrapScanCount{}; diff --git a/src/winterm/Workspaces/Model/WorkspaceDescriptor.h b/src/winterm/Workspaces/Model/WorkspaceDescriptor.h index b37c9c29e..375e890bf 100644 --- a/src/winterm/Workspaces/Model/WorkspaceDescriptor.h +++ b/src/winterm/Workspaces/Model/WorkspaceDescriptor.h @@ -219,7 +219,7 @@ namespace winTerm::Workspaces std::string createdAt; std::string updatedAt; WorkspaceSource source{ WorkspaceSource::User }; - std::string applicationVersion{ "1.2.3" }; + std::string applicationVersion{ "1.2.4" }; uint32_t protocolVersion{ 1 }; uint32_t dockingModelVersion{ DockingModelVersion }; WorkspaceStartupBehavior startupBehavior; diff --git a/src/winterm/Workspaces/Persistence/WorkspaceSerializer.cpp b/src/winterm/Workspaces/Persistence/WorkspaceSerializer.cpp index 023703f96..17f19856a 100644 --- a/src/winterm/Workspaces/Persistence/WorkspaceSerializer.cpp +++ b/src/winterm/Workspaces/Persistence/WorkspaceSerializer.cpp @@ -618,7 +618,7 @@ WorkspaceDescriptor WorkspaceSerializer::FromJson(const Json::Value& json, const throw std::runtime_error("The workspace source is not supported."); } workspace.source = *source; - workspace.applicationVersion = StringOrDefault(json, "applicationVersion", "1.2.3"); + workspace.applicationVersion = StringOrDefault(json, "applicationVersion", "1.2.4"); workspace.protocolVersion = UIntOrDefault(json, "protocolVersion", 1); workspace.dockingModelVersion = UIntOrDefault(json, "dockingModelVersion", DockingModelVersion); if (const auto& startup = json["startupBehavior"]; !startup.isNull()) From 6f90593764954cf5b1120b47cd40f856637f9dd9 Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Tue, 4 Aug 2026 01:07:40 +0800 Subject: [PATCH 3/3] docs: record Phase 3 merge to main in Phase 4 checkpoint notes Phase 3 was squash-merged to main as 5fd2172 through pull request #29 while Phase 4 was in progress, so the Phase 4 base branch is main rather than the Phase 3 feature branch. --- CHANGELOG.md | 2 ++ docs/current-progress.md | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dac1744f..8815eb5e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,8 @@ public GitHub Release. GitHub Latest and README public downloads remain on v1.2.0. There is still no persistent history, no output cache, and no telemetry. +- Builds on Command Timeline Phase 3, squash-merged to `main` as `5fd2172` + through pull request #29. ## 1.2.3 - 2026-08-03 diff --git a/docs/current-progress.md b/docs/current-progress.md index 32a9f9040..6549fccd6 100644 --- a/docs/current-progress.md +++ b/docs/current-progress.md @@ -5,8 +5,8 @@ Last updated: 2026-08-04 ## Repository state - Branch: `feature/command-timeline-v1.3.0-phase4` -- Base branch: `feature/command-timeline-v1.3.0` (Command Timeline Phase 3, - tag `v1.2.3`, pull request #29 awaiting owner merge) +- Base branch: `main`. Command Timeline Phase 3 (tag `v1.2.3`) was squash-merged + to `main` as `5fd2172` through pull request #29. - Microsoft Terminal upstream revision: `1cea42d433253d95c4487a3037db48197b5e72f4` - Engineering application and PowerShell module version: `1.2.4`