diff --git a/.github/actions/spelling/allow/allow.txt b/.github/actions/spelling/allow/allow.txt index 276d428c77..cc0625b7aa 100644 --- a/.github/actions/spelling/allow/allow.txt +++ b/.github/actions/spelling/allow/allow.txt @@ -208,6 +208,7 @@ rrx rtx rubyw runtimes +rusqlite rustc rustflags rustup diff --git a/NOTICE.md b/NOTICE.md index 0c98697c0c..260b92f2ed 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -560,6 +560,7 @@ if it were canonical for the whole group. - **agent-client-protocol** v1.0.0 -- [https://github.com/agentclientprotocol/rust-sdk](https://github.com/agentclientprotocol/rust-sdk) -- `Apache-2.0` - **agent-client-protocol-derive** v1.0.0 -- [https://github.com/agentclientprotocol/rust-sdk](https://github.com/agentclientprotocol/rust-sdk) -- `Apache-2.0` - **agent-client-protocol-schema** v1.1.0 -- [https://github.com/agentclientprotocol/agent-client-protocol](https://github.com/agentclientprotocol/agent-client-protocol) -- `Apache-2.0` +- **ahash** v0.8.12 -- [https://github.com/tkaitchuck/ahash](https://github.com/tkaitchuck/ahash) -- `Apache-2.0 OR MIT` - **aho-corasick** v1.1.4 -- [https://github.com/BurntSushi/aho-corasick](https://github.com/BurntSushi/aho-corasick) -- `MIT OR Unlicense` - **allocator-api2** v0.2.21 -- [https://github.com/zakarumych/allocator-api2](https://github.com/zakarumych/allocator-api2) -- `Apache-2.0 OR MIT` - **anstream** v0.6.21 -- [https://github.com/rust-cli/anstyle.git](https://github.com/rust-cli/anstyle.git) -- `Apache-2.0 OR MIT` @@ -627,6 +628,8 @@ if it were canonical for the whole group. - **euclid** v0.22.13 -- [https://github.com/servo/euclid](https://github.com/servo/euclid) -- `Apache-2.0 OR MIT` - **event-listener** v5.4.1 -- [https://github.com/smol-rs/event-listener](https://github.com/smol-rs/event-listener) -- `Apache-2.0 OR MIT` - **event-listener-strategy** v0.5.4 -- [https://github.com/smol-rs/event-listener-strategy](https://github.com/smol-rs/event-listener-strategy) -- `Apache-2.0 OR MIT` +- **fallible-iterator** v0.3.0 -- [https://github.com/sfackler/rust-fallible-iterator](https://github.com/sfackler/rust-fallible-iterator) -- `Apache-2.0 OR MIT` +- **fallible-streaming-iterator** v0.1.9 -- [https://github.com/sfackler/fallible-streaming-iterator](https://github.com/sfackler/fallible-streaming-iterator) -- `Apache-2.0 OR MIT` - **fancy-regex** v0.11.0 -- [https://github.com/fancy-regex/fancy-regex](https://github.com/fancy-regex/fancy-regex) -- `MIT` - **fastrand** v2.4.1 -- [https://github.com/smol-rs/fastrand](https://github.com/smol-rs/fastrand) -- `Apache-2.0 OR MIT` - **fdeflate** v0.3.7 -- [https://github.com/image-rs/fdeflate](https://github.com/image-rs/fdeflate) -- `Apache-2.0 OR MIT` @@ -656,7 +659,9 @@ if it were canonical for the whole group. - **globset** v0.4.18 -- [https://github.com/BurntSushi/ripgrep/tree/master/crates/globset](https://github.com/BurntSushi/ripgrep/tree/master/crates/globset) -- `MIT OR Unlicense` - **globwalk** v0.8.1 -- [https://github.com/gilnaa/globwalk](https://github.com/gilnaa/globwalk) -- `MIT` - **hashbrown** v0.12.3 -- [https://github.com/rust-lang/hashbrown](https://github.com/rust-lang/hashbrown) -- `Apache-2.0 OR MIT` +- **hashbrown** v0.14.5 -- [https://github.com/rust-lang/hashbrown](https://github.com/rust-lang/hashbrown) -- `Apache-2.0 OR MIT` - **hashbrown** v0.16.1 -- [https://github.com/rust-lang/hashbrown](https://github.com/rust-lang/hashbrown) -- `Apache-2.0 OR MIT` +- **hashlink** v0.9.1 -- [https://github.com/kyren/hashlink](https://github.com/kyren/hashlink) -- `Apache-2.0 OR MIT` - **heck** v0.5.0 -- [https://github.com/withoutboats/heck](https://github.com/withoutboats/heck) -- `Apache-2.0 OR MIT` - **hex** v0.4.3 -- [https://github.com/KokaKiwi/rust-hex](https://github.com/KokaKiwi/rust-hex) -- `Apache-2.0 OR MIT` - **ident_case** v1.0.1 -- [https://github.com/TedDriggs/ident_case](https://github.com/TedDriggs/ident_case) -- `Apache-2.0 OR MIT` @@ -674,6 +679,7 @@ if it were canonical for the whole group. - **lab** v0.11.0 -- [https://github.com/TooManyBees/lab](https://github.com/TooManyBees/lab) -- `MIT` - **lazy_static** v1.5.0 -- [https://github.com/rust-lang-nursery/lazy-static.rs](https://github.com/rust-lang-nursery/lazy-static.rs) -- `Apache-2.0 OR MIT` - **libc** v0.2.183 -- [https://github.com/rust-lang/libc](https://github.com/rust-lang/libc) -- `Apache-2.0 OR MIT` +- **libsqlite3-sys** v0.28.0 -- [https://github.com/rusqlite/rusqlite](https://github.com/rusqlite/rusqlite) -- `MIT` - **line-clipping** v0.3.5 -- [https://github.com/joshka/line-clipping](https://github.com/joshka/line-clipping) -- `Apache-2.0 OR MIT` - **litrs** v1.0.0 -- [https://github.com/LukasKalbertodt/litrs](https://github.com/LukasKalbertodt/litrs) -- `Apache-2.0 OR MIT` - **lock_api** v0.4.14 -- [https://github.com/Amanieu/parking_lot](https://github.com/Amanieu/parking_lot) -- `Apache-2.0 OR MIT` @@ -733,6 +739,7 @@ if it were canonical for the whole group. - **regex** v1.12.3 -- [https://github.com/rust-lang/regex](https://github.com/rust-lang/regex) -- `Apache-2.0 OR MIT` - **regex-automata** v0.4.14 -- [https://github.com/rust-lang/regex](https://github.com/rust-lang/regex) -- `Apache-2.0 OR MIT` - **regex-syntax** v0.8.10 -- [https://github.com/rust-lang/regex](https://github.com/rust-lang/regex) -- `Apache-2.0 OR MIT` +- **rusqlite** v0.31.0 -- [https://github.com/rusqlite/rusqlite](https://github.com/rusqlite/rusqlite) -- `MIT` - **rust-i18n** v3.1.5 -- [https://github.com/longbridge/rust-i18n](https://github.com/longbridge/rust-i18n) -- `MIT` - **rust-i18n-macro** v3.1.5 -- [https://github.com/longbridge/rust-i18n](https://github.com/longbridge/rust-i18n) -- `MIT` - **rust-i18n-support** v3.1.5 -- [https://github.com/longbridge/rust-i18n](https://github.com/longbridge/rust-i18n) -- `MIT` @@ -833,6 +840,7 @@ if it were canonical for the whole group. - **windows-targets** v0.48.5 -- [https://github.com/microsoft/windows-rs](https://github.com/microsoft/windows-rs) -- `Apache-2.0 OR MIT` - **winnow** v0.7.15 -- [https://github.com/winnow-rs/winnow](https://github.com/winnow-rs/winnow) -- `MIT` - **winsafe** v0.0.19 -- [https://github.com/rodrigocfd/winsafe](https://github.com/rodrigocfd/winsafe) -- `MIT` +- **zerocopy** v0.8.54 -- [https://github.com/google/zerocopy](https://github.com/google/zerocopy) -- `Apache-2.0 OR BSD-2-Clause OR MIT` - **zmij** v1.0.21 -- [https://github.com/dtolnay/zmij](https://github.com/dtolnay/zmij) -- `MIT` ## License texts @@ -858,7 +866,7 @@ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH RE ### `Apache-2.0` -Applies to 191 crate(s) (directly or via composite identifiers): adler2 v2.0.1, agent-client-protocol v1.0.0, agent-client-protocol-derive v1.0.0, agent-client-protocol-schema v1.1.0, allocator-api2 v0.2.21, anstream v0.6.21, anstyle v1.0.13, anstyle-parse v0.2.7, ... (+183 more) +Applies to 197 crate(s) (directly or via composite identifiers): adler2 v2.0.1, agent-client-protocol v1.0.0, agent-client-protocol-derive v1.0.0, agent-client-protocol-schema v1.1.0, ahash v0.8.12, allocator-api2 v0.2.21, anstream v0.6.21, anstyle v1.0.13, ... (+189 more) _Canonical text reproduced from upstream `SPDX:Apache-2.0`:_ @@ -959,6 +967,7 @@ Copyright (c) 2015 Alice Maz Copyright (c) 2015 Andrew Gallant Copyright (c) 2015 nwin Copyright (c) 2015 The Rust Project Developers +Copyright (c) 2015 The rust-openssl-verify Developers Copyright (c) 2015-2018 The winapi-rs Developers Copyright (c) 2015-2020 The rust-hex Developers Copyright (c) 2016 Alex Crichton @@ -966,6 +975,7 @@ Copyright (c) 2016 Amanieu d'Antras Copyright (c) 2016 Artyom Pavlov Copyright (c) 2016 Dylan Ede Copyright (c) 2016 Joe Wilm +Copyright (c) 2016 The fallible-streaming-iterator Developers Copyright (c) 2016 The roaring-rs developers. Copyright (c) 2016 The Rust Project Developers Copyright (c) 2016 Tomasz Miąsko @@ -981,6 +991,7 @@ Copyright (c) 2017-2024 oyvindln Copyright (c) 2018 Ashley Mannix, Christopher Armstrong, Dylan DPC, Hunar Roop Kahlon Copyright (c) 2018 Sam Rijs, Alex Crichton and contributors Copyright (c) 2018 The Servo Project Developers +Copyright (c) 2018 Tom Kaitchuck Copyright (c) 2018-2019 Andrew Gallant Copyright (c) 2018-2019 The RustCrypto Project Developers Copyright (c) 2018-2025 The rust-random Project Developers @@ -1008,17 +1019,44 @@ Copyright 2013-2014 RAD Game Tools and Valve Software Copyright 2014 Paho Lurie-Gregg Copyright 2016-2026 Frank Denis. Copyright 2018 Developers of the Rand project +Copyright 2019 The Fuchsia Authors. Copyright 2020 Nor Khasyatillah Copyright 2020 Tomasz "Soveu" Marx Copyright 2020 Yoshua Wuyts Copyright 2023 Jacob Pratt Copyright 2023 Jacob Pratt et al. +Copyright 2023 The Fuchsia Authors Copyright 2024 Jacob Pratt et al. Copyright 2024 Josh McKinney Copyright 2024 Radzivon Bartoshyk Copyright 2025 Zed Industries, Inc. and contributors ``` +### `BSD-2-Clause` + +Applies to 1 crate(s) (directly or via composite identifiers): zerocopy v0.8.54 + +_Canonical text reproduced from upstream `SPDX:BSD-2-Clause`:_ + +``` +Copyright (c) + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +_Copyright notices harvested from the above crates' upstream LICENSE files:_ + +``` +Copyright 2019 The Fuchsia Authors. +Copyright 2023 The Fuchsia Authors +``` + ### `BSD-3-Clause` Applies to 2 crate(s) (directly or via composite identifiers): moxcms v0.8.1, pxfm v0.1.29 @@ -1217,7 +1255,7 @@ _Canonical text reproduced from upstream `SPDX:LLVM-exception`:_ ### `MIT` -Applies to 268 crate(s) (directly or via composite identifiers): adler2 v2.0.1, aho-corasick v1.1.4, allocator-api2 v0.2.21, anstream v0.6.21, anstyle v1.0.13, anstyle-parse v0.2.7, anstyle-query v1.1.5, anstyle-wincon v3.0.11, ... (+260 more) +Applies to 276 crate(s) (directly or via composite identifiers): adler2 v2.0.1, ahash v0.8.12, aho-corasick v1.1.4, allocator-api2 v0.2.21, anstream v0.6.21, anstyle v1.0.13, anstyle-parse v0.2.7, anstyle-query v1.1.5, ... (+268 more) _Canonical text reproduced from upstream `SPDX:MIT`:_ @@ -1259,6 +1297,7 @@ Copyright (c) 2014 Chris Wong Copyright (c) 2014 Paho Lurie-Gregg Copyright (c) 2014 The Rust Project Developers Copyright (c) 2014-2019 Geoffroy Couprie +Copyright (c) 2014-2021 The rusqlite developers Copyright (c) 2014-2022 Steven Fackler, Yuki Okushi Copyright (c) 2014-2026 Alex Crichton Copyright (c) 2014-2026 Sean McArthur @@ -1272,6 +1311,7 @@ Copyright (c) 2015 François Bernier Copyright (c) 2015 Jonathan Reem Copyright (c) 2015 nwin Copyright (c) 2015 The Rust Project Developers +Copyright (c) 2015 The rust-openssl-verify Developers Copyright (c) 2015-2018 The winapi-rs Developers Copyright (c) 2015-2020 The rust-hex Developers Copyright (c) 2016 Alex Crichton @@ -1282,6 +1322,7 @@ Copyright (c) 2016 Jelte Fennema Copyright (c) 2016 Jerome Froelich Copyright (c) 2016 Joe Wilm Copyright (c) 2016 Martin Geisler +Copyright (c) 2016 The fallible-streaming-iterator Developers Copyright (c) 2016 The roaring-rs developers. Copyright (c) 2016 The Rust Project Developers Copyright (c) 2016 Titus Wormer @@ -1305,6 +1346,7 @@ Copyright (c) 2018 Ashley Mannix, Christopher Armstrong, Dylan DPC, Hunar Roop K Copyright (c) 2018 Carl Lerche Copyright (c) 2018 Sam Rijs, Alex Crichton and contributors Copyright (c) 2018 The Servo Project Developers +Copyright (c) 2018 Tom Kaitchuck Copyright (c) 2018 Wez Furlong Copyright (c) 2018-2019 Andrew Gallant Copyright (c) 2018-2019 The RustCrypto Project Developers @@ -1353,11 +1395,13 @@ Copyright 2015 The Fancy Regex Authors. Copyright 2016-2026 Frank Denis. Copyright 2018 Developers of the Rand project Copyright 2019 Ryan O'Beirne +Copyright 2019 The Fuchsia Authors. Copyright 2020 Nor Khasyatillah Copyright 2020 Tomasz "Soveu" Marx Copyright 2020 Yoshua Wuyts Copyright 2023 Jacob Pratt Copyright 2023 Jacob Pratt et al. +Copyright 2023 The Fuchsia Authors Copyright 2024 Jacob Pratt et al. Copyright 2024 Josh McKinney ``` @@ -1445,31 +1489,17 @@ Except as contained in this notice, the name of a copyright holder shall not be Applies to 8 crate(s) (directly or via composite identifiers): aho-corasick v1.1.4, byteorder-lite v0.1.0, globset v0.4.18, ignore v0.4.25, memchr v2.8.0, same-file v1.0.6, walkdir v2.5.0, winapi-util v0.1.11 -_Canonical text reproduced from upstream `UNLICENSE`:_ +_Canonical text reproduced from upstream `SPDX:Unlicense`:_ ``` This is free and unencumbered software released into the public domain. -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. +Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. +In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to ``` diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index d4d6c7d1e6..5132cbdbdc 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -662,9 +662,300 @@ namespace winrt::TerminalApp::implementation _SaveWorkspaceIfNeeded(); } + // Durable shell sessions: snapshot this tab (layout, scrollback, cwd) + // before its pane content is torn down, so the user can reopen it later + // from the agent-pane `/shell-sessions` picker. + _SaveShellSessionForTab(tab); + tab.Close(); } + // Method Description: + // - Persist a tab as a named, durable shell session keyed by its title. + // Must run while the tab's pane content is still alive (before + // `tab.Close()`), so the terminal buffers can be written to disk. + // - Reuses the same `BuildStartupKind::Persist` serialization as the + // window-restore path: it captures each terminal pane's current working + // directory + session GUID and intentionally excludes agent panes (their + // command line points at a now-dead wta-master pipe — restoring the agent + // session is handled separately in a later step). + void TerminalPage::_SaveShellSessionForTab(const winrt::TerminalApp::Tab& tab) + { + // Opt-out via Settings > Startup > Durable session > "Restore shell + // sessions when terminal relaunches". When disabled, closing a tab does + // not snapshot its layout/scrollback, and nothing is written to disk. + if (!_settings || !_settings.GlobalSettings().DurableRestoreShellSessions()) + { + return; + } + + try + { + auto tabImpl = winrt::get_self(tab)->get_strong(); + + auto tabActions = tabImpl->BuildStartupActions(BuildStartupKind::Persist); + if (tabActions.empty()) + { + return; + } + + // Don't save a tab the user never did anything in: a brand-new tab + // that was opened and closed with no command run (and no scrollback) + // has nothing worth restoring, and — since sessions are keyed by tab + // title — an empty "PowerShell" snapshot would overwrite a real, + // same-named session. See `_TabHasSaveableContent`. + if (!_TabHasSaveableContent(tabImpl)) + { + return; + } + + WindowLayout layout; + layout.TabLayout(winrt::single_threaded_vector(std::move(tabActions))); + + auto name = tab.Title(); + if (name.empty()) + { + name = L"Shell session"; + } + + // Persist each terminal pane's scrollback to disk and collect the + // GUIDs, so master can reference (and later TTL-clean) those files. + const auto bufferGuids = _PersistShellSessionBuffers(tabImpl); + + winrt::hstring cwd; + if (const auto activeControl = tabImpl->GetActiveTerminalControl()) + { + cwd = activeControl.WorkingDirectory(); + } + + // If the tab has an agent pane the user actually opened, record its + // WT session GUID (pane_session_id) so restore can resolve it to the + // ACP session id (via wta's agent-pane-sessions.jsonl) and resume + // that conversation, faithfully re-showing it. + // + // We deliberately capture ONLY a visible (open) agent pane, not a + // stashed/hidden one: every tab pre-warms a stashed agent pane with + // a fresh, empty ACP session (see `_InitializeTab`). Capturing that + // would resurrect an agent the user never engaged with — showing an + // empty pane on restore. Requiring `!IsHidden()` scopes capture to + // an agent the user had open, matching the saved display. + winrt::hstring agentPaneSessionId; + if (const auto agentPane = tabImpl->FindAgentPane(); agentPane && !agentPane->IsHidden()) + { + if (const auto sid = agentPane->GetSessionId(); sid != winrt::guid{}) + { + agentPaneSessionId = winrt::hstring{ ::Microsoft::Console::Utils::GuidToPlainString(sid) }; + } + } + + // The durable session's stable identity (SQLite primary key) is the + // anchor terminal pane's WT session GUID — the first pane in walk + // order, i.e. bufferGuids[0]. Keying the store on this instead of the + // tab title lets two tabs share a name, and — because a restored tab + // reuses its pane GUIDs — makes a re-save update the SAME row rather + // than duplicate it. It also matches how master derives session_id + // when migrating legacy title-keyed rows (`buffer_guids[0]`). Fall + // back to the agent pane's session id for an agent-only tab (no + // terminal panes with a live session), then to a fresh GUID. + winrt::hstring sessionId; + if (!bufferGuids.empty()) + { + sessionId = bufferGuids.front(); + } + else if (!agentPaneSessionId.empty()) + { + sessionId = agentPaneSessionId; + } + else + { + sessionId = winrt::hstring{ ::Microsoft::Console::Utils::GuidToPlainString(::Microsoft::Console::Utils::CreateGuid()) }; + } + + // wta-master is the SOLE owner of the durable shell-session store + // (SQLite). We do NOT persist into WT's state.json; instead we ship + // the whole snapshot as a `save_shell_session` event, which master's + // WT-event subscriber upserts. See the durable-sessions spec. + const auto layoutJson = WindowLayout::ToJson(layout); + const auto savedAt = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + + Json::Value evt; + evt["type"] = "event"; + evt["method"] = "save_shell_session"; + Json::Value params; + params["session_id"] = winrt::to_string(sessionId); + params["name"] = winrt::to_string(name); + params["cwd"] = winrt::to_string(cwd); + params["layout_json"] = winrt::to_string(layoutJson); + params["saved_at"] = static_cast(savedAt); + Json::Value guidsArr{ Json::arrayValue }; + for (const auto& guid : bufferGuids) + { + guidsArr.append(winrt::to_string(guid)); + } + params["buffer_guids"] = std::move(guidsArr); + // When the tab had an agent pane, forward its WT session GUID so + // master can persist it and restore can resume that conversation. + if (!agentPaneSessionId.empty()) + { + params["agent_pane_session_id"] = winrt::to_string(agentPaneSessionId); + } + evt["params"] = std::move(params); + + Json::StreamWriterBuilder wb; + wb["indentation"] = ""; + const auto payload = winrt::to_hstring(Json::writeString(wb, evt)); + ProtocolVtSequenceReceived.raise(*this, payload); + } + CATCH_LOG(); + } + + // Method Description: + // - True when a tab has content worth persisting as a durable shell session. + // A brand-new tab that was opened and closed without doing anything has + // none of these and is skipped, so the session list isn't polluted with + // empty snapshots. A tab is saveable when any of its terminal panes: + // * is showing the alternate screen buffer — a full-screen TUI (copilot, + // vim, less, htop, …) is running, the clearest "in use" signal and the + // one that catches a tab where the user launched an interactive CLI + // (while alt-screen is active neither of the next two fire), or + // * ran at least one command / has a command typed at the prompt + // (shell-integration command history + current commandline — works for + // PowerShell/pwsh with OSC 133 marks), or + // * accumulated scrollback beyond the viewport (`BufferHeight > + // ViewHeight`) — the fallback for shells with no shell integration, + // whose command history is always empty. + // An agent pane the user actually opened (with a live session) also counts, + // so a tab used only for an agent conversation still saves. + bool TerminalPage::_TabHasSaveableContent(winrt::com_ptr tabImpl) + { + const auto rootPane = tabImpl->GetRootPane(); + if (!rootPane) + { + return false; + } + + bool saveable = false; + rootPane->WalkTree([&](const std::shared_ptr& p) { + if (saveable || !p->GetContent() || p->IsAgentPane()) + { + return; + } + const auto control = p->GetTerminalControl(); + if (!control) + { + return; + } + // A full-screen TUI (copilot, vim, less, htop, …) is running on the + // alternate screen buffer: while it's active the main buffer stops + // growing and command marks aren't recorded, so neither the history + // nor the scrollback check below fires — but the tab is clearly in + // use and worth saving. This is the primary signal for a tab where + // the user launched an interactive CLI (e.g. typed `copilot`). + if (control.InAltBuffer()) + { + saveable = true; + return; + } + // Shell integration recorded at least one executed command, or the + // user has a command typed/running at the current prompt. + if (const auto history = control.CommandHistory()) + { + if (history.History().Size() > 0 || !history.CurrentCommandline().empty()) + { + saveable = true; + return; + } + } + // Fallback: any scrollback beyond the viewport means real output + // landed even without shell integration (whose history stays empty). + if (control.BufferHeight() > control.ViewHeight()) + { + saveable = true; + } + }); + + // An open agent pane with a session is also meaningful work. + if (!saveable) + { + if (const auto agentPane = tabImpl->FindAgentPane(); agentPane && !agentPane->IsHidden()) + { + if (agentPane->GetSessionId() != winrt::guid{}) + { + saveable = true; + } + } + } + + return saveable; + } + + // Method Description: + // - Persist each terminal pane's scrollback to a durable + // `{guid}.txt` file under `\shell-session-buffers\` — the + // IntelligentTerminal LocalState dir that wta-master can also reach (so it + // can TTL-clean expired files). The dedicated dir keeps these clear of the + // window-close cleanup in `WindowEmperor::_finalizeSessionPersistence`, + // which only sweeps `buffer_*` / `elevated_*` next to `state.json`. + // Return Value: + // - The session GUIDs whose scrollback was written, formatted identically to + // the restore path (`_MakePane`), so master's `buffer_guids` reference and + // the on-disk filenames agree. + std::vector TerminalPage::_PersistShellSessionBuffers(winrt::com_ptr tabImpl) + { + std::vector guids; + + const auto rootPane = tabImpl->GetRootPane(); + if (!rootPane) + { + return guids; + } + + const auto stateDir = ::IntelligentTerminal::StateDir(); + if (stateDir.empty()) + { + return guids; + } + const auto buffersDir = stateDir / L"shell-session-buffers"; + std::error_code ec; + std::filesystem::create_directories(buffersDir, ec); + + rootPane->WalkTree([&](const std::shared_ptr& p) { + // Leaves only (GetContent() is null for splits); agent panes are + // excluded from the saved layout, so skip their buffers too. + if (!p->GetContent() || p->IsAgentPane()) + { + return; + } + const auto control = p->GetTerminalControl(); + if (!control) + { + return; + } + const auto connection = control.Connection(); + if (!connection) + { + return; + } + const auto sessionId = connection.SessionId(); + if (sessionId == winrt::guid{}) + { + return; + } + + const auto guidStr = fmt::format(FMT_COMPILE(L"{}"), sessionId); + const auto path = buffersDir / (guidStr + L".txt"); + if (wil::unique_hfile file{ CreateFileW(path.c_str(), GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_DELETE, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr) }) + { + control.PersistTo(reinterpret_cast(file.get())); + guids.emplace_back(guidStr); + } + }); + + return guids; + } + // Removes the tab (both TerminalControl and XAML). // NOTE: Don't call this directly, but rather `tab.Close()`. // - movingAway: true when this _RemoveTab is the tail of a cross-window diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index f93f02d2e0..3c787db370 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -5189,6 +5189,166 @@ namespace winrt::TerminalApp::implementation _RequestAgentStateForTab(newTab, std::nullopt, /*pane_open*/ true); } + // Inbound event from WTA: {method:"restore_shell_session", + // params:{name, layout_json, agent_session_id?, agent_cwd?}}. + // Sent by the agent-pane `/shell-sessions` picker's Enter handler. The + // `layout_json` comes straight from master's SQLite store (the sole owner of + // durable shell sessions), so we deserialize it here and replay its startup + // actions to rebuild the tab — working directory and pane splits. Each + // pane's scrollback is reconnected from its + // `\shell-session-buffers\{guid}.txt` file: we pre-mark the + // layout's session GUIDs in `_pendingShellSessionBufferIds` so the + // buffer-restore path (in `_MakePane`) reads the durable file. + // + // When the saved tab had an agent pane, wta resolves its ACP session id and + // passes it as `agent_session_id` (+ `agent_cwd`); we resume that + // conversation into the rebuilt tab (see `_RestoreShellSessionCoro`). + void TerminalPage::OnRestoreShellSessionRequested(hstring eventJson) + { + _agentPaneLog("OnRestoreShellSessionRequested: received from wta"); + + // Honor Settings > Startup > Durable session > "Restore shell sessions + // when terminal relaunches". When disabled, ignore restore requests + // (the picker may still surface previously saved sessions). + if (!_settings || !_settings.GlobalSettings().DurableRestoreShellSessions()) + { + _agentPaneLog("OnRestoreShellSessionRequested: durable shell-session restore disabled by setting - ignoring"); + return; + } + + Json::Value evt; + Json::CharReaderBuilder rb; + std::string errs; + const auto jsonStr = winrt::to_string(eventJson); + std::istringstream is{ jsonStr }; + if (!Json::parseFromStream(rb, is, &evt, &errs)) + { + _agentPaneLog("OnRestoreShellSessionRequested: failed to parse JSON: " + errs); + return; + } + if (!evt.isMember("params") || !evt["params"].isObject()) + { + _agentPaneLog("OnRestoreShellSessionRequested: missing params object"); + return; + } + const auto& params = evt["params"]; + const auto nameStr = params.get("name", "").asString(); + const auto layoutJsonStr = params.get("layout_json", "").asString(); + if (layoutJsonStr.empty()) + { + _agentPaneLog("OnRestoreShellSessionRequested: missing layout_json — ignoring"); + return; + } + const auto agentSessionId = params.get("agent_session_id", "").asString(); + const auto agentCwd = params.get("agent_cwd", "").asString(); + + WindowLayout layout{ nullptr }; + try + { + layout = WindowLayout::FromJson(winrt::to_hstring(layoutJsonStr)); + } + catch (...) + { + _agentPaneLog("OnRestoreShellSessionRequested: failed to parse layout_json for '" + nameStr + "'"); + return; + } + if (!layout) + { + _agentPaneLog("OnRestoreShellSessionRequested: null layout for '" + nameStr + "'"); + return; + } + const auto tabLayout = layout.TabLayout(); + if (!tabLayout || tabLayout.Size() == 0) + { + _agentPaneLog("OnRestoreShellSessionRequested: saved session '" + nameStr + "' has no layout"); + return; + } + + // Pre-mark each pane's session GUID so the buffer-restore path pulls + // scrollback from the durable `shell-session-buffers\{guid}.txt` file. + std::vector actions; + actions.reserve(tabLayout.Size()); + for (const auto& action : tabLayout) + { + Settings::Model::NewTerminalArgs termArgs{ nullptr }; + if (const auto newTabArgs = action.Args().try_as()) + { + termArgs = newTabArgs.ContentArgs().try_as(); + } + else if (const auto splitArgs = action.Args().try_as()) + { + termArgs = splitArgs.ContentArgs().try_as(); + } + if (termArgs) + { + if (const auto sid = termArgs.SessionId(); sid != winrt::guid{}) + { + _pendingShellSessionBufferIds.insert(sid); + } + } + actions.emplace_back(action); + } + + _agentPaneLog("OnRestoreShellSessionRequested: rebuilding tab for session '" + nameStr + "'"); + _RestoreShellSessionCoro(std::move(actions), agentSessionId, agentCwd); + } + + // Rebuild a shell session's tab from its saved startup actions, then (when + // the saved tab had an agent pane) resume that agent session into the new + // tab. This is a coroutine so it can sequence the agent-pane open AFTER the + // async tab/pane construction has settled. + // + // Agent-pane handling mirrors `OnAgentPaneRestartRequested`: every new tab + // pre-warms a FRESH stashed agent pane (`_InitializeTab`), so we tear that + // down first (FindAgentPane also finds the stashed/hidden one) and then + // create a RESUMED pane via `--initial-load-session-id`. Because both steps + // run in one synchronous block on the UI thread, the pre-warm's deferred + // tick either already ran (we tear it down) or runs later and no-ops on our + // now-present pane — so we never end up with two agent panes. + safe_void_coroutine TerminalPage::_RestoreShellSessionCoro(std::vector actions, std::string agentSessionId, std::string agentCwd) + { + const auto strong = get_strong(); + + // Same replay shape as ProcessStartupActions: don't suspend before the + // first action if this is the first tab, but suspend between subsequent + // ones so WinUI can lay out each new control. + auto suspend = _tabs.Size() > 0; + for (const auto& action : actions) + { + if (suspend) + { + co_await wil::resume_foreground(Dispatcher(), CoreDispatcherPriority::Low); + } + _actionDispatch->DoAction(action); + suspend = true; + } + + if (!agentSessionId.empty()) + { + if (const auto newTab = _GetFocusedTabImpl()) + { + _agentPaneLog("OnRestoreShellSessionRequested: resuming agent session " + agentSessionId + " into restored tab"); + // Replace any pre-warmed (fresh-session) agent pane with a + // resumed one bound to the saved ACP session id. + _TeardownAgentPane(newTab, /*suppressMasterRestart*/ true); + _AutoCreateHiddenAgentPaneShared(newTab, + /*intoSessionsView*/ false, + /*autoStash*/ false, + agentSessionId, + agentCwd); + } + } + + // Mirror ProcessStartupActions' tail: focus the active pane's content. + if (const auto& tabImpl{ _GetFocusedTabImpl() }) + { + if (const auto& content{ tabImpl->GetActiveContent() }) + { + content.Focus(FocusState::Programmatic); + } + } + } + // Method Description: // - Called when the users pressed keyBindings while CommandPaletteElement is open. // - As of GH#8480, this is also bound to the TabRowControl's KeyUp event. @@ -6099,6 +6259,14 @@ namespace winrt::TerminalApp::implementation // Persist the full layout into the workspace collection. ApplicationState::SharedInstance().SaveWorkspace(windowName, layout); + // Durable sessions: also persist this workspace's scrollback so + // reopening it later restores terminal contents, not just the + // layout + working directories. The buffers use the standard + // `buffer_`/`elevated_` prefix (so the normal restore path picks + // them up on reopen) and are kept alive by the workspace-aware + // cleanup in `WindowEmperor::_finalizeSessionPersistence`. + _PersistWorkspaceBuffers(); + // Build a minimal layout with just an openWorkspace action // so the generic restore path re-opens this workspace by name. std::vector actions; @@ -6119,6 +6287,62 @@ namespace winrt::TerminalApp::implementation } } + // Persist every terminal pane's scrollback in this window to + // `buffer_{guid}.txt` (or `elevated_` when running as admin) next to + // state.json, so a saved workspace can restore its contents on reopen. + // Agent panes are skipped (excluded from the saved layout). Mirrors the + // per-pane persistence WindowEmperor does at app close, but runs when a + // workspace is saved so its buffers exist even if its window isn't live at + // the next shutdown — `_finalizeSessionPersistence` keeps the files it + // finds referenced by a saved workspace. + void TerminalPage::_PersistWorkspaceBuffers() + { + using namespace std::string_view_literals; + + const std::filesystem::path settingsDirectory{ std::wstring_view{ CascadiaSettings::SettingsDirectory() } }; + const auto filenamePrefix = IsRunningElevated() ? L"elevated_"sv : L"buffer_"sv; + + for (const auto& tab : _tabs) + { + const auto tabImpl = winrt::get_self(tab); + if (!tabImpl) + { + continue; + } + const auto rootPane = tabImpl->GetRootPane(); + if (!rootPane) + { + continue; + } + rootPane->WalkTree([&](const std::shared_ptr& p) { + if (!p->GetContent() || p->IsAgentPane()) + { + return; + } + const auto control = p->GetTerminalControl(); + if (!control) + { + return; + } + const auto connection = control.Connection(); + if (!connection) + { + return; + } + const auto sessionId = connection.SessionId(); + if (sessionId == winrt::guid{}) + { + return; + } + const auto path = settingsDirectory / fmt::format(FMT_COMPILE(L"{}{}.txt"), filenamePrefix, sessionId); + if (wil::unique_hfile file{ CreateFileW(path.c_str(), GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_DELETE, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr) }) + { + control.PersistTo(reinterpret_cast(file.get())); + } + }); + } + } + // Method Description: // - Determines whether a close-window action should show a confirmation // dialog, based on the confirmOnClose setting and the current window state. @@ -7761,11 +7985,32 @@ namespace winrt::TerminalApp::implementation { using namespace std::string_view_literals; - const auto settingsDir = CascadiaSettings::SettingsDirectory(); - const auto admin = IsRunningElevated(); - const auto filenamePrefix = admin ? L"elevated_"sv : L"buffer_"sv; - const auto path = fmt::format(FMT_COMPILE(L"{}\\{}{}.txt"), settingsDir, filenamePrefix, sessionId); - control.RestoreFromPath(path); + // A durable shell-session restore reads scrollback from + // `\shell-session-buffers\{guid}.txt` (written by + // `_PersistShellSessionBuffers`, and TTL-cleaned by wta-master). + // Each GUID is consumed exactly once. Everything else — normal + // window relaunch — uses the transient per-window buffer file next + // to `state.json` (`buffer_*` / `elevated_*`). + std::wstring path; + if (const auto it = _pendingShellSessionBufferIds.find(sessionId); it != _pendingShellSessionBufferIds.end()) + { + _pendingShellSessionBufferIds.erase(it); + const auto stateDir = ::IntelligentTerminal::StateDir(); + if (!stateDir.empty()) + { + path = (stateDir / L"shell-session-buffers" / (fmt::format(FMT_COMPILE(L"{}"), sessionId) + L".txt")).wstring(); + } + } + else + { + const auto settingsDir = CascadiaSettings::SettingsDirectory(); + const auto filenamePrefix = IsRunningElevated() ? L"elevated_"sv : L"buffer_"sv; + path = fmt::format(FMT_COMPILE(L"{}\\{}{}.txt"), settingsDir, filenamePrefix, sessionId); + } + if (!path.empty()) + { + control.RestoreFromPath(path); + } } auto paneContent{ winrt::make(profile, _terminalSettingsCache, control) }; diff --git a/src/cascadia/TerminalApp/TerminalPage.h b/src/cascadia/TerminalApp/TerminalPage.h index d3b93f0209..d5032f120b 100644 --- a/src/cascadia/TerminalApp/TerminalPage.h +++ b/src/cascadia/TerminalApp/TerminalPage.h @@ -4,7 +4,7 @@ #pragma once #include - +#include #include "TerminalPage.g.h" #include "Tab.h" #include "AppKeyBindings.h" @@ -256,6 +256,7 @@ namespace winrt::TerminalApp::implementation void OnCloseAgentPaneRequested(hstring eventJson); void OnAgentStateChanged(hstring eventJson); void OnResumeInNewAgentTabRequested(hstring eventJson); + void OnRestoreShellSessionRequested(hstring eventJson); void OnAgentChipTargetChanged(hstring eventJson); void OnRestartAgentStackRequested(hstring eventJson); void OnAgentPaneRestartRequested(hstring eventJson); @@ -495,6 +496,14 @@ namespace winrt::TerminalApp::implementation std::string cwd; }; std::unordered_map _pendingLoadSessions; + + // Session GUIDs currently being restored as part of a durable shell + // session. The terminal buffer-restore path (in `_MakePane`) consults + // this set to read the scrollback from the durable + // `\shell-session-buffers\{guid}.txt` file instead of the + // transient `buffer_` file (which the window-close cleanup sweeps). Each + // GUID is consumed (erased) exactly once, when its pane is created. + std::unordered_set _pendingShellSessionBufferIds; // Short-lived marks keyed by tab StableId: set whenever an agent // pane is torn down deliberately (Ctrl+C×2, settings rebuild, // /restart, recovery re-warm). `OnAgentPaneRestartRequested` @@ -672,6 +681,16 @@ namespace winrt::TerminalApp::implementation safe_void_coroutine _RemoveTabs(const std::vector tabs); void _SaveWorkspaceIfNeeded(); + // Durable shell sessions (step 1): snapshot a tab on close so it can be + // restored on demand from the agent-pane `/shell-sessions` picker. The + // snapshot is shipped to wta-master (the SQLite store owner) as a + // `save_shell_session` event; WT never persists it into state.json. + void _SaveShellSessionForTab(const winrt::TerminalApp::Tab& tab); + bool _TabHasSaveableContent(winrt::com_ptr tabImpl); + std::vector _PersistShellSessionBuffers(winrt::com_ptr tabImpl); + safe_void_coroutine _RestoreShellSessionCoro(std::vector actions, std::string agentSessionId, std::string agentCwd); + void _PersistWorkspaceBuffers(); + void _InitializeTab(winrt::com_ptr newTabImpl, uint32_t insertPosition = -1, bool openInBackground = false); void _RegisterTerminalEvents(Microsoft::Terminal::Control::TermControl term); std::string _FindSessionIdForControl(const Microsoft::Terminal::Control::TermControl& control); diff --git a/src/cascadia/TerminalApp/TerminalPage.idl b/src/cascadia/TerminalApp/TerminalPage.idl index 7a82a28d52..80c02c4656 100644 --- a/src/cascadia/TerminalApp/TerminalPage.idl +++ b/src/cascadia/TerminalApp/TerminalPage.idl @@ -188,6 +188,13 @@ namespace TerminalApp // tab's StableId so wta calls ACP `session/load` for that tab. void OnResumeInNewAgentTabRequested(String eventJson); + // Inbound event from WTA carrying + // {method:"restore_shell_session",params:{name}}. Emitted by the + // agent-pane `/shell-sessions` picker's Enter handler. The page looks + // up the saved layout by name and rebuilds the tab (working directory, + // panes, and scrollback). + void OnRestoreShellSessionRequested(String eventJson); + // Inbound event from WTA carrying // {method:"set_agent_chip_target",params:{tab_id, pane_session_id?}}. // Helper override for which pane in the tab gets the blue "Agent" diff --git a/src/cascadia/TerminalControl/ControlCore.cpp b/src/cascadia/TerminalControl/ControlCore.cpp index 118aa3ef93..64a04cf8b0 100644 --- a/src/cascadia/TerminalControl/ControlCore.cpp +++ b/src/cascadia/TerminalControl/ControlCore.cpp @@ -1627,6 +1627,13 @@ namespace winrt::Microsoft::Terminal::Control::implementation return _terminal->GetBufferHeight(); } + // Whether a full-screen TUI (alternate screen buffer) is currently active. + bool ControlCore::InAltBuffer() const + { + const auto lock = _terminal->LockForReading(); + return _terminal->IsInAltBuffer(); + } + void ControlCore::_terminalWarningBell() { // Since this can only ever be triggered by output from the connection, diff --git a/src/cascadia/TerminalControl/ControlCore.h b/src/cascadia/TerminalControl/ControlCore.h index e9808834be..0e43523ad7 100644 --- a/src/cascadia/TerminalControl/ControlCore.h +++ b/src/cascadia/TerminalControl/ControlCore.h @@ -175,6 +175,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation int ScrollOffset(); int ViewHeight() const; int BufferHeight() const; + bool InAltBuffer() const; bool HasSelection() const; bool HasMultiLineSelection() const; diff --git a/src/cascadia/TerminalControl/ICoreState.idl b/src/cascadia/TerminalControl/ICoreState.idl index c20bfc6678..804defe247 100644 --- a/src/cascadia/TerminalControl/ICoreState.idl +++ b/src/cascadia/TerminalControl/ICoreState.idl @@ -47,6 +47,7 @@ namespace Microsoft.Terminal.Control Int32 ScrollOffset { get; }; Int32 ViewHeight { get; }; Int32 BufferHeight { get; }; + Boolean InAltBuffer { get; }; Boolean HasSelection { get; }; Boolean HasMultiLineSelection { get; }; diff --git a/src/cascadia/TerminalControl/TermControl.cpp b/src/cascadia/TerminalControl/TermControl.cpp index bba9254b7a..63df8044c1 100644 --- a/src/cascadia/TerminalControl/TermControl.cpp +++ b/src/cascadia/TerminalControl/TermControl.cpp @@ -2725,6 +2725,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation return _core.BufferHeight(); } + bool TermControl::InAltBuffer() const + { + return _core.InAltBuffer(); + } + // Function Description: // - Determines how much space (in pixels) an app would need to reserve to // create a control with the settings stored in the settings param. This diff --git a/src/cascadia/TerminalControl/TermControl.h b/src/cascadia/TerminalControl/TermControl.h index 82ce5df816..df085cd525 100644 --- a/src/cascadia/TerminalControl/TermControl.h +++ b/src/cascadia/TerminalControl/TermControl.h @@ -100,6 +100,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation int ScrollOffset() const; int ViewHeight() const; int BufferHeight() const; + bool InAltBuffer() const; bool HasSelection() const; bool HasMultiLineSelection() const; diff --git a/src/cascadia/TerminalCore/Terminal.cpp b/src/cascadia/TerminalCore/Terminal.cpp index 3bbaeeb898..c422ba1299 100644 --- a/src/cascadia/TerminalCore/Terminal.cpp +++ b/src/cascadia/TerminalCore/Terminal.cpp @@ -1554,6 +1554,16 @@ std::wstring Terminal::CurrentCommand() const return _activeBuffer().CurrentCommand(); } +// Whether the terminal is currently showing the alternate screen buffer, i.e. a +// full-screen TUI application (copilot, vim, less, htop, …) has taken over. In +// that state the main buffer stops growing and command marks aren't recorded, +// so durable-session save uses this as a signal that meaningful work is +// underway even when command history / scrollback look empty. +bool Terminal::IsInAltBuffer() const noexcept +{ + return _inAltBuffer(); +} + void Terminal::SerializeMainBuffer(HANDLE handle) const { _mainBuffer->SerializeTo(handle); diff --git a/src/cascadia/TerminalCore/Terminal.hpp b/src/cascadia/TerminalCore/Terminal.hpp index 9bc191f743..a00d0b4447 100644 --- a/src/cascadia/TerminalCore/Terminal.hpp +++ b/src/cascadia/TerminalCore/Terminal.hpp @@ -130,6 +130,8 @@ class Microsoft::Terminal::Core::Terminal final : std::wstring CurrentCommand() const; + bool IsInAltBuffer() const noexcept; + void SerializeMainBuffer(HANDLE handle) const; #pragma region ITerminalApi diff --git a/src/cascadia/TerminalProtocol/ProtocolParsing.h b/src/cascadia/TerminalProtocol/ProtocolParsing.h index dad0a2180e..98627bcbad 100644 --- a/src/cascadia/TerminalProtocol/ProtocolParsing.h +++ b/src/cascadia/TerminalProtocol/ProtocolParsing.h @@ -37,6 +37,7 @@ namespace Microsoft::Terminal::Protocol::Parsing CloseAgentPane, // Direct to TerminalPage, no broadcast AgentState, // Direct to TerminalPage, no broadcast — unified per-tab agent-pane UI snapshot (view + pane_open + ...) ResumeInNewAgentTab, // Direct to TerminalPage, no broadcast + RestoreShellSession, // Direct to TerminalPage, no broadcast — durable-sessions `/shell-sessions` restore AgentChipTarget, // Direct to TerminalPage, no broadcast — "draw the Agent chip on this pane (or hide override)" RestartAgentStack, // Direct to TerminalPage, no broadcast — `/restart` from any agent pane TUI RestartAgentPane, // Direct to TerminalPage, no broadcast — master detected helper death; re-warm a fresh helper for this tab @@ -93,6 +94,10 @@ namespace Microsoft::Terminal::Protocol::Parsing { return SendEventRoute::ResumeInNewAgentTab; } + if (method == "restore_shell_session") + { + return SendEventRoute::RestoreShellSession; + } if (method == "set_agent_chip_target") { return SendEventRoute::AgentChipTarget; diff --git a/src/cascadia/TerminalSettingsEditor/Launch.xaml b/src/cascadia/TerminalSettingsEditor/Launch.xaml index e202feef33..08df54b13a 100644 --- a/src/cascadia/TerminalSettingsEditor/Launch.xaml +++ b/src/cascadia/TerminalSettingsEditor/Launch.xaml @@ -316,6 +316,26 @@ Style="{StaticResource ToggleSwitchInExpanderStyle}" /> + + + + + + + + + + + + diff --git a/src/cascadia/TerminalSettingsEditor/LaunchViewModel.h b/src/cascadia/TerminalSettingsEditor/LaunchViewModel.h index 98851b74dc..d8f5993468 100644 --- a/src/cascadia/TerminalSettingsEditor/LaunchViewModel.h +++ b/src/cascadia/TerminalSettingsEditor/LaunchViewModel.h @@ -51,6 +51,9 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation GETSET_BINDABLE_ENUM_SETTING(WindowingBehavior, Model::WindowingMode, _Settings.GlobalSettings().WindowingBehavior); PERMANENT_OBSERVABLE_PROJECTED_SETTING(_Settings.GlobalSettings(), CenterOnLaunch); + PERMANENT_OBSERVABLE_PROJECTED_SETTING(_Settings.GlobalSettings(), DurableRestoreShellSessions); + PERMANENT_OBSERVABLE_PROJECTED_SETTING(_Settings.GlobalSettings(), DurableRestoreAgentSession); + PERMANENT_OBSERVABLE_PROJECTED_SETTING(_Settings.GlobalSettings(), DurableKeepRunningCommands); PERMANENT_OBSERVABLE_PROJECTED_SETTING(_Settings.GlobalSettings(), InitialRows); PERMANENT_OBSERVABLE_PROJECTED_SETTING(_Settings.GlobalSettings(), InitialCols); diff --git a/src/cascadia/TerminalSettingsEditor/LaunchViewModel.idl b/src/cascadia/TerminalSettingsEditor/LaunchViewModel.idl index fe39cbccb1..ad1be4dd25 100644 --- a/src/cascadia/TerminalSettingsEditor/LaunchViewModel.idl +++ b/src/cascadia/TerminalSettingsEditor/LaunchViewModel.idl @@ -38,6 +38,9 @@ namespace Microsoft.Terminal.Settings.Editor IObservableVector WindowingBehaviorList { get; }; PERMANENT_OBSERVABLE_PROJECTED_SETTING(Boolean, CenterOnLaunch); + PERMANENT_OBSERVABLE_PROJECTED_SETTING(Boolean, DurableRestoreShellSessions); + PERMANENT_OBSERVABLE_PROJECTED_SETTING(Boolean, DurableRestoreAgentSession); + PERMANENT_OBSERVABLE_PROJECTED_SETTING(Boolean, DurableKeepRunningCommands); PERMANENT_OBSERVABLE_PROJECTED_SETTING(Int32, InitialRows); PERMANENT_OBSERVABLE_PROJECTED_SETTING(Int32, InitialCols); diff --git a/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw index 1f8224327f..601db12328 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw @@ -1679,6 +1679,30 @@ In this context, "agent" refers to an AI agent (e.g. Copilot, Claude, Gemini), n The initial position of the terminal window upon startup. When launching as maximized, full screen, or with "Center on launch" enabled, this is used to target the monitor of interest. A description for what the "X position" and "Y position" settings do. Presented near "Globals_LaunchPosition.Header". + + Durable session + Section header for the group of settings that control saving and restoring terminal sessions across relaunches. + + + Restore sessions + Header for an expandable group of settings that control which parts of a terminal session are restored when the terminal relaunches. + + + Automatically reconnects to your active terminal session when the connection is restored. + A description for the "Restore sessions" group of settings. Presented near "Globals_DurableRestoreSessions.Header". + + + Restore shell sessions when terminal relaunches + A checkbox that, when enabled, saves a tab's shell session (layout, working directory, and scrollback) on close so it can be restored after the terminal relaunches. + + + Restore agent session when terminal relaunches + A checkbox that, when enabled, restores an AI agent conversation session (e.g. Copilot, Claude, Gemini) when the terminal relaunches. Here "agent" refers to an AI agent, not a human agent. + + + Continue running commands when terminal relaunches + A checkbox that, when enabled, keeps a session's running command alive in the background so it continues after the terminal relaunches. + All An option to choose from for the "bell style" setting. When selected, a combination of the other bell styles is used to notify the user. diff --git a/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl b/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl index 06e7db116d..dc6b0b2913 100644 --- a/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl +++ b/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl @@ -82,6 +82,13 @@ namespace Microsoft.Terminal.Settings.Model INHERITABLE_SETTING(Boolean, TrimPaste); INHERITABLE_SETTING(LaunchPosition, InitialPosition); INHERITABLE_SETTING(Boolean, CenterOnLaunch); + + // Durable sessions: control what is saved on tab close and restored + // when the terminal relaunches. See Settings > Startup > Durable session. + INHERITABLE_SETTING(Boolean, DurableRestoreShellSessions); + INHERITABLE_SETTING(Boolean, DurableRestoreAgentSession); + INHERITABLE_SETTING(Boolean, DurableKeepRunningCommands); + INHERITABLE_SETTING(Microsoft.Terminal.Control.DefaultInputScope, DefaultInputScope); INHERITABLE_SETTING(FirstWindowPreference, FirstWindowPreference); INHERITABLE_SETTING(LaunchMode, LaunchMode); diff --git a/src/cascadia/TerminalSettingsModel/MTSMSettings.h b/src/cascadia/TerminalSettingsModel/MTSMSettings.h index f8adcb45ee..27da72e94d 100644 --- a/src/cascadia/TerminalSettingsModel/MTSMSettings.h +++ b/src/cascadia/TerminalSettingsModel/MTSMSettings.h @@ -50,6 +50,9 @@ Author(s): X(winrt::Microsoft::Terminal::Control::WarnAboutMultiLinePaste, WarnAboutMultiLinePaste, "warning.multiLinePaste", winrt::Microsoft::Terminal::Control::WarnAboutMultiLinePaste::Automatic) \ X(Model::LaunchPosition, InitialPosition, "initialPosition", nullptr, nullptr) \ X(bool, CenterOnLaunch, "centerOnLaunch", false) \ + X(bool, DurableRestoreShellSessions, "durableSession.restoreShellSessions", true) \ + X(bool, DurableRestoreAgentSession, "durableSession.restoreAgentSession", true) \ + X(bool, DurableKeepRunningCommands, "durableSession.keepRunningCommands", true) \ X(Model::FirstWindowPreference, FirstWindowPreference, "firstWindowPreference", FirstWindowPreference::DefaultProfile) \ X(Model::LaunchMode, LaunchMode, "launchMode", LaunchMode::DefaultMode) \ X(bool, SnapToGridOnResize, "snapToGridOnResize", true) \ diff --git a/src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp b/src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp index 871e595b12..c1d843748f 100644 --- a/src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp +++ b/src/cascadia/WindowsTerminal/TerminalProtocolComServer.cpp @@ -1089,6 +1089,11 @@ try // a new tab and asks wta to open an agent pane in it. _dispatchResumeInNewAgentTabToPage(eventH); return S_OK; + case ProtocolParsing::SendEventRoute::RestoreShellSession: + // Agent-pane `/shell-sessions` picker Enter handler. WT looks up the + // durable layout by name and rebuilds the tab (layout + scrollback). + _dispatchRestoreShellSessionToPage(eventH); + return S_OK; case ProtocolParsing::SendEventRoute::AgentChipTarget: // Helper override for which pane gets the "Agent" chip; null // pane_session_id reverts the tab to source-flag-driven chip. @@ -1409,6 +1414,41 @@ void TerminalProtocolComServer::_dispatchResumeInNewAgentTabToPage(const winrt:: } } +void TerminalProtocolComServer::_dispatchRestoreShellSessionToPage(const winrt::hstring& eventJson) +{ + if (!s_emperor) + { + return; + } + // Same fan-out shape as _dispatchResumeInNewAgentTabToPage. The page-side + // handler looks up the saved layout by name and rebuilds the tab. + for (const auto& host : s_emperor->GetWindows()) + { + auto page = _getPage(host.get()); + if (!page) + { + continue; + } + const auto dispatcher = page.Dispatcher(); + if (!dispatcher) + { + continue; + } + dispatcher.RunAsync( + winrt::Windows::UI::Core::CoreDispatcherPriority::Normal, + [page, eventJson]() { + try + { + page.OnRestoreShellSessionRequested(eventJson); + } + catch (...) + { + // Swallow: page may have been torn down during dispatch. + } + }); + } +} + void TerminalProtocolComServer::_dispatchAgentChipTargetToPage(const winrt::hstring& eventJson) { if (!s_emperor) diff --git a/src/cascadia/WindowsTerminal/TerminalProtocolComServer.h b/src/cascadia/WindowsTerminal/TerminalProtocolComServer.h index a7e192ce5a..6b6df9ac9d 100644 --- a/src/cascadia/WindowsTerminal/TerminalProtocolComServer.h +++ b/src/cascadia/WindowsTerminal/TerminalProtocolComServer.h @@ -137,6 +137,7 @@ TerminalProtocolComServer : public Microsoft::WRL::RuntimeClass< static void _dispatchCloseAgentPaneToPage(const winrt::hstring& eventJson); static void _dispatchAgentStateChangedToPage(const winrt::hstring& eventJson); static void _dispatchResumeInNewAgentTabToPage(const winrt::hstring& eventJson); + static void _dispatchRestoreShellSessionToPage(const winrt::hstring& eventJson); static void _dispatchAgentChipTargetToPage(const winrt::hstring& eventJson); static void _dispatchRestartAgentStackToPage(const winrt::hstring& eventJson); static void _dispatchRestartAgentPaneToPage(const winrt::hstring& eventJson); diff --git a/src/cascadia/WindowsTerminal/WindowEmperor.cpp b/src/cascadia/WindowsTerminal/WindowEmperor.cpp index fdda5b3bff..81ba1cfe2c 100644 --- a/src/cascadia/WindowsTerminal/WindowEmperor.cpp +++ b/src/cascadia/WindowsTerminal/WindowEmperor.cpp @@ -1462,6 +1462,47 @@ void WindowEmperor::_finalizeSessionPersistence() const // Now remove the "buffer_{guid}.txt" files that shouldn't be there. if (_needsPersistenceCleanup) { + // Durable workspaces: keep the scrollback files referenced by any saved + // workspace, even though that workspace's window isn't currently open. + // Without this the cleanup below would delete a saved workspace's + // buffers, so reopening it later would lose its terminal contents. + if (const auto workspaces = ApplicationState::SharedInstance().AllPersistedWorkspaces()) + { + const auto rememberSessionArgs = [&](const auto& contentArgs) { + if (const auto termArgs = contentArgs.try_as()) + { + if (const auto sid = termArgs.SessionId(); sid != winrt::guid{}) + { + bufferFilenames.emplace(fmt::format(FMT_COMPILE(L"{}{}.txt"), filenamePrefix, sid)); + } + } + }; + for (const auto& kv : workspaces) + { + const auto layout = kv.Value(); + if (!layout) + { + continue; + } + const auto tabLayout = layout.TabLayout(); + if (!tabLayout) + { + continue; + } + for (const auto& action : tabLayout) + { + if (const auto newTabArgs = action.Args().try_as()) + { + rememberSessionArgs(newTabArgs.ContentArgs()); + } + else if (const auto splitArgs = action.Args().try_as()) + { + rememberSessionArgs(splitArgs.ContentArgs()); + } + } + } + } + const auto filter = fmt::format(FMT_COMPILE(L"{}\\{}*"), settingsDirectory.native(), filenamePrefix); // This could also use std::filesystem::directory_iterator. diff --git a/src/cascadia/inc/IntelligentTerminalPaths.h b/src/cascadia/inc/IntelligentTerminalPaths.h index 9c8763a90f..c8b7a9743f 100644 --- a/src/cascadia/inc/IntelligentTerminalPaths.h +++ b/src/cascadia/inc/IntelligentTerminalPaths.h @@ -58,6 +58,40 @@ namespace IntelligentTerminal return base / L"IntelligentTerminal" / L"logs"; } + // Resolve the persistent, package-private STATE root: + // + // * Packaged: %LOCALAPPDATA%\Packages\\LocalState\IntelligentTerminal + // * Unpackaged: %LOCALAPPDATA%\IntelligentTerminal + // + // Mirrors wta's Rust `runtime_paths::intelligent_terminal_root()` exactly, so + // both sides agree on the shared C++<->Rust state directory. Unlike `LogDir()` + // (transient cache under LocalCache\Local), this is persistent state that must + // survive — it backs the agent-pane session index (`agent-pane-sessions.jsonl`) + // and the durable shell-session scrollback files + // (`shell-session-buffers\{guid}.txt`) that wta-master's SQLite store + // references. Returns an empty path when `%LOCALAPPDATA%` is unavailable. + inline std::filesystem::path StateDir() + { + wchar_t localAppData[MAX_PATH]; + if (GetEnvironmentVariableW(L"LOCALAPPDATA", localAppData, MAX_PATH) == 0) + { + return {}; + } + std::filesystem::path base{ std::wstring(localAppData) }; + + UINT32 length = 0; + if (GetCurrentPackageFamilyName(&length, nullptr) == ERROR_INSUFFICIENT_BUFFER && length != 0) + { + std::wstring family(length, L'\0'); + if (GetCurrentPackageFamilyName(&length, family.data()) == ERROR_SUCCESS) + { + family.resize(::wcslen(family.c_str())); // drop trailing NUL(s) + return base / L"Packages" / family / L"LocalState" / L"IntelligentTerminal"; + } + } + return base / L"IntelligentTerminal"; + } + // The current process's package version as `"Major.Minor.Build.Revision"` // (e.g. `"0.8.0.2"`), or an empty string when unpackaged. This is the // shared per-version key — wta's Rust `logging::package_version()` reads diff --git a/tools/wta/Cargo.lock b/tools/wta/Cargo.lock index 0d1890f563..5ac28ad45b 100644 --- a/tools/wta/Cargo.lock +++ b/tools/wta/Cargo.lock @@ -56,6 +56,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -747,6 +759,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -1039,6 +1063,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1059,6 +1092,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -1302,6 +1344,17 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "line-clipping" version = "0.3.5" @@ -1714,6 +1767,12 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "png" version = "0.18.1" @@ -1968,6 +2027,20 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust-i18n" version = "3.1.5" @@ -2872,6 +2945,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -3366,6 +3445,7 @@ dependencies = [ "notify", "pulldown-cmark", "ratatui", + "rusqlite", "rust-i18n", "serde", "serde_json", @@ -3384,6 +3464,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/tools/wta/Cargo.toml b/tools/wta/Cargo.toml index b505bf7a09..82ae51793e 100644 --- a/tools/wta/Cargo.toml +++ b/tools/wta/Cargo.toml @@ -61,3 +61,8 @@ sys-locale = "0.3" # (issue #287). Already in the lockfile as a transitive dep of clap, so # promoting it to a direct dep does not change the resolved graph. strsim = "0.11" +# Embedded SQLite for the durable shell-session store. Owned exclusively by +# wta-master (single writer/reader — no cross-process/cross-language locking), +# so C++ and helpers never link it; they reach the DB via WT events / ACP ext +# methods. `bundled` compiles sqlite3 from source (no system dependency). +rusqlite = { version = "0.31", features = ["bundled"] } diff --git a/tools/wta/cgmanifest.json b/tools/wta/cgmanifest.json index d9debf2717..50bbf7f7bc 100644 --- a/tools/wta/cgmanifest.json +++ b/tools/wta/cgmanifest.json @@ -37,6 +37,15 @@ } } }, + { + "component": { + "type": "cargo", + "cargo": { + "name": "ahash", + "version": "0.8.12" + } + } + }, { "component": { "type": "cargo", @@ -640,6 +649,24 @@ } } }, + { + "component": { + "type": "cargo", + "cargo": { + "name": "fallible-iterator", + "version": "0.3.0" + } + } + }, + { + "component": { + "type": "cargo", + "cargo": { + "name": "fallible-streaming-iterator", + "version": "0.1.9" + } + } + }, { "component": { "type": "cargo", @@ -901,6 +928,15 @@ } } }, + { + "component": { + "type": "cargo", + "cargo": { + "name": "hashbrown", + "version": "0.14.5" + } + } + }, { "component": { "type": "cargo", @@ -910,6 +946,15 @@ } } }, + { + "component": { + "type": "cargo", + "cargo": { + "name": "hashlink", + "version": "0.9.1" + } + } + }, { "component": { "type": "cargo", @@ -1063,6 +1108,15 @@ } } }, + { + "component": { + "type": "cargo", + "cargo": { + "name": "libsqlite3-sys", + "version": "0.28.0" + } + } + }, { "component": { "type": "cargo", @@ -1594,6 +1648,15 @@ } } }, + { + "component": { + "type": "cargo", + "cargo": { + "name": "rusqlite", + "version": "0.31.0" + } + } + }, { "component": { "type": "cargo", @@ -2494,6 +2557,15 @@ } } }, + { + "component": { + "type": "cargo", + "cargo": { + "name": "zerocopy", + "version": "0.8.54" + } + } + }, { "component": { "type": "cargo", diff --git a/tools/wta/src/agent_pane_origin.rs b/tools/wta/src/agent_pane_origin.rs index d67b2bca13..350daa8a97 100644 --- a/tools/wta/src/agent_pane_origin.rs +++ b/tools/wta/src/agent_pane_origin.rs @@ -250,11 +250,61 @@ pub fn load_records_from(path: &std::path::Path) -> HashMap Option { + let needle = pane_session_id.trim(); + if needle.is_empty() { + return None; + } + let mut found = None; + // Later paths (the current runtime's index) win over installed-package + // indices, matching the `default_index_paths` / `load_default_records` order. + for path in default_index_paths() { + if let Some(session_id) = last_session_for_pane_in(&path, needle) { + found = Some(session_id); + } + } + found +} + +/// Scan one index file in append order, returning the `session_id` of the last +/// record whose `pane_session_id` matches (case-insensitive), or `None`. +fn last_session_for_pane_in(path: &std::path::Path, pane_session_id: &str) -> Option { + let file = File::open(path).ok()?; + let mut last = None; + for line in BufReader::new(file).lines().map_while(Result::ok) { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(trimmed) else { + continue; + }; + let Some(pane) = value.get("pane_session_id").and_then(|v| v.as_str()) else { + continue; + }; + if !pane.eq_ignore_ascii_case(pane_session_id) { + continue; + } + if let Some(sid) = value.get("session_id").and_then(|v| v.as_str()) { + if !sid.is_empty() { + last = Some(sid.to_string()); + } + } + } + last +} + fn rfc3339_now() -> String { - // Tiny RFC3339 emitter — we don't pull in chrono just for this. The - // exact format is unspecified by callers (the index is for our own - // consumption); a sortable UTC timestamp is enough for `tail -f` - // debugging. let secs = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .map(|d| d.as_secs()) @@ -377,6 +427,30 @@ mod tests { assert!(set.is_empty()); } + #[test] + fn resolve_latest_session_for_pane_returns_last_match() { + let path = tmp_index_path("resolve-pane"); + // The same pane hosted two sessions over time (e.g. via `/new`); the + // latest (last appended) must win. GUID compare is case-insensitive. + append_to(&path, "sess-old", Some("PANE-GUID")).unwrap(); + append_to(&path, "sess-new", Some("pane-guid")).unwrap(); + append_to(&path, "other", Some("different-pane")).unwrap(); + + assert_eq!( + last_session_for_pane_in(&path, "pane-guid").as_deref(), + Some("sess-new") + ); + assert_eq!( + last_session_for_pane_in(&path, "PANE-GUID").as_deref(), + Some("sess-new") + ); + // Unknown pane, and a missing file, both resolve to None. + assert_eq!(last_session_for_pane_in(&path, "no-such-pane"), None); + let missing = std::env::temp_dir().join("no-such-index-77a1.jsonl"); + let _ = std::fs::remove_file(&missing); + assert_eq!(last_session_for_pane_in(&missing, "pane-guid"), None); + } + #[test] fn corrupt_lines_are_skipped() { let path = tmp_index_path("corrupt"); diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index 3ac3cbbbd9..460d8874df 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -1328,6 +1328,14 @@ pub enum AppEvent { request_id: u64, sessions: Vec, }, + /// `shell_sessions/list` response from master (the `/shell-sessions` + /// picker's data). Routed to the tab whose `shell_sessions_pending_request` + /// matches `request_id`; a stale response is dropped. Emitted by + /// `dispatch_master_ext_request`'s `ShellSessionsList` arm. + ShellSessionsLoaded { + request_id: u64, + sessions: Vec, + }, /// `sessions/list` RPC failed or timed out — unblock the tab's /// `refetch_in_flight` gate without overwriting the existing /// snapshot, so the 5s periodic tick / next `SessionsChanged` @@ -1543,6 +1551,23 @@ pub struct TabSession { /// Highlighted row in `App::available_agents`. pub agent_picker_selected: usize, + /// Highlighted row in `shell_sessions` below (the `/shell-sessions` + /// restore picker, shown full-pane via `current_view == View::ShellSessions`). + pub shell_sessions_picker_selected: usize, + /// `Some(index)` while a delete-confirmation prompt is up for that row in + /// the restore view (`D` pressed, awaiting Enter to confirm / Esc to cancel). + pub shell_sessions_pending_delete: Option, + /// Durable shell sessions for the picker, fetched from master's SQLite + /// store (via the `shell_sessions/list` ext method) when the picker is + /// invoked. Empty otherwise. + pub shell_sessions: Vec, + /// Monotonic id for the in-flight `shell_sessions/list` request, so a stale + /// response for a superseded `/shell-sessions` invocation is ignored. + pub shell_sessions_next_request_id: u64, + /// `Some(id)` while a `/shell-sessions` fetch is outstanding for this tab; + /// the matching [`AppEvent::ShellSessionsLoaded`] opens the picker. + pub shell_sessions_pending_request: Option, + // agent session view (`/sessions`) — per-tab so each WT tab keeps // its own open/closed state and selected row across tab switches. pub current_view: View, @@ -1598,6 +1623,7 @@ impl TabSession { && !self.paste_pending && !self.model_picker_open && !self.agent_picker_open + && self.current_view != View::ShellSessions } pub fn clear_recommendations(&mut self) { @@ -2192,6 +2218,10 @@ pub const CLOSE_PANE_ARM_WINDOW: std::time::Duration = std::time::Duration::from pub enum View { Chat, Agents, + /// The `/shell-sessions` restore picker, rendered full-pane (like + /// `Agents`) rather than as a bottom popup, so chat / input / the + /// AI disclaimer are hidden while choosing a durable session to restore. + ShellSessions, } impl Default for View { @@ -2993,6 +3023,214 @@ impl App { self.publish_agent_status(); } + // ── /shell-sessions restore view (full-pane, like /sessions) ───────── + + /// `/shell-sessions` — show a full-pane picker of durable shell sessions + /// Windows Terminal saved on tab close. The list lives in master's SQLite + /// store, so this fires an async `shell_sessions/list` request; the matching + /// [`AppEvent::ShellSessionsLoaded`] switches the tab into + /// [`View::ShellSessions`] (or reports "none saved yet" and stays in chat). + /// Enter then restores the highlighted tab. + fn cmd_shell_sessions(&mut self) { + let tab_id = self.active_tab_key().to_string(); + let request_id = { + let tab = self.tab_mut(&tab_id); + tab.shell_sessions_next_request_id = + tab.shell_sessions_next_request_id.wrapping_add(1); + let id = tab.shell_sessions_next_request_id; + tab.shell_sessions_pending_request = Some(id); + id + }; + let _ = self.master_request_tx.send( + crate::protocol::acp::client::MasterExtRequest::ShellSessionsList { request_id }, + ); + } + + /// Apply a `shell_sessions/list` response: if it matches the tab's pending + /// request, either switch the tab into the full-pane restore view (sessions + /// present) or report that none are saved yet. A stale response (superseded + /// request id) is dropped. We wait for the response before switching views + /// so an empty result stays in chat instead of flashing an empty page. + fn handle_shell_sessions_loaded( + &mut self, + request_id: u64, + sessions: Vec, + ) { + let tab_id = self.tab_sessions.iter().find_map(|(id, tab)| { + (tab.shell_sessions_pending_request == Some(request_id)).then(|| id.clone()) + }); + let Some(tab_id) = tab_id else { + return; + }; + if sessions.is_empty() { + let tab = self.tab_mut(&tab_id); + tab.shell_sessions_pending_request = None; + tab.messages + // TODO(localize): extract to `system.no_shell_sessions`. + .push(ChatMessage::System( + "No saved shell sessions yet — close a tab to save one.".to_string(), + )); + tab.scroll_to_bottom(); + return; + } + let tab = self.tab_mut(&tab_id); + tab.shell_sessions_pending_request = None; + tab.shell_sessions = sessions; + tab.shell_sessions_picker_selected = 0; + tab.current_view = View::ShellSessions; + } + + /// Leave the restore view back to chat and drop the fetched list. + fn close_shell_sessions_view(&mut self) { + let tab = self.current_tab_mut(); + tab.current_view = View::Chat; + tab.shell_sessions.clear(); + tab.shell_sessions_picker_selected = 0; + tab.shell_sessions_pending_delete = None; + } + + /// Confirm the pending delete: ask master to clean-delete the highlighted + /// session (DB row + its scrollback files), and optimistically remove the + /// row from the local list. When the list empties, fall back to chat. + fn confirm_shell_session_delete(&mut self) { + let session_id = { + let tab = self.current_tab(); + let idx = match tab.shell_sessions_pending_delete { + Some(i) => i, + None => return, + }; + tab.shell_sessions.get(idx).map(|e| e.session_id.clone()) + }; + + // Clear the confirmation and optimistically drop the row locally; the + // authoritative delete (DB + files) happens master-side. + { + let tab = self.current_tab_mut(); + if let Some(idx) = tab.shell_sessions_pending_delete.take() { + if idx < tab.shell_sessions.len() { + tab.shell_sessions.remove(idx); + } + let len = tab.shell_sessions.len(); + if len == 0 { + tab.shell_sessions_picker_selected = 0; + } else if tab.shell_sessions_picker_selected >= len { + tab.shell_sessions_picker_selected = len - 1; + } + } + } + + if let Some(session_id) = session_id.filter(|s| !s.is_empty()) { + let _ = self.master_request_tx.send( + crate::protocol::acp::client::MasterExtRequest::ShellSessionsDelete { session_id }, + ); + } + + // Nothing left to restore → return to chat. + if self.current_tab().shell_sessions.is_empty() { + self.close_shell_sessions_view(); + } + } + + fn shell_sessions_picker_up(&mut self) { + let tab = self.current_tab_mut(); + if tab.shell_sessions_picker_selected > 0 { + tab.shell_sessions_picker_selected -= 1; + } + } + + fn shell_sessions_picker_down(&mut self) { + let tab = self.current_tab_mut(); + let last = tab.shell_sessions.len().saturating_sub(1); + if tab.shell_sessions_picker_selected < last { + tab.shell_sessions_picker_selected += 1; + } + } + + /// Commit the highlighted row: ask Windows Terminal to restore the saved + /// tab via a one-way `restore_shell_session` protocol event. The row + /// carries master's authoritative `layout_json`, so we forward it (plus the + /// name) and let the C++ side replay the layout and reconnect scrollback. + /// When the saved tab had an agent pane, resolve its WT pane GUID to the ACP + /// session id so WT can resume that conversation into the rebuilt tab. + fn commit_shell_session_pick(&mut self) { + let picked = { + let tab = self.current_tab(); + let idx = tab.shell_sessions_picker_selected; + tab.shell_sessions.get(idx).cloned() + }; + self.close_shell_sessions_view(); + let Some(picked) = picked else { + return; + }; + let name = picked.name.clone(); + + let mut params = serde_json::Map::new(); + params.insert("name".to_string(), serde_json::Value::String(name.clone())); + // master's authoritative WT layout — forwarded verbatim so C++ replays + // it without a second round-trip. + params.insert( + "layout_json".to_string(), + serde_json::Value::String(picked.layout_json.clone()), + ); + + // Resolve the saved agent pane's WT pane GUID (pane_session_id) to the + // latest ACP session id created in that pane, from the persistent + // agent-pane-sessions.jsonl index (survives a Terminal restart). Skip + // silently when the tab had no agent pane or the session can't be + // resolved — the shell session still restores without an agent. + if let Some(pane_id) = picked + .agent_pane_session_id + .as_deref() + .filter(|s| !s.is_empty()) + { + if let Some(agent_session_id) = + crate::agent_pane_origin::resolve_latest_session_for_pane(pane_id) + { + params.insert( + "agent_session_id".to_string(), + serde_json::Value::String(agent_session_id), + ); + if !picked.cwd.is_empty() { + params.insert( + "agent_cwd".to_string(), + serde_json::Value::String(picked.cwd.clone()), + ); + } + } else { + tracing::info!( + target: "shell_sessions", + pane_id, + "no ACP session found for saved agent pane; restoring shell only", + ); + } + } + + let evt = serde_json::json!({ + "type": "event", + "method": "restore_shell_session", + "params": serde_json::Value::Object(params), + }); + send_wt_protocol_event(evt.to_string()); + tracing::info!(target: "shell_sessions", %name, "restore_shell_session event published"); + + // Mark the session as just used so master bumps its last_used_at and the + // TTL won't reclaim a session the user keeps reopening. + if !picked.session_id.is_empty() { + let _ = self.master_request_tx.send( + crate::protocol::acp::client::MasterExtRequest::ShellSessionsTouch { + session_id: picked.session_id.clone(), + }, + ); + } + + let tab = self.current_tab_mut(); + tab.messages.push(ChatMessage::System( + // TODO(localize): extract to `system.shell_session_restoring`. + format!("Restoring shell session {}…", name), + )); + tab.scroll_to_bottom(); + } + /// Rebuild the shared delegate runtime table from a settings change. /// `delegate_agent` / `delegate_model` are the new effective values /// (empty string = unset → fall back to deriving from the base agent @@ -4554,6 +4792,7 @@ impl App { AppEvent::SessionsChanged => "sessions_changed", AppEvent::AgentsSnapshotLoaded { .. } => "agents_snapshot_loaded", AppEvent::AgentsSnapshotFailed { .. } => "agents_snapshot_failed", + AppEvent::ShellSessionsLoaded { .. } => "shell_sessions_loaded", AppEvent::MasterMutationCompleted { .. } => "master_mutation_completed", AppEvent::RevealTick => "reveal_tick", } @@ -5607,6 +5846,12 @@ impl App { AppEvent::AgentsSnapshotFailed { request_id } => { self.handle_agents_snapshot_failed(request_id); } + AppEvent::ShellSessionsLoaded { + request_id, + sessions, + } => { + self.handle_shell_sessions_loaded(request_id, sessions); + } AppEvent::MasterMutationCompleted { request_id } => { tracing::debug!(target: "agents_view", request_id, "master mutation completed; refetching open views"); self.schedule_agents_refetch_for_open_views(); @@ -7097,6 +7342,39 @@ impl App { return; } + // Shell-session restore view (`/shell-sessions`): full-pane list — + // arrows move, Enter restores the highlighted tab, D asks to delete it + // (Enter confirms, Esc cancels the confirmation), Esc dismisses the + // view. Swallow everything else while it's up. + if self.current_tab().current_view == View::ShellSessions { + // Delete-confirmation sub-state takes priority: Enter confirms the + // clean delete, Esc cancels, anything else is ignored. + if self.current_tab().shell_sessions_pending_delete.is_some() { + match key.code { + KeyCode::Enter => self.confirm_shell_session_delete(), + KeyCode::Esc => { + self.current_tab_mut().shell_sessions_pending_delete = None; + } + _ => {} + } + return; + } + match key.code { + KeyCode::Up => self.shell_sessions_picker_up(), + KeyCode::Down => self.shell_sessions_picker_down(), + KeyCode::Enter => self.commit_shell_session_pick(), + KeyCode::Char('d') | KeyCode::Char('D') => { + let tab = self.current_tab_mut(); + if !tab.shell_sessions.is_empty() { + tab.shell_sessions_pending_delete = Some(tab.shell_sessions_picker_selected); + } + } + KeyCode::Esc => self.close_shell_sessions_view(), + _ => {} + } + return; + } + if self.current_tab().paste_pending { tracing::debug!(target: "agent_paste", "ignoring key while paste is pending"); return; @@ -7771,6 +8049,26 @@ impl App { }) } + /// Full-pane render state for the `/shell-sessions` restore view, or `None` + /// when the active tab isn't in that view. + pub fn shell_sessions_view_state(&self) -> Option> { + let tab = self.current_tab(); + if tab.current_view != View::ShellSessions { + return None; + } + // When a delete confirmation is pending, surface the target row's name + // so the view can render the confirm prompt in place of the hint. + let confirm_delete = tab + .shell_sessions_pending_delete + .and_then(|i| tab.shell_sessions.get(i)) + .map(|e| e.name.as_str()); + Some(crate::ui::ShellSessionsViewState { + sessions: &tab.shell_sessions, + selected: tab.shell_sessions_picker_selected, + confirm_delete, + }) + } + /// Handle Enter for the slash-command system. Centralizes all three /// intents in one place so the giant `handle_key` match has a single /// guard instead of an inline block plus a separate popup arm: @@ -7910,6 +8208,7 @@ impl App { CommandKind::Agent => self.cmd_agent(cmd.rest), CommandKind::Model => self.cmd_model(cmd.rest), CommandKind::Move => self.cmd_move(cmd.rest), + CommandKind::ShellSessions => self.cmd_shell_sessions(), } } @@ -9812,6 +10111,10 @@ impl App { let view = match tab.current_view { View::Agents => "sessions", View::Chat => "chat", + // The shell-session restore picker is a transient full-pane overlay + // entered from chat; to C++'s pane-chrome logic it is chat-like + // (not the dedicated agent-sessions management mode). + View::ShellSessions => "chat", }; let evt = serde_json::json!({ "type": "event", diff --git a/tools/wta/src/commands.rs b/tools/wta/src/commands.rs index 3ba1f29094..366f61e5e9 100644 --- a/tools/wta/src/commands.rs +++ b/tools/wta/src/commands.rs @@ -56,6 +56,13 @@ pub enum CommandKind { /// Move this tab's agent pane without changing the global pane-position /// setting or any other tab. Move, + /// Restore a durable shell session Windows Terminal saved on tab close. + /// + /// Bare `/shell-sessions` opens an interactive picker of the saved + /// sessions (read from the `shell-sessions.json` sidecar WT writes); Enter + /// asks WT to rebuild the highlighted tab — its layout, working directory, + /// and scrollback. This is a zero-arg command; the picker is the only UI. + ShellSessions, } #[derive(Debug, Clone, Copy)] @@ -147,6 +154,15 @@ pub const REGISTRY: &[CommandSpec] = &[ kind: CommandKind::Move, takes_args: true, }, + CommandSpec { + name: "shell-sessions", + // TODO(localize): use a `commands.shell_sessions.summary` catalog key + // when the localization pass adds it to every locale. Until then the + // English text is returned verbatim by `t!` (unknown key -> key text). + summary_key: "Restore a saved shell session (layout, scrollback, working directory)", + kind: CommandKind::ShellSessions, + takes_args: false, + }, ]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -361,6 +377,17 @@ mod tests { assert!(s_matches.contains(&"sessions")); } + #[test] + fn shell_sessions_parses() { + let p = parse("/shell-sessions").unwrap(); + assert_eq!(p.kind, CommandKind::ShellSessions); + assert_eq!(p.rest, ""); + assert!(!lookup("shell-sessions").unwrap().takes_args); + // Prefix "shell" resolves uniquely to the shell-sessions command. + let m: Vec<&str> = matches("shell").into_iter().map(|c| c.name).collect(); + assert_eq!(m, vec!["shell-sessions"]); + } + #[test] fn agent_parses_with_optional_id() { let bare = parse("/agent").unwrap(); diff --git a/tools/wta/src/master/mod.rs b/tools/wta/src/master/mod.rs index 7c7b1fabf7..33b9619f42 100644 --- a/tools/wta/src/master/mod.rs +++ b/tools/wta/src/master/mod.rs @@ -62,6 +62,9 @@ use crate::protocol::acp::conn; use crate::protocol::acp::spawn::spawn_agent_process; use crate::Cli; +/// Durable shell-session SQLite store. Owned exclusively by wta-master. +mod shell_sessions_db; + /// Opaque identifier for a helper connection. Used in logs only; /// routing keys off `acp::schema::v1::SessionId`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -1467,6 +1470,9 @@ impl HelperHandler { match crate::session_registry::parse_ext_request(args) { Req::FocusSession(p) => handle_focus_session(&self.state, &p).await, Req::SessionsList(p) => handle_sessions_list(&self.state, &p).await, + Req::ShellSessionsList(_) => handle_shell_sessions_list().await, + Req::ShellSessionsDelete(p) => handle_shell_sessions_delete(&p.session_id).await, + Req::ShellSessionsTouch(p) => handle_shell_sessions_touch(&p.session_id).await, Req::SessionHook(ev) => handle_session_hook(&self.state, ev, false).await, Req::SessionBornBound(ev, wsl_distro) => { handle_session_born_bound(&self.state, ev, wsl_distro).await @@ -1537,6 +1543,11 @@ pub async fn run_master_mode(cli: Cli, pipe_name: String) -> Result<()> { } }); + // Sweep expired durable shell sessions (15-day TTL) off the blocking pool + // so a large/slow DB or filesystem can't delay the pipe accept loop. master + // owns the DB, so this is the only sweeper — lock-free and fire-and-forget. + tokio::task::spawn_blocking(sweep_expired_shell_sessions); + let local_set = LocalSet::new(); let result = local_set .run_until(async move { run_master_loop(cli, pipe_name).await }) @@ -3238,6 +3249,281 @@ async fn handle_sessions_list( Ok(acp::schema::v1::ExtResponse::new(raw.into())) } +// ─── Durable shell sessions (SQLite, master-owned) ─────────────────────────── +// +// master is the SOLE owner of the shell-session store: C++ ships each save as a +// `save_shell_session` WT event (handled in `handle_master_wt_event`), and the +// `/shell-sessions` picker reads via the `shell_sessions/list` ext method. All +// DB access funnels through the tiny helpers below. + +/// Retention window for durable shell sessions. Rows untouched for longer than +/// this — and their scrollback files — are swept once at master startup. +const SHELL_SESSION_TTL_DAYS: i64 = 15; + +/// Path to the shell-session SQLite DB (`\shell-sessions.db`), or +/// `None` when the IT root can't be resolved (no package identity and no +/// `LOCALAPPDATA`). +fn shell_sessions_db_path() -> Option { + crate::runtime_paths::intelligent_terminal_root().map(|r| r.join("shell-sessions.db")) +} + +/// Directory holding per-pane scrollback files +/// (`\shell-session-buffers\{guid}.txt`). C++ writes them on tab +/// close; master deletes them when their row is superseded or expires. +fn shell_session_buffers_dir() -> Option { + crate::runtime_paths::intelligent_terminal_root().map(|r| r.join("shell-session-buffers")) +} + +/// Best-effort unlink of the scrollback files for `guids`. A missing file is +/// fine (already gone); other errors are logged, never propagated — buffer +/// cleanup must not fail a save or block startup. +fn unlink_shell_session_buffers(guids: &[String]) { + let Some(dir) = shell_session_buffers_dir() else { + return; + }; + for guid in guids { + let path = dir.join(format!("{guid}.txt")); + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => tracing::warn!( + target: "shell_sessions", + guid = %guid, + err = %e, + "failed to delete orphaned scrollback file" + ), + } + } +} + +/// Handle a `save_shell_session` WT event: upsert the row and unlink any +/// scrollback files orphaned by overwriting a same-named session. +fn save_shell_session_from_event(params: &serde_json::Value) { + let Some(db_path) = shell_sessions_db_path() else { + tracing::warn!(target: "shell_sessions", "no IT root; dropping save_shell_session"); + return; + }; + let session_id = params + .get("session_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let layout_json = params + .get("layout_json") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if session_id.is_empty() || layout_json.is_empty() { + tracing::warn!( + target: "shell_sessions", + has_session_id = !session_id.is_empty(), + has_layout = !layout_json.is_empty(), + "save_shell_session missing session_id/layout_json; ignoring" + ); + return; + } + let cwd = params + .get("cwd") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + // `saved_at` from the event seeds both timestamps on a save: content just + // changed (updated_at) and the session was just used (last_used_at). + let saved_at = params + .get("saved_at") + .and_then(|v| v.as_i64()) + .unwrap_or_else(unix_now_secs); + let buffer_guids = params + .get("buffer_guids") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect::>() + }) + .unwrap_or_default(); + // Present only when the closed tab had an open agent pane (see the C++ + // `_SaveShellSessionForTab`); resolved to an ACP session id on restore. + let agent_pane_session_id = params + .get("agent_pane_session_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string); + + let row = shell_sessions_db::ShellSessionRow { + session_id: session_id.to_string(), + name: name.to_string(), + cwd, + updated_at: saved_at, + last_used_at: saved_at, + layout_json: layout_json.to_string(), + buffer_guids, + agent_pane_session_id, + }; + + match shell_sessions_db::open(&db_path).and_then(|conn| shell_sessions_db::upsert(&conn, &row)) { + Ok(orphaned) => { + tracing::info!( + target: "shell_sessions", + name = %row.name, + buffers = row.buffer_guids.len(), + orphaned = orphaned.len(), + "saved durable shell session" + ); + unlink_shell_session_buffers(&orphaned); + } + Err(e) => tracing::warn!( + target: "shell_sessions", + name = %row.name, + err = %e, + "failed to save shell session" + ), + } +} + +/// Serve the `shell_sessions/list` ext request from the DB. +async fn handle_shell_sessions_list() -> acp::Result { + let sessions = load_shell_sessions_for_list(); + let raw = crate::session_registry::build_shell_sessions_list_response(sessions); + Ok(acp::schema::v1::ExtResponse::new(raw.into())) +} + +/// Serve the `shell_sessions/delete` ext request: remove the named row from the +/// DB and unlink its scrollback files, for a clean delete. Best-effort — errors +/// are logged; the ext response is always empty-success so the helper's +/// optimistic UI removal stands. +async fn handle_shell_sessions_delete(session_id: &str) -> acp::Result { + if let Some(db_path) = shell_sessions_db_path() { + match shell_sessions_db::open(&db_path) + .and_then(|conn| shell_sessions_db::delete(&conn, session_id)) + { + Ok(guids) => { + tracing::info!( + target: "shell_sessions", + session_id = %session_id, + buffers = guids.len(), + "deleted durable shell session" + ); + unlink_shell_session_buffers(&guids); + } + Err(e) => tracing::warn!( + target: "shell_sessions", + session_id = %session_id, + err = %e, + "failed to delete shell session" + ), + } + } else { + tracing::warn!(target: "shell_sessions", "no IT root; dropping shell_sessions/delete"); + } + let raw = serde_json::value::to_raw_value(&serde_json::json!({})) + .expect("empty object serializes"); + Ok(acp::schema::v1::ExtResponse::new(raw.into())) +} + +/// Serve the `shell_sessions/touch` ext request: bump the session's +/// `last_used_at` so a session the user keeps restoring survives the TTL. +/// Best-effort empty-success response. +async fn handle_shell_sessions_touch(session_id: &str) -> acp::Result { + if let Some(db_path) = shell_sessions_db_path() { + let now = unix_now_secs(); + match shell_sessions_db::open(&db_path) + .and_then(|conn| shell_sessions_db::touch(&conn, session_id, now)) + { + Ok(()) => tracing::debug!( + target: "shell_sessions", + session_id = %session_id, + "touched durable shell session (last_used_at bumped)" + ), + Err(e) => tracing::warn!( + target: "shell_sessions", + session_id = %session_id, + err = %e, + "failed to touch shell session" + ), + } + } + let raw = serde_json::value::to_raw_value(&serde_json::json!({})) + .expect("empty object serializes"); + Ok(acp::schema::v1::ExtResponse::new(raw.into())) +} + +/// Read all durable shell sessions from the DB as wire rows (newest first). +/// Returns empty on any error so the picker degrades to "no saved sessions". +fn load_shell_sessions_for_list() -> Vec { + let Some(db_path) = shell_sessions_db_path() else { + return Vec::new(); + }; + match shell_sessions_db::open(&db_path).and_then(|conn| shell_sessions_db::list(&conn)) { + Ok(rows) => rows + .into_iter() + .map(|r| crate::session_registry::ShellSessionInfo { + session_id: r.session_id, + name: r.name, + cwd: r.cwd, + updated_at: r.updated_at, + layout_json: r.layout_json, + agent_pane_session_id: r.agent_pane_session_id, + }) + .collect(), + Err(e) => { + tracing::warn!(target: "shell_sessions", err = %e, "failed to list shell sessions"); + Vec::new() + } + } +} + +/// Delete shell sessions older than [`SHELL_SESSION_TTL_DAYS`] plus their +/// scrollback files, but at most once per day (see +/// [`shell_sessions_db::TTL_SWEEP_MIN_INTERVAL_SECS`]). Invoked on every master +/// startup, but the per-day gate in the DB means a user who relaunches WT +/// repeatedly pays the scan only once a day. Best-effort: any error is logged, +/// never fatal — durable sessions are a convenience, not core. No-op when the +/// DB file doesn't exist yet (nothing was ever saved). +fn sweep_expired_shell_sessions() { + let Some(db_path) = shell_sessions_db_path() else { + return; + }; + if !db_path.exists() { + return; + } + let now = unix_now_secs(); + let ttl_secs = SHELL_SESSION_TTL_DAYS * 24 * 60 * 60; + match shell_sessions_db::open(&db_path) + .and_then(|conn| shell_sessions_db::ttl_sweep_if_due(&conn, now, ttl_secs)) + { + Ok(shell_sessions_db::TtlSweepOutcome::Swept(orphaned)) => { + if !orphaned.is_empty() { + tracing::info!( + target: "shell_sessions", + files = orphaned.len(), + ttl_days = SHELL_SESSION_TTL_DAYS, + "TTL-swept expired shell sessions" + ); + unlink_shell_session_buffers(&orphaned); + } + } + Ok(shell_sessions_db::TtlSweepOutcome::Skipped) => { + tracing::debug!( + target: "shell_sessions", + "shell-session TTL sweep skipped (ran within the last 24h)" + ); + } + Err(e) => tracing::warn!( + target: "shell_sessions", + err = %e, + "shell-session TTL sweep failed" + ), + } +} + +/// Current Unix time in whole seconds (0 on the impossible pre-epoch clock). +fn unix_now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + /// Pure async handler for the `intellterm.wta/session_hook` ExtRequest. /// /// Decodes the hook event, dispatches it to the master-side registry reducer @@ -3462,6 +3748,17 @@ async fn handle_master_wt_event( .get("method") .and_then(|v| v.as_str()) .unwrap_or(""); + // Durable shell-session save: C++ raises this on tab close (see + // `TerminalPage::_SaveShellSessionForTab`). master is the sole DB owner, so + // the write happens here rather than C++-side. + if method == "save_shell_session" { + let params = event_json + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null); + save_shell_session_from_event(¶ms); + return; + } if method != "connection_state" { return; } diff --git a/tools/wta/src/master/shell_sessions_db.rs b/tools/wta/src/master/shell_sessions_db.rs new file mode 100644 index 0000000000..83845a8981 --- /dev/null +++ b/tools/wta/src/master/shell_sessions_db.rs @@ -0,0 +1,698 @@ +// tools/wta/src/master/shell_sessions_db.rs +// +// Durable shell-session SQLite store, owned **exclusively** by `wta-master`. +// +// Why master-only: the store is written on tab close and read by the +// `/shell-sessions` picker. Rather than let C++ and every helper open the +// same DB (cross-process + cross-language locking, plus the elevated / +// non-elevated split that WT's `state.json` needs), master is the single +// owner. C++ ships the metadata via a `save_shell_session` WT event; helpers +// query via the `intellterm.wta/shell_sessions/list` ACP ext method. This +// keeps the DB single-writer/single-reader, so no WAL/busy-timeout dance is +// needed. +// +// What lives here vs. on disk: +// * SQLite row = { session_id (PK = anchor pane GUID), name, cwd, +// updated_at, last_used_at, layout_json, buffer_guids }. Keyed by +// session_id (NOT the title), so different tabs may share a name. +// `updated_at` (last save) drives list order; `last_used_at` (last save OR +// restore) drives the TTL. +// * Scrollback = files on disk (`\shell-session-buffers\{guid}.txt`), +// written/read by WT's terminal core via a file handle. Too large to ship +// base64 through JSON-RPC, so they stay as files; the row only references +// them by GUID. TTL cleanup deletes the row and its buffer files together +// (see [`ttl_sweep`]), and an upsert over an existing name returns the +// superseded GUIDs so the caller can unlink the now-orphaned files. + +use anyhow::{Context, Result}; +use rusqlite::Connection; + +/// One durable shell session as stored in the DB. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShellSessionRow { + /// Stable per-tab identity and the row's primary key — the anchor terminal + /// pane's WT session GUID (a restored tab reuses it, so re-saving updates + /// the same row instead of duplicating). NOT the title: two different tabs + /// may share a `name`, so the display name can't be the key. + pub session_id: String, + /// The tab's title — display label only (no longer unique across rows). + pub name: String, + /// Working directory captured at save time (display only; may be empty). + pub cwd: String, + /// Unix seconds of the last **save** (content change). Drives the + /// newest-first list ordering. + pub updated_at: i64, + /// Unix seconds of the last **use** — a save or a restore. Drives the TTL + /// (a session you keep restoring stays alive even if never re-saved). + pub last_used_at: i64, + /// Opaque WT `WindowLayout` JSON. C++ replays this verbatim on restore. + pub layout_json: String, + /// Pane session GUIDs whose scrollback lives in + /// `\shell-session-buffers\{guid}.txt`. Used by TTL / upsert to + /// delete the orphaned buffer files. + pub buffer_guids: Vec, + /// The agent pane's WT session GUID (`pane_session_id`) at save time, if the + /// saved tab had an open agent pane. `None` when it didn't. Resolved to an + /// ACP session id (via `agent_pane_origin`) on restore, so the agent + /// conversation is resumed into the rebuilt tab. + pub agent_pane_session_id: Option, +} + +/// Open (creating if needed) the shell-session DB at `db_path` and ensure the +/// schema exists. +/// +/// `buffer_guids` is stored as a JSON array string — a session rarely has more +/// than a handful of panes, so a child table would be overkill. +pub fn open(db_path: &std::path::Path) -> Result { + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("creating shell-session DB dir {}", parent.display()))?; + } + let conn = Connection::open(db_path) + .with_context(|| format!("opening shell-session DB {}", db_path.display()))?; + // master is the only writer, but a `/shell-sessions` list can race a + // concurrent save; a short busy timeout turns a rare SQLITE_BUSY into a + // brief wait instead of an error. + conn.busy_timeout(std::time::Duration::from_secs(5)) + .context("setting shell-session DB busy_timeout")?; + init_schema(&conn)?; + Ok(conn) +} + +/// Create the `sessions` table if it does not exist. Split out so tests can run +/// it against an in-memory connection. +fn init_schema(conn: &Connection) -> Result<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + cwd TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL DEFAULT 0, + last_used_at INTEGER NOT NULL DEFAULT 0, + layout_json TEXT NOT NULL, + buffer_guids TEXT NOT NULL DEFAULT '[]', + agent_pane_session_id TEXT + ); + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL + );", + ) + .context("initializing shell-session schema")?; + // Migrate DBs created before `agent_pane_session_id` existed (the column was + // added when durable agent-session resume landed). Idempotent: skipped once + // the column is present. + ensure_agent_pane_session_id_column(conn)?; + // Migrate DBs whose `sessions` table still keys on `name` (the pre-refactor + // shape) to the `session_id` primary key, so different tabs can share a name. + // Must run before the `updated_at` index below: a legacy table has no such + // column until the migration rebuilds it. + migrate_name_pk_to_session_id(conn)?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at)", + [], + ) + .context("creating shell-session updated_at index")?; + Ok(()) +} + +/// Add the `agent_pane_session_id` column to a preexisting `sessions` table +/// that was created before that column existed. No-op when it's already there. +fn ensure_agent_pane_session_id_column(conn: &Connection) -> Result<()> { + let has_column = conn + .prepare("SELECT 1 FROM pragma_table_info('sessions') WHERE name = 'agent_pane_session_id'") + .and_then(|mut stmt| stmt.exists([])) + .context("checking for agent_pane_session_id column")?; + if !has_column { + conn.execute("ALTER TABLE sessions ADD COLUMN agent_pane_session_id TEXT", []) + .context("adding agent_pane_session_id column")?; + } + Ok(()) +} + +/// Rebuild a legacy `sessions` table (title as PRIMARY KEY, single `saved_at`) +/// into the current shape (`session_id` PRIMARY KEY, `name` a plain column, +/// split `updated_at` / `last_used_at`), preserving rows. The old title becomes +/// `name`; the new `session_id` is derived from the row's first buffer GUID (the +/// anchor pane, matching how C++ now keys sessions), falling back to the title +/// when a legacy row had no buffers; the old `saved_at` seeds both timestamps. +/// No-op once the `session_id` column is present. +fn migrate_name_pk_to_session_id(conn: &Connection) -> Result<()> { + let has_session_id = conn + .prepare("SELECT 1 FROM pragma_table_info('sessions') WHERE name = 'session_id'") + .and_then(|mut stmt| stmt.exists([])) + .context("checking for session_id column")?; + if has_session_id { + return Ok(()); + } + conn.execute_batch( + "BEGIN; + ALTER TABLE sessions RENAME TO sessions_legacy; + CREATE TABLE sessions ( + session_id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + cwd TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL DEFAULT 0, + last_used_at INTEGER NOT NULL DEFAULT 0, + layout_json TEXT NOT NULL, + buffer_guids TEXT NOT NULL DEFAULT '[]', + agent_pane_session_id TEXT + ); + INSERT OR IGNORE INTO sessions + (session_id, name, cwd, updated_at, last_used_at, layout_json, buffer_guids, agent_pane_session_id) + SELECT + COALESCE(NULLIF(json_extract(buffer_guids, '$[0]'), ''), name), + name, cwd, saved_at, saved_at, layout_json, buffer_guids, agent_pane_session_id + FROM sessions_legacy; + DROP TABLE sessions_legacy; + CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at); + COMMIT;", + ) + .context("migrating sessions table to session_id primary key")?; + Ok(()) +} + +/// Insert or replace the row for `row.session_id`. +/// +/// Returns the buffer GUIDs orphaned by the overwrite — the previous row's +/// GUIDs that the **new** row no longer references — so the caller can unlink +/// only the now-dead scrollback files. Crucially this is a set difference +/// (`old - new`), NOT all of the old GUIDs: a restored tab reuses its pane +/// session GUIDs, so re-saving the same session writes the same +/// `{guid}.txt` files C++ just refreshed; returning those as "orphaned" would +/// delete the freshly-written buffers and leave the row pointing at nothing. +/// Returns an empty vec when there was no prior row (or nothing was dropped). +/// +/// Keyed by `session_id` (the anchor pane GUID), NOT the title — so two tabs +/// that share a `name` are stored as distinct rows. +pub fn upsert(conn: &Connection, row: &ShellSessionRow) -> Result> { + let previous = get_buffer_guids(conn, &row.session_id)?; + let kept: std::collections::HashSet<&str> = + row.buffer_guids.iter().map(String::as_str).collect(); + let orphaned: Vec = previous + .into_iter() + .filter(|g| !kept.contains(g.as_str())) + .collect(); + + let guids_json = serde_json::to_string(&row.buffer_guids) + .context("serializing buffer_guids")?; + conn.execute( + "INSERT INTO sessions + (session_id, name, cwd, updated_at, last_used_at, layout_json, buffer_guids, agent_pane_session_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(session_id) DO UPDATE SET + name = excluded.name, + cwd = excluded.cwd, + updated_at = excluded.updated_at, + last_used_at = excluded.last_used_at, + layout_json = excluded.layout_json, + buffer_guids = excluded.buffer_guids, + agent_pane_session_id = excluded.agent_pane_session_id", + rusqlite::params![ + row.session_id, + row.name, + row.cwd, + row.updated_at, + row.last_used_at, + row.layout_json, + guids_json, + row.agent_pane_session_id + ], + ) + .with_context(|| format!("upserting shell session {}", row.session_id))?; + + Ok(orphaned) +} + +/// Mark a session as used **now** without changing its content: bumps +/// `last_used_at` only, so a restored-but-unmodified session survives the TTL. +/// No-op when `session_id` is unknown. +pub fn touch(conn: &Connection, session_id: &str, now: i64) -> Result<()> { + conn.execute( + "UPDATE sessions SET last_used_at = ?2 WHERE session_id = ?1", + rusqlite::params![session_id, now], + ) + .with_context(|| format!("touching shell session {session_id}"))?; + Ok(()) +} + +/// All saved sessions, newest-updated first. +pub fn list(conn: &Connection) -> Result> { + let mut stmt = conn + .prepare( + "SELECT session_id, name, cwd, updated_at, last_used_at, layout_json, buffer_guids, agent_pane_session_id + FROM sessions ORDER BY updated_at DESC", + ) + .context("preparing shell-session list query")?; + let rows = stmt + .query_map([], row_from_sqlite) + .context("querying shell sessions")? + .collect::, _>>() + .context("collecting shell-session rows")?; + Ok(rows) +} + +/// Delete the row with `session_id` and return its buffer GUIDs, so the caller +/// can unlink the scrollback files for a clean removal. Returns an empty vec +/// when no such row existed (idempotent). +pub fn delete(conn: &Connection, session_id: &str) -> Result> { + let orphaned = get_buffer_guids(conn, session_id)?; + conn.execute("DELETE FROM sessions WHERE session_id = ?1", [session_id]) + .with_context(|| format!("deleting shell session {session_id}"))?; + Ok(orphaned) +} + +/// Delete rows whose last use predates `cutoff_unix_secs` and return the buffer +/// GUIDs of every deleted row, so the caller can unlink their scrollback files. +/// +/// TTL is keyed on `last_used_at` (save OR restore), not on the save time — a +/// session you keep restoring won't be swept just because its content is old. +/// Lock-free and safe because master is the only writer: no other process can +/// be mid-write on a row master is deleting. +pub fn ttl_sweep(conn: &Connection, cutoff_unix_secs: i64) -> Result> { + let orphaned = { + let mut stmt = conn + .prepare("SELECT buffer_guids FROM sessions WHERE last_used_at < ?1") + .context("preparing TTL select")?; + let guid_lists = stmt + .query_map([cutoff_unix_secs], |r| r.get::<_, String>(0)) + .context("querying expired sessions")? + .collect::, _>>() + .context("collecting expired buffer guid lists")?; + guid_lists + .iter() + .flat_map(|json| parse_guids(json)) + .collect::>() + }; + + conn.execute("DELETE FROM sessions WHERE last_used_at < ?1", [cutoff_unix_secs]) + .context("deleting expired shell sessions")?; + + Ok(orphaned) +} + +/// Minimum wall-clock interval between TTL sweeps. master runs the sweep at +/// most once per this window regardless of how many times it restarts in a day +/// (each WT relaunch respawns master), so a user who opens/closes the terminal +/// repeatedly doesn't pay the scan every time. +pub const TTL_SWEEP_MIN_INTERVAL_SECS: i64 = 24 * 60 * 60; + +/// Outcome of [`ttl_sweep_if_due`]. +#[derive(Debug)] +pub enum TtlSweepOutcome { + /// The sweep ran; carries the orphaned buffer GUIDs for the caller to + /// unlink. + Swept(Vec), + /// Skipped because the previous sweep was within + /// [`TTL_SWEEP_MIN_INTERVAL_SECS`]. + Skipped, +} + +/// Run [`ttl_sweep`] at most once per [`TTL_SWEEP_MIN_INTERVAL_SECS`]. +/// +/// Reads the last-sweep timestamp from the `meta` table; if the window hasn't +/// elapsed, returns [`TtlSweepOutcome::Skipped`] without touching the sessions +/// table. Otherwise, deletes rows older than `now - ttl_secs`, records `now` as +/// the new last-sweep time, and returns their orphaned buffer GUIDs. Storing +/// the timestamp in the DB (rather than a separate file) keeps the whole store +/// in one master-owned place. +pub fn ttl_sweep_if_due(conn: &Connection, now: i64, ttl_secs: i64) -> Result { + if let Some(last) = last_ttl_sweep(conn)? { + if now.saturating_sub(last) < TTL_SWEEP_MIN_INTERVAL_SECS { + return Ok(TtlSweepOutcome::Skipped); + } + } + let orphaned = ttl_sweep(conn, now - ttl_secs)?; + record_ttl_sweep(conn, now)?; + Ok(TtlSweepOutcome::Swept(orphaned)) +} + +/// Read the recorded Unix time of the last TTL sweep, or `None` if never swept. +fn last_ttl_sweep(conn: &Connection) -> Result> { + conn.query_row( + "SELECT value FROM meta WHERE key = 'last_ttl_sweep'", + [], + |r| r.get::<_, i64>(0), + ) + .map(Some) + .or_else(|err| match err { + rusqlite::Error::QueryReturnedNoRows => Ok(None), + other => Err(other), + }) + .context("reading last_ttl_sweep") +} + +/// Record `now` as the last-sweep time. +fn record_ttl_sweep(conn: &Connection, now: i64) -> Result<()> { + conn.execute( + "INSERT INTO meta (key, value) VALUES ('last_ttl_sweep', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [now], + ) + .context("recording last_ttl_sweep")?; + Ok(()) +} + +/// Look up the buffer GUIDs currently stored under `session_id` (empty when absent). +fn get_buffer_guids(conn: &Connection, session_id: &str) -> Result> { + let json: Option = conn + .query_row( + "SELECT buffer_guids FROM sessions WHERE session_id = ?1", + [session_id], + |r| r.get(0), + ) + .or_else(|err| match err { + rusqlite::Error::QueryReturnedNoRows => Ok(None), + other => Err(other), + }) + .with_context(|| format!("looking up buffer guids for {session_id}"))?; + Ok(json.map(|j| parse_guids(&j)).unwrap_or_default()) +} + +/// Decode a `buffer_guids` JSON array, tolerating corruption by returning an +/// empty list (a malformed cell must not abort a TTL sweep or list). +fn parse_guids(json: &str) -> Vec { + serde_json::from_str::>(json).unwrap_or_default() +} + +/// Map one SQLite row to a [`ShellSessionRow`]. Column order must match the +/// SELECTs in [`list`]. +fn row_from_sqlite(r: &rusqlite::Row<'_>) -> rusqlite::Result { + let buffer_guids: String = r.get(6)?; + Ok(ShellSessionRow { + session_id: r.get(0)?, + name: r.get(1)?, + cwd: r.get(2)?, + updated_at: r.get(3)?, + last_used_at: r.get(4)?, + layout_json: r.get(5)?, + buffer_guids: parse_guids(&buffer_guids), + agent_pane_session_id: r.get(7)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mem() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + init_schema(&conn).unwrap(); + conn + } + + // Build a row whose session_id is anchored to the first buffer guid (as C++ + // does), or the name when no guids are given. `ts` seeds both timestamps. + fn row(name: &str, ts: i64, guids: &[&str]) -> ShellSessionRow { + let session_id = guids + .first() + .map(|s| s.to_string()) + .unwrap_or_else(|| name.to_string()); + row_with_id(&session_id, name, ts, guids) + } + + // Build a row with an explicit session_id — for tests that re-save the same + // session with different buffer guids (where the guid can't be the key). + fn row_with_id(session_id: &str, name: &str, ts: i64, guids: &[&str]) -> ShellSessionRow { + ShellSessionRow { + session_id: session_id.to_string(), + name: name.to_string(), + cwd: format!("C:\\{name}"), + updated_at: ts, + last_used_at: ts, + layout_json: format!("{{\"tab\":\"{name}\"}}"), + buffer_guids: guids.iter().map(|s| s.to_string()).collect(), + agent_pane_session_id: None, + } + } + + #[test] + fn upsert_then_list_newest_first() { + let conn = mem(); + assert!(upsert(&conn, &row("old", 100, &["g1"])).unwrap().is_empty()); + assert!(upsert(&conn, &row("new", 200, &["g2"])).unwrap().is_empty()); + + let all = list(&conn).unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].name, "new"); + assert_eq!(all[1].name, "old"); + assert_eq!(all[0].cwd, "C:\\new"); + assert_eq!(all[0].buffer_guids, vec!["g2".to_string()]); + } + + #[test] + fn different_tabs_can_share_a_name() { + // The point of keying on session_id: two tabs both titled "PowerShell" + // are distinct rows, not one overwriting the other. + let conn = mem(); + upsert(&conn, &row_with_id("s-1", "PowerShell", 100, &["a"])).unwrap(); + upsert(&conn, &row_with_id("s-2", "PowerShell", 200, &["b"])).unwrap(); + let all = list(&conn).unwrap(); + assert_eq!(all.len(), 2, "same name, different session_id -> two rows"); + assert!(all.iter().all(|r| r.name == "PowerShell")); + } + + #[test] + fn upsert_same_session_overwrites_and_returns_orphaned_guids() { + let conn = mem(); + upsert(&conn, &row_with_id("s", "tab", 100, &["old-a", "old-b"])).unwrap(); + + // Same session_id, entirely fresh GUIDs: both old ones are orphaned. + let orphaned = upsert(&conn, &row_with_id("s", "tab", 200, &["new-a"])).unwrap(); + assert_eq!(orphaned, vec!["old-a".to_string(), "old-b".to_string()]); + + let all = list(&conn).unwrap(); + assert_eq!(all.len(), 1, "same session_id must not create a second row"); + assert_eq!(all[0].updated_at, 200); + assert_eq!(all[0].buffer_guids, vec!["new-a".to_string()]); + } + + #[test] + fn upsert_reusing_same_guids_orphans_nothing() { + // A restored tab reuses its pane session GUIDs, so re-saving the same + // session writes the same {guid}.txt files. Those must NOT be reported + // as orphaned (that would delete the freshly-written buffers). + let conn = mem(); + upsert(&conn, &row_with_id("s", "tab", 100, &["a", "b"])).unwrap(); + let orphaned = upsert(&conn, &row_with_id("s", "tab", 200, &["a", "b"])).unwrap(); + assert!(orphaned.is_empty(), "re-saving identical guids must orphan nothing"); + assert_eq!(list(&conn).unwrap()[0].buffer_guids, vec!["a".to_string(), "b".to_string()]); + } + + #[test] + fn upsert_partial_overlap_orphans_only_dropped_guids() { + let conn = mem(); + upsert(&conn, &row_with_id("s", "tab", 100, &["a", "b"])).unwrap(); + // New row keeps `a`, drops `b`, adds `c` → only `b` is orphaned. + let orphaned = upsert(&conn, &row_with_id("s", "tab", 200, &["a", "c"])).unwrap(); + assert_eq!(orphaned, vec!["b".to_string()]); + } + + #[test] + fn touch_bumps_last_used_at_only_and_list_orders_by_updated_at() { + let conn = mem(); + upsert(&conn, &row("a", 100, &["ga"])).unwrap(); + upsert(&conn, &row("b", 200, &["gb"])).unwrap(); + // Touch `a` (bumps last_used_at) — updated_at and list order unchanged. + touch(&conn, "ga", 999).unwrap(); + let all = list(&conn).unwrap(); + assert_eq!(all[0].name, "b", "list sorts by updated_at, not last_used_at"); + let a = all.iter().find(|r| r.name == "a").unwrap(); + assert_eq!(a.updated_at, 100, "touch must not change updated_at"); + assert_eq!(a.last_used_at, 999); + } + + #[test] + fn touch_missing_session_is_noop() { + let conn = mem(); + upsert(&conn, &row("tab", 100, &["g"])).unwrap(); + touch(&conn, "nope", 500).unwrap(); + assert_eq!(list(&conn).unwrap()[0].last_used_at, 100); + } + + #[test] + fn ttl_sweep_keys_on_last_used_at() { + let conn = mem(); + // stale: saved long ago AND never re-used → expired. + upsert(&conn, &row("stale", 100, &["s1", "s2"])).unwrap(); + // kept: saved long ago but recently *used* (restored) → survives. + upsert(&conn, &row("kept", 100, &["k1"])).unwrap(); + touch(&conn, "k1", 500).unwrap(); + + let mut orphaned = ttl_sweep(&conn, 300).unwrap(); + orphaned.sort(); + assert_eq!(orphaned, vec!["s1".to_string(), "s2".to_string()]); + let all = list(&conn).unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].name, "kept", "recently-used session survives the TTL"); + } + + #[test] + fn ttl_sweep_deletes_expired_and_returns_their_guids() { + let conn = mem(); + upsert(&conn, &row("stale", 100, &["s1", "s2"])).unwrap(); + upsert(&conn, &row("fresh", 500, &["f1"])).unwrap(); + + // Cutoff 300 → only "stale" (saved_at 100) is expired. + let mut orphaned = ttl_sweep(&conn, 300).unwrap(); + orphaned.sort(); + assert_eq!(orphaned, vec!["s1".to_string(), "s2".to_string()]); + + let all = list(&conn).unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].name, "fresh"); + } + + #[test] + fn ttl_sweep_noop_when_nothing_expired() { + let conn = mem(); + upsert(&conn, &row("fresh", 500, &["f1"])).unwrap(); + assert!(ttl_sweep(&conn, 100).unwrap().is_empty()); + assert_eq!(list(&conn).unwrap().len(), 1); + } + + #[test] + fn delete_removes_row_and_returns_its_guids() { + let conn = mem(); + upsert(&conn, &row_with_id("s-keep", "keep", 200, &["k1"])).unwrap(); + upsert(&conn, &row_with_id("s-drop", "drop", 100, &["d1", "d2"])).unwrap(); + + let mut guids = delete(&conn, "s-drop").unwrap(); + guids.sort(); + assert_eq!(guids, vec!["d1".to_string(), "d2".to_string()]); + + let all = list(&conn).unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].name, "keep"); + } + + #[test] + fn delete_missing_session_is_noop() { + let conn = mem(); + upsert(&conn, &row("keep", 200, &["k1"])).unwrap(); + assert!(delete(&conn, "nope").unwrap().is_empty()); + assert_eq!(list(&conn).unwrap().len(), 1); + } + + #[test] + fn ttl_sweep_if_due_runs_first_time_then_skips_within_a_day() { + let conn = mem(); + // saved_at 0 → far older than the 15-day TTL relative to `now`. + upsert(&conn, &row("stale", 0, &["s1"])).unwrap(); + let ttl = 15 * 24 * 60 * 60; + let now = 100 * 24 * 60 * 60; // day 100 + + // First run: never swept before → sweeps. + match ttl_sweep_if_due(&conn, now, ttl).unwrap() { + TtlSweepOutcome::Swept(g) => assert_eq!(g, vec!["s1".to_string()]), + other => panic!("expected Swept, got {other:?}"), + } + assert!(list(&conn).unwrap().is_empty()); + + // A second stale row + another call a few hours later: within 24h → skip. + upsert(&conn, &row("stale2", 0, &["s2"])).unwrap(); + let later = now + 6 * 60 * 60; // +6h + assert!(matches!( + ttl_sweep_if_due(&conn, later, ttl).unwrap(), + TtlSweepOutcome::Skipped + )); + assert_eq!(list(&conn).unwrap().len(), 1, "skip must not delete"); + } + + #[test] + fn ttl_sweep_if_due_runs_again_after_a_day() { + let conn = mem(); + upsert(&conn, &row("stale", 0, &["s1"])).unwrap(); + let ttl = 15 * 24 * 60 * 60; + let day0 = 100 * 24 * 60 * 60; + + assert!(matches!( + ttl_sweep_if_due(&conn, day0, ttl).unwrap(), + TtlSweepOutcome::Swept(_) + )); + + // >24h later a newly-expired row is swept on the next due run. + upsert(&conn, &row("stale2", 0, &["s2"])).unwrap(); + let day2 = day0 + 25 * 60 * 60; // +25h + match ttl_sweep_if_due(&conn, day2, ttl).unwrap() { + TtlSweepOutcome::Swept(g) => assert_eq!(g, vec!["s2".to_string()]), + other => panic!("expected Swept, got {other:?}"), + } + } + + #[test] + fn corrupt_buffer_guids_cell_yields_empty_not_panic() { + let conn = mem(); + conn.execute( + "INSERT INTO sessions (session_id, name, cwd, updated_at, last_used_at, layout_json, buffer_guids) + VALUES ('s', 'bad', '', 1, 1, '{}', 'not-json')", + [], + ) + .unwrap(); + let all = list(&conn).unwrap(); + assert_eq!(all.len(), 1); + assert!(all[0].buffer_guids.is_empty()); + } + + #[test] + fn upsert_round_trips_agent_pane_session_id() { + let conn = mem(); + let mut with_agent = row("with-agent", 100, &["g1"]); + with_agent.agent_pane_session_id = Some("pane-guid-1".to_string()); + upsert(&conn, &with_agent).unwrap(); + upsert(&conn, &row("no-agent", 50, &["g2"])).unwrap(); + + let all = list(&conn).unwrap(); + assert_eq!(all[0].name, "with-agent"); + assert_eq!( + all[0].agent_pane_session_id.as_deref(), + Some("pane-guid-1") + ); + assert_eq!(all[1].name, "no-agent"); + assert_eq!(all[1].agent_pane_session_id, None); + } + + #[test] + fn migrates_legacy_name_pk_table_to_session_id() { + // A DB created before the session_id refactor: `name` PRIMARY KEY, a + // single `saved_at`, no agent column. `init_schema` must rebuild it — + // deriving session_id from the first buffer guid and seeding both + // timestamps from `saved_at` — while preserving the row. + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE sessions ( + name TEXT PRIMARY KEY, + cwd TEXT NOT NULL DEFAULT '', + saved_at INTEGER NOT NULL DEFAULT 0, + layout_json TEXT NOT NULL, + buffer_guids TEXT NOT NULL DEFAULT '[]' + ); + CREATE TABLE meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL);", + ) + .unwrap(); + conn.execute( + "INSERT INTO sessions (name, cwd, saved_at, layout_json, buffer_guids) + VALUES ('legacy', 'C:\\x', 42, '{}', '[\"anchor-guid\"]')", + [], + ) + .unwrap(); + + // Run the full migration (idempotent — must survive a second call). + init_schema(&conn).unwrap(); + init_schema(&conn).unwrap(); + + let all = list(&conn).unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].session_id, "anchor-guid", "session_id from first buffer guid"); + assert_eq!(all[0].name, "legacy"); + assert_eq!(all[0].updated_at, 42); + assert_eq!(all[0].last_used_at, 42); + assert_eq!(all[0].agent_pane_session_id, None); + + // New upserts work on the migrated table. + let mut r = row("fresh", 200, &["g1"]); + r.agent_pane_session_id = Some("pane-2".to_string()); + upsert(&conn, &r).unwrap(); + assert_eq!(list(&conn).unwrap()[0].name, "fresh"); + } +} diff --git a/tools/wta/src/protocol/acp/client.rs b/tools/wta/src/protocol/acp/client.rs index b5bfeb71f9..0a9d095ecd 100644 --- a/tools/wta/src/protocol/acp/client.rs +++ b/tools/wta/src/protocol/acp/client.rs @@ -178,6 +178,23 @@ pub enum MasterExtRequest { /// returning the cached registry snapshot. rescan: bool, }, + /// Fetch the durable shell-session list for the `/shell-sessions` restore + /// picker. Served from master's SQLite store; the response carries each + /// row's `layout_json` so committing a pick needs no second round-trip. + ShellSessionsList { + request_id: u64, + }, + /// Clean-delete one durable shell session (DB row + its scrollback files), + /// by session_id. Fire-and-forget: the helper removes the row from its local + /// list optimistically, master does the authoritative delete. + ShellSessionsDelete { + session_id: String, + }, + /// Mark a durable shell session as just used (on restore) so master bumps + /// its `last_used_at` and the TTL doesn't reclaim it. Fire-and-forget. + ShellSessionsTouch { + session_id: String, + }, SessionResumeDispatched { request_id: u64, sid: acp::schema::v1::SessionId, @@ -2925,6 +2942,82 @@ fn dispatch_master_ext_request( } } } + MasterExtRequest::ShellSessionsList { request_id } => { + // Same 8s guardrail as `SessionsList` against the ACP-0.10 + // cancellation-safety bug (see that arm). The picker degrades + // to an empty list on timeout / error. + const SHELL_SESSIONS_LIST_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(8); + let wire = crate::session_registry::build_shell_sessions_list_request(); + match tokio::time::timeout(SHELL_SESSIONS_LIST_TIMEOUT, conn.ext_method(wire)).await + { + Ok(Ok(resp)) => { + let sessions = + crate::session_registry::parse_shell_sessions_list_response(&resp.0) + .map(|r| r.sessions) + .unwrap_or_default(); + let _ = event_tx.send(AppEvent::ShellSessionsLoaded { + request_id, + sessions, + }); + } + Ok(Err(err)) => { + tracing::warn!( + target: "shell_sessions", + request_id, + error = ?err, + "shell_sessions/list ext-request failed" + ); + let _ = event_tx.send(AppEvent::ShellSessionsLoaded { + request_id, + sessions: Vec::new(), + }); + } + Err(_elapsed) => { + tracing::warn!( + target: "shell_sessions", + request_id, + "shell_sessions/list timed out" + ); + let _ = event_tx.send(AppEvent::ShellSessionsLoaded { + request_id, + sessions: Vec::new(), + }); + } + } + } + MasterExtRequest::ShellSessionsDelete { session_id } => { + // Fire-and-forget clean delete: master removes the DB row and + // unlinks the scrollback files. The helper already removed the + // row from its local list optimistically, so we only log. + let wire = crate::session_registry::build_shell_sessions_delete_request(&session_id); + match conn.ext_method(wire).await { + Ok(_) => tracing::info!( + target: "shell_sessions", + %session_id, + "shell_sessions/delete acknowledged by master" + ), + Err(err) => tracing::warn!( + target: "shell_sessions", + %session_id, + error = ?err, + "shell_sessions/delete ext-request failed" + ), + } + } + MasterExtRequest::ShellSessionsTouch { session_id } => { + // Fire-and-forget: master bumps last_used_at so the TTL keeps a + // session the user just restored. + let wire = crate::session_registry::build_shell_sessions_touch_request(&session_id); + if let Err(err) = conn.ext_method(wire).await { + tracing::warn!( + target: "shell_sessions", + %session_id, + error = ?err, + "shell_sessions/touch ext-request failed" + ); + } + } MasterExtRequest::SessionResumeDispatched { request_id, sid } => { let wire = crate::session_registry::build_session_resume_dispatched_request(&sid); match conn.ext_method(wire).await { diff --git a/tools/wta/src/session_registry.rs b/tools/wta/src/session_registry.rs index dbb14d0043..25cb0ddb1b 100644 --- a/tools/wta/src/session_registry.rs +++ b/tools/wta/src/session_registry.rs @@ -228,6 +228,11 @@ pub const INTELLTERM_METHOD_SESSIONS_CHANGED: &str = "_intellterm.wta/sessions/c /// ExtRequest method for fetching the master's full session registry snapshot. pub const INTELLTERM_METHOD_SESSIONS_LIST: &str = "_intellterm.wta/sessions/list"; +/// ExtRequest method for fetching the durable shell-session list (the +/// `/shell-sessions` restore picker's data source). Served from master's +/// SQLite store; see `master/shell_sessions_db.rs`. +pub const INTELLTERM_METHOD_SHELL_SESSIONS_LIST: &str = "_intellterm.wta/shell_sessions/list"; + /// Wire payload for [`INTELLTERM_METHOD_SESSION_REMOVED`]. /// /// We only need the session id — helpers look the row up locally to @@ -321,6 +326,134 @@ pub fn parse_sessions_list_response( serde_json::from_str::(raw.get()) } +// ─── intellterm.wta/shell_sessions/list ────────────────────────────────────── + +/// Request params for the durable shell-session list. Empty today; a struct +/// (rather than `()`) so future filters (e.g. cwd prefix) are additive. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct ShellSessionsListParams {} + +/// One durable shell session as sent to the helper's `/shell-sessions` picker. +/// +/// Carries `layout_json` so pressing Enter can forward it straight to WT in the +/// `restore_shell_session` event — no second master round-trip. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct ShellSessionInfo { + /// Stable per-session key (anchor pane GUID). Used to delete/touch a + /// specific row — the title is no longer unique. + #[serde(default)] + pub session_id: String, + pub name: String, + #[serde(default)] + pub cwd: String, + /// Unix seconds of the last save (content change). The list is ordered + /// newest-first by this, and the picker shows it as the "last update" time. + #[serde(default)] + pub updated_at: i64, + /// Opaque WT `WindowLayout` JSON, replayed verbatim by the C++ side. + #[serde(default)] + pub layout_json: String, + /// The agent pane's WT session GUID (`pane_session_id`) at save time, if the + /// saved tab had an open agent pane. `None`/absent when it didn't. Resolved + /// to an ACP session id (via `agent_pane_origin`) when restoring, so the + /// agent conversation is resumed into the rebuilt tab. + #[serde(default)] + pub agent_pane_session_id: Option, +} + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct ShellSessionsListResponse { + pub sessions: Vec, +} + +/// Build an `ExtRequest` for `intellterm.wta/shell_sessions/list`. +pub fn build_shell_sessions_list_request() -> acp::schema::v1::ExtRequest { + let json = serde_json::to_string(&ShellSessionsListParams::default()) + .expect("ShellSessionsListParams is trivially serializable"); + let raw = serde_json::value::RawValue::from_string(json) + .expect("serde_json::to_string always produces valid JSON"); + acp::schema::v1::ExtRequest::new(INTELLTERM_METHOD_SHELL_SESSIONS_LIST, Arc::from(raw)) +} + +pub fn parse_shell_sessions_list_params( + raw: &serde_json::value::RawValue, +) -> Result { + serde_json::from_str::(raw.get()) +} + +pub fn build_shell_sessions_list_response( + sessions: Vec, +) -> Box { + let response = ShellSessionsListResponse { sessions }; + serde_json::value::to_raw_value(&response) + .expect("ShellSessionsListResponse serialization is infallible for owned data") +} + +pub fn parse_shell_sessions_list_response( + raw: &serde_json::value::RawValue, +) -> Result { + serde_json::from_str::(raw.get()) +} + +// ─── intellterm.wta/shell_sessions/delete ──────────────────────────────────── + +/// ExtRequest method for deleting one durable shell session (row + its +/// scrollback files). Served by master; see `handle_shell_sessions_delete`. +pub const INTELLTERM_METHOD_SHELL_SESSIONS_DELETE: &str = "_intellterm.wta/shell_sessions/delete"; + +/// Request params for deleting a durable shell session by its `session_id`. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct ShellSessionsDeleteParams { + pub session_id: String, +} + +/// Build an `ExtRequest` for `intellterm.wta/shell_sessions/delete`. +pub fn build_shell_sessions_delete_request(session_id: &str) -> acp::schema::v1::ExtRequest { + let json = serde_json::to_string(&ShellSessionsDeleteParams { + session_id: session_id.to_string(), + }) + .expect("ShellSessionsDeleteParams is trivially serializable"); + let raw = serde_json::value::RawValue::from_string(json) + .expect("serde_json::to_string always produces valid JSON"); + acp::schema::v1::ExtRequest::new(INTELLTERM_METHOD_SHELL_SESSIONS_DELETE, Arc::from(raw)) +} + +pub fn parse_shell_sessions_delete_params( + raw: &serde_json::value::RawValue, +) -> Result { + serde_json::from_str::(raw.get()) +} + +// ─── intellterm.wta/shell_sessions/touch ───────────────────────────────────── + +/// ExtRequest method for marking a durable shell session as just used (on +/// restore), so its `last_used_at` is bumped and the TTL doesn't reclaim a +/// session the user keeps reopening. Served by master. +pub const INTELLTERM_METHOD_SHELL_SESSIONS_TOUCH: &str = "_intellterm.wta/shell_sessions/touch"; + +/// Request params for touching a durable shell session by its `session_id`. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct ShellSessionsTouchParams { + pub session_id: String, +} + +/// Build an `ExtRequest` for `intellterm.wta/shell_sessions/touch`. +pub fn build_shell_sessions_touch_request(session_id: &str) -> acp::schema::v1::ExtRequest { + let json = serde_json::to_string(&ShellSessionsTouchParams { + session_id: session_id.to_string(), + }) + .expect("ShellSessionsTouchParams is trivially serializable"); + let raw = serde_json::value::RawValue::from_string(json) + .expect("serde_json::to_string always produces valid JSON"); + acp::schema::v1::ExtRequest::new(INTELLTERM_METHOD_SHELL_SESSIONS_TOUCH, Arc::from(raw)) +} + +pub fn parse_shell_sessions_touch_params( + raw: &serde_json::value::RawValue, +) -> Result { + serde_json::from_str::(raw.get()) +} + /// Parsed view of an inbound ACP `ExtNotification` from master, as /// recognized by the helper's live-set mirror. /// @@ -420,6 +553,15 @@ pub enum WtaExtRequest { FocusSession(FocusSessionParams), /// `_intellterm.wta/sessions/list` — full registry snapshot. SessionsList(SessionsListParams), + /// `_intellterm.wta/shell_sessions/list` — durable shell-session list for + /// the `/shell-sessions` restore picker (served from master's SQLite). + ShellSessionsList(ShellSessionsListParams), + /// `_intellterm.wta/shell_sessions/delete` — clean-delete one durable shell + /// session (row + its scrollback files). + ShellSessionsDelete(ShellSessionsDeleteParams), + /// `_intellterm.wta/shell_sessions/touch` — bump a session's `last_used_at` + /// on restore so the TTL doesn't reclaim a session the user keeps reopening. + ShellSessionsTouch(ShellSessionsTouchParams), /// `_intellterm.wta/session_hook` — a real per-session hook event. SessionHook(crate::agent_sessions::SessionEvent), /// `_intellterm.wta/session_born_bound` — a #266 binding-only registration @@ -466,6 +608,12 @@ pub fn parse_ext_request(req: acp::schema::v1::ExtRequest) -> WtaExtRequest { decode!(FocusSession, parse_focus_session_params) } else if ext_method_matches(&req.method, INTELLTERM_METHOD_SESSIONS_LIST) { decode!(SessionsList, parse_sessions_list_params) + } else if ext_method_matches(&req.method, INTELLTERM_METHOD_SHELL_SESSIONS_LIST) { + decode!(ShellSessionsList, parse_shell_sessions_list_params) + } else if ext_method_matches(&req.method, INTELLTERM_METHOD_SHELL_SESSIONS_DELETE) { + decode!(ShellSessionsDelete, parse_shell_sessions_delete_params) + } else if ext_method_matches(&req.method, INTELLTERM_METHOD_SHELL_SESSIONS_TOUCH) { + decode!(ShellSessionsTouch, parse_shell_sessions_touch_params) } else if ext_method_matches(&req.method, INTELLTERM_METHOD_SESSION_HOOK) { decode!(SessionHook, parse_session_hook_params) } else if ext_method_matches(&req.method, INTELLTERM_METHOD_SESSION_BORN_BOUND) { diff --git a/tools/wta/src/ui/agents_view.rs b/tools/wta/src/ui/agents_view.rs index a2749b8571..527e595772 100644 --- a/tools/wta/src/ui/agents_view.rs +++ b/tools/wta/src/ui/agents_view.rs @@ -509,7 +509,10 @@ fn origin_prefix_for(s: &AgentSession) -> Option { /// support, so we pick `_singular` for n=1 and `_other` for n≠1; locales /// with no singular/plural distinction can map both keys to the same /// template. -fn relative_age(t: SystemTime) -> String { +/// +/// `pub(crate)` so the `/shell-sessions` restore view renders its "last +/// update" column with the identical format. +pub(crate) fn relative_age(t: SystemTime) -> String { let now = SystemTime::now(); let secs = now.duration_since(t).map(|d| d.as_secs()).unwrap_or(0); if secs < 60 { diff --git a/tools/wta/src/ui/layout.rs b/tools/wta/src/ui/layout.rs index a08eda8317..0d0edaa966 100644 --- a/tools/wta/src/ui/layout.rs +++ b/tools/wta/src/ui/layout.rs @@ -3,7 +3,7 @@ use ratatui::prelude::*; use super::{ agent_popup, agents_view, auth, chat, command_popup, debug_panel, input, model_popup, - permission, recommendations, setup, + permission, recommendations, setup, shell_sessions_view, }; pub fn render(frame: &mut Frame, app: &mut App) { @@ -78,6 +78,14 @@ pub fn render(frame: &mut Frame, app: &mut App) { return; } + // Shell-session restore view (`/shell-sessions`) also takes over the full + // pane area; chat / input / disclaimer are not drawn. Same early-return + // shape as the agent-session view above. + if let Some(shell_sessions_state) = app.shell_sessions_view_state() { + shell_sessions_view::render(frame, shell_sessions_state, area); + return; + } + let (main_area, debug_area) = if app.show_debug_panel { let h = Layout::default() .direction(Direction::Horizontal) diff --git a/tools/wta/src/ui/mod.rs b/tools/wta/src/ui/mod.rs index f5960a879d..a928353405 100644 --- a/tools/wta/src/ui/mod.rs +++ b/tools/wta/src/ui/mod.rs @@ -12,6 +12,7 @@ mod model_popup; mod permission; mod popup; mod recommendations; +mod shell_sessions_view; pub mod setup; pub mod shimmer; @@ -19,4 +20,5 @@ pub use agent_popup::AgentPopupState; pub use command_popup::{PopupCandidates, PopupState}; pub use layout::render; pub use model_popup::ModelPopupState; +pub use shell_sessions_view::ShellSessionsViewState; pub use shimmer::CYCLE_FRAMES as ACTIVITY_CYCLE_FRAMES; diff --git a/tools/wta/src/ui/shell_sessions_view.rs b/tools/wta/src/ui/shell_sessions_view.rs new file mode 100644 index 0000000000..1dc16f5fd1 --- /dev/null +++ b/tools/wta/src/ui/shell_sessions_view.rs @@ -0,0 +1,152 @@ +//! `/shell-sessions` restore view. +//! +//! Full-pane list of the durable shell sessions Windows Terminal saved on tab +//! close (fetched from master's SQLite store via the `shell_sessions/list` ext +//! method — see `master/shell_sessions_db.rs`). Rendered full-pane like the +//! agent-session view (`agents_view.rs`), reached via +//! [`View::ShellSessions`](crate::app::View::ShellSessions), so chat / input / +//! the AI disclaimer are hidden while choosing a session. Enter restores the +//! highlighted tab (its layout, working directory, and scrollback); Esc +//! dismisses. Key handling lives in `App::handle_key`. + +use ratatui::prelude::*; +use ratatui::widgets::{List, ListItem, Paragraph}; +use std::time::{Duration, UNIX_EPOCH}; +use unicode_width::UnicodeWidthStr; + +use crate::session_registry::ShellSessionInfo; +use crate::theme; +use crate::ui::agents_view::relative_age; + +// Selected-row accent, matching the agent-session view (`agents_view.rs`): a +// cyan `>` caret + cyan title, rather than a filled highlight bar — so the two +// full-pane lists read identically. +const ACCENT_CYAN: Color = Color::Cyan; +// Muted color for the trailing "last update" timestamp, matching agents_view. +const MUTED_WHITE: Color = Color::Rgb(0x8b, 0x8b, 0x8b); + +/// Per-frame render state captured from the [`App`](crate::app::App). +pub struct ShellSessionsViewState<'a> { + /// Saved sessions, newest first (ordered by master's `list`). + pub sessions: &'a [ShellSessionInfo], + /// Highlighted row, an index into `sessions`. + pub selected: usize, + /// `Some(name)` while a delete confirmation is pending for the selected + /// row — the view renders a confirm prompt instead of the normal hint. + pub confirm_delete: Option<&'a str>, +} + +/// Render the restore view flush against the top of `area`, mirroring the +/// agent-session view's chrome-light layout: a title row, the list, then a +/// footer hint. Assumes a non-empty list (the app only enters this view when +/// sessions are present). +pub fn render(frame: &mut Frame, state: ShellSessionsViewState<'_>, area: Rect) { + // Indent two columns from the pane's left edge, matching agents_view. + let inner = Rect { + x: area.x + 2, + y: area.y, + width: area.width.saturating_sub(2), + height: area.height, + }; + + // Rows: [title][blank][list ...][blank][hint] + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // title + Constraint::Length(1), // spacer + Constraint::Min(1), // list + Constraint::Length(1), // spacer + Constraint::Length(1), // hint + ]) + .split(inner); + + // TODO(localize): extract these strings to i18n catalog keys. + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "Restore shell session", + theme::INPUT_TEXT.add_modifier(Modifier::BOLD), + ))), + chunks[0], + ); + + // Build each row manually (no `highlight_style`): selection is conveyed by + // a cyan `>` caret + cyan name, exactly like the agent-session view — not a + // filled highlight bar. A muted "last update" age is right-aligned at the + // row's trailing edge, matching agents_view. Rows are already newest-first + // (master's `list` orders by `updated_at DESC`). + let row_width = chunks[2].width as usize; + let items: Vec = state + .sessions + .iter() + .enumerate() + .map(|(i, entry)| { + let selected = i == state.selected; + // The row awaiting delete confirmation (always the selected one) is + // shown in red so the target is unmistakable. + let pending_delete = state.confirm_delete.is_some() && selected; + let caret = if selected { + let color = if pending_delete { Color::Red } else { ACCENT_CYAN }; + Span::styled("> ", Style::default().fg(color)) + } else { + Span::raw(" ") + }; + let name_style = if pending_delete { + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) + } else if selected { + Style::default().fg(ACCENT_CYAN) + } else { + theme::INPUT_TEXT + }; + + // "last update" age from the updated_at unix timestamp. + let age = if entry.updated_at > 0 { + relative_age(UNIX_EPOCH + Duration::from_secs(entry.updated_at as u64)) + } else { + String::new() + }; + + let name = entry.name.clone(); + let cwd_part = if entry.cwd.is_empty() { + String::new() + } else { + format!(" {}", entry.cwd) + }; + + // Right-align the age: pad between the left content and the age so + // the timestamp sits flush at the row's trailing edge. + let left_width = 2 + name.width() + cwd_part.width(); + let age_width = age.width(); + let pad = row_width + .saturating_sub(left_width) + .saturating_sub(age_width) + .max(1); + + let mut spans = vec![caret, Span::styled(name, name_style)]; + if !cwd_part.is_empty() { + spans.push(Span::styled(cwd_part, theme::DIM)); + } + if !age.is_empty() { + spans.push(Span::raw(" ".repeat(pad))); + spans.push(Span::styled(age, Style::default().fg(MUTED_WHITE))); + } + ListItem::new(Line::from(spans)) + }) + .collect(); + + frame.render_widget(List::new(items), chunks[2]); + + // Footer: normally the key hints; while a delete is pending, a confirm + // prompt (in a warning color) replaces them. + let hint = match state.confirm_delete { + Some(name) => Line::from(Span::styled( + format!("Delete shell session '{}'? Enter = confirm • Esc = cancel", name), + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )), + None => Line::from(Span::styled( + "↑ ↓ move • Enter restore • D delete • Esc cancel", + theme::DIM, + )), + }; + frame.render_widget(Paragraph::new(hint), chunks[4]); +}