From fc829aa38bafa760854d548ccf560c43e1d24dd7 Mon Sep 17 00:00:00 2001 From: DDKinger Date: Fri, 17 Jul 2026 20:41:33 +0800 Subject: [PATCH 01/14] feat(durable-sessions): save shell sessions on tab close, restore via /shell-sessions Step 1 of durable sessions. On tab close, snapshot the tab as a named, durable 'shell session' (layout + working directory + scrollback), keyed by the tab title. A new agent-pane slash command '/shell-sessions' lists the saved sessions and, on Enter, asks Windows Terminal to rebuild that tab. C++ (Windows Terminal): - ApplicationState: new ShellSessions map (Save/Remove/Get/All), persisted in state.json alongside PersistedWorkspaces but never auto-cleared. - On _HandleCloseTabRequested, capture the single-tab layout via BuildStartupActions(Persist) and persist each terminal pane's scrollback to shellsession_buffer_{guid}.txt (dedicated prefix so the window-close cleanup that sweeps buffer_*/elevated_* never deletes them). Write a lightweight shell-sessions.json sidecar (name/cwd/saved_at) for the TUI picker. - restore_shell_session protocol route + OnRestoreShellSessionRequested: replay the saved layout's startup actions and restore scrollback (threaded via _pendingShellSessionBufferIds so the restore path reads the durable buffer file). Agent panes are excluded (handled in a later step). - IntelligentTerminal::StateDir() mirrors wta's intelligent_terminal_root(). Rust (wta agent pane): - /shell-sessions slash command + per-tab modal picker (mirrors /model), reads the shell-sessions.json sidecar, Enter emits restore_shell_session. - New shell_sessions module + ui/shell_sessions_popup widget. Localization is intentionally deferred: the new UI strings are hardcoded English (marked with TODO(localize)) so this change touches no locale files; a final localization pass will extract them to i18n catalog keys. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 99c4f6fb-6ea5-4902-aa34-a68733db9393 --- src/cascadia/TerminalApp/TabManagement.cpp | 160 ++++++++++++++++++ src/cascadia/TerminalApp/TerminalPage.cpp | 93 +++++++++- src/cascadia/TerminalApp/TerminalPage.h | 17 +- src/cascadia/TerminalApp/TerminalPage.idl | 7 + .../TerminalProtocol/ProtocolParsing.h | 5 + .../ApplicationState.cpp | 72 ++++++++ .../TerminalSettingsModel/ApplicationState.h | 9 + .../ApplicationState.idl | 5 + .../TerminalProtocolComServer.cpp | 40 +++++ .../TerminalProtocolComServer.h | 1 + src/cascadia/inc/IntelligentTerminalPaths.h | 33 ++++ tools/wta/src/app.rs | 115 +++++++++++++ tools/wta/src/commands.rs | 27 +++ tools/wta/src/main.rs | 1 + tools/wta/src/shell_sessions.rs | 109 ++++++++++++ tools/wta/src/ui/layout.rs | 6 +- tools/wta/src/ui/mod.rs | 2 + tools/wta/src/ui/shell_sessions_popup.rs | 63 +++++++ 18 files changed, 762 insertions(+), 3 deletions(-) create mode 100644 tools/wta/src/shell_sessions.rs create mode 100644 tools/wta/src/ui/shell_sessions_popup.rs diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index d4d6c7d1e6..c97c28e9af 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -662,9 +662,169 @@ 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) + { + try + { + auto tabImpl = winrt::get_self(tab)->get_strong(); + + auto tabActions = tabImpl->BuildStartupActions(BuildStartupKind::Persist); + if (tabActions.empty()) + { + return; + } + + WindowLayout layout; + layout.TabLayout(winrt::single_threaded_vector(std::move(tabActions))); + + auto name = tab.Title(); + if (name.empty()) + { + name = L"Shell session"; + } + + ApplicationState::SharedInstance().SaveShellSession(name, layout); + _PersistShellSessionBuffers(tabImpl); + + // Record the current working directory for the picker's display. + winrt::hstring cwd; + if (const auto activeControl = tabImpl->GetActiveTerminalControl()) + { + cwd = activeControl.WorkingDirectory(); + } + _WriteShellSessionsIndexEntry(name, cwd); + } + CATCH_LOG(); + } + + // Method Description: + // - Persist each terminal pane's scrollback to a durable + // `shellsession_buffer_{guid}.txt` file next to `state.json`. The + // dedicated prefix keeps these files clear of the window-close cleanup in + // `WindowEmperor::_finalizeSessionPersistence`, which only sweeps + // `buffer_*` / `elevated_*`. The GUID matches `NewTerminalArgs.SessionId` + // in the saved layout, so restore reconnects each pane to its scrollback. + void TerminalPage::_PersistShellSessionBuffers(winrt::com_ptr tabImpl) + { + const auto rootPane = tabImpl->GetRootPane(); + if (!rootPane) + { + return; + } + + const std::filesystem::path settingsDirectory{ std::wstring_view{ CascadiaSettings::SettingsDirectory() } }; + + 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 filename = fmt::format(FMT_COMPILE(L"shellsession_buffer_{}.txt"), sessionId); + const auto path = settingsDirectory / filename; + 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: + // - Upsert one entry (by name) in the `shell-sessions.json` sidecar the + // agent-pane `/shell-sessions` TUI reads to render the picker. The + // authoritative layout lives in `state.json` (ApplicationState); this + // lightweight index just carries display metadata (name, cwd, saved_at) + // so the Rust side doesn't have to parse WT layouts. Written to the shared + // IntelligentTerminal LocalState dir (same root as agent-pane-sessions.jsonl). + void TerminalPage::_WriteShellSessionsIndexEntry(const winrt::hstring& name, const winrt::hstring& cwd) + { + const auto stateDir = ::IntelligentTerminal::StateDir(); + if (stateDir.empty()) + { + return; + } + + std::error_code ec; + std::filesystem::create_directories(stateDir, ec); + const auto indexPath = stateDir / L"shell-sessions.json"; + + Json::Value root{ Json::objectValue }; + if (const auto existing = til::io::read_file_as_utf8_string_if_exists(indexPath); !existing.empty()) + { + Json::CharReaderBuilder rb; + std::string errs; + std::istringstream is{ existing }; + if (!Json::parseFromStream(rb, is, &root, &errs) || !root.isObject()) + { + root = Json::Value{ Json::objectValue }; + } + } + + const auto nameUtf8 = winrt::to_string(name); + const auto savedAt = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + + // Rebuild the array, dropping any prior entry with the same name so the + // freshest snapshot wins (tab names are the session key). + Json::Value updated{ Json::arrayValue }; + if (root.isMember("sessions") && root["sessions"].isArray()) + { + for (const auto& entry : root["sessions"]) + { + if (entry.isObject() && entry.get("name", "").asString() != nameUtf8) + { + updated.append(entry); + } + } + } + + Json::Value entry{ Json::objectValue }; + entry["name"] = nameUtf8; + entry["cwd"] = winrt::to_string(cwd); + entry["saved_at"] = static_cast(savedAt); + updated.append(std::move(entry)); + root["sessions"] = std::move(updated); + + Json::StreamWriterBuilder wb; + wb["indentation"] = " "; + til::io::write_utf8_string_to_file_atomic(indexPath, Json::writeString(wb, root)); + } + // 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..4183a889ef 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -5189,6 +5189,82 @@ namespace winrt::TerminalApp::implementation _RequestAgentStateForTab(newTab, std::nullopt, /*pane_open*/ true); } + // Inbound event from WTA: {method:"restore_shell_session", params:{name}}. + // Sent by the agent-pane `/shell-sessions` picker's Enter handler. We look + // up the durable layout saved under `name` (on tab close, see + // `_SaveShellSessionForTab`) and replay its startup actions to rebuild the + // tab — working directory and pane splits. Each pane's scrollback is + // reconnected from its `shellsession_buffer_{guid}.txt` file: we pre-mark + // the layout's session GUIDs in `_pendingShellSessionBufferIds` so the + // buffer-restore path (in `_MakePane`) reads the durable file. + void TerminalPage::OnRestoreShellSessionRequested(hstring eventJson) + { + _agentPaneLog("OnRestoreShellSessionRequested: received from wta"); + + 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 nameStr = evt["params"].get("name", "").asString(); + if (nameStr.empty()) + { + _agentPaneLog("OnRestoreShellSessionRequested: empty name — ignoring"); + return; + } + + const auto layout = ApplicationState::SharedInstance().GetShellSession(winrt::to_hstring(nameStr)); + if (!layout) + { + _agentPaneLog("OnRestoreShellSessionRequested: no saved session named '" + 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 `shellsession_buffer_` 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 + "'"); + ProcessStartupActions(std::move(actions)); + } + // 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. @@ -7763,7 +7839,22 @@ namespace winrt::TerminalApp::implementation const auto settingsDir = CascadiaSettings::SettingsDirectory(); const auto admin = IsRunningElevated(); - const auto filenamePrefix = admin ? L"elevated_"sv : L"buffer_"sv; + + // A durable shell-session restore reads scrollback from the + // dedicated `shellsession_buffer_` file (kept clear of the + // window-close cleanup that sweeps `buffer_*` / `elevated_*`). + // Each GUID is consumed exactly once. Everything else — normal + // window relaunch — uses the transient per-window buffer file. + std::wstring_view filenamePrefix; + if (const auto it = _pendingShellSessionBufferIds.find(sessionId); it != _pendingShellSessionBufferIds.end()) + { + _pendingShellSessionBufferIds.erase(it); + filenamePrefix = L"shellsession_buffer_"sv; + } + else + { + filenamePrefix = admin ? L"elevated_"sv : L"buffer_"sv; + } const auto path = fmt::format(FMT_COMPILE(L"{}\\{}{}.txt"), settingsDir, filenamePrefix, sessionId); control.RestoreFromPath(path); } diff --git a/src/cascadia/TerminalApp/TerminalPage.h b/src/cascadia/TerminalApp/TerminalPage.h index d3b93f0209..766cfd33d4 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 `shellsession_buffer_` + // 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,12 @@ 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. + void _SaveShellSessionForTab(const winrt::TerminalApp::Tab& tab); + void _PersistShellSessionBuffers(winrt::com_ptr tabImpl); + void _WriteShellSessionsIndexEntry(const winrt::hstring& name, const winrt::hstring& cwd); + 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/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/TerminalSettingsModel/ApplicationState.cpp b/src/cascadia/TerminalSettingsModel/ApplicationState.cpp index 45e3e2fde5..77e8fe2bbd 100644 --- a/src/cascadia/TerminalSettingsModel/ApplicationState.cpp +++ b/src/cascadia/TerminalSettingsModel/ApplicationState.cpp @@ -460,6 +460,78 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation return nullptr; } + // Method Description: + // - Save (or overwrite) a durable shell session under `name`. Unlike + // workspaces, shell sessions persist after being restored, so the user + // can reopen the same saved tab more than once. + void ApplicationState::SaveShellSession(const hstring& name, const Model::WindowLayout& layout) + { + { + const auto state = _state.lock(); + if (!state->ShellSessions || !*state->ShellSessions) + { + state->ShellSessions = winrt::single_threaded_map(); + } + (*state->ShellSessions).Insert(name, layout); + } + _throttler(); + } + + // Method Description: + // - Remove the durable shell session stored under `name`. + // Return Value: + // - true if an entry was removed, false otherwise. + bool ApplicationState::RemoveShellSession(const hstring& name) + { + bool removed{ false }; + { + const auto state = _state.lock(); + if (state->ShellSessions && *state->ShellSessions) + { + auto map = *state->ShellSessions; + if (map.HasKey(name)) + { + map.Remove(name); + removed = true; + } + } + } + if (removed) + { + _throttler(); + } + return removed; + } + + // Method Description: + // - Look up a durable shell session by `name` WITHOUT removing it (the + // restore path is non-destructive so a session can be reopened again). + // Return Value: + // - The stored layout, or nullptr if there was none. + Model::WindowLayout ApplicationState::GetShellSession(const hstring& name) + { + const auto state = _state.lock_shared(); + if (state->ShellSessions && *state->ShellSessions) + { + auto map = *state->ShellSessions; + if (map.HasKey(name)) + { + return map.Lookup(name); + } + } + return nullptr; + } + + Windows::Foundation::Collections::IMapView ApplicationState::AllShellSessions() + { + const auto state = _state.lock_shared(); + if (state->ShellSessions && *state->ShellSessions) + { + return (*state->ShellSessions).GetView(); + } + return nullptr; + } + // Generate all getter/setters #define MTSM_APPLICATION_STATE_GEN(source, type, name, key, ...) \ type ApplicationState::name() const noexcept \ diff --git a/src/cascadia/TerminalSettingsModel/ApplicationState.h b/src/cascadia/TerminalSettingsModel/ApplicationState.h index 73209a799a..70b3fc88d0 100644 --- a/src/cascadia/TerminalSettingsModel/ApplicationState.h +++ b/src/cascadia/TerminalSettingsModel/ApplicationState.h @@ -44,6 +44,7 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation X(FileSource::Local, Windows::Foundation::Collections::IVector, AllowedCommandlines, "allowedCommandlines") \ X(FileSource::Local, std::unordered_set, DismissedBadges, "dismissedBadges") \ X(FileSource::Local, Windows::Foundation::Collections::IMap, PersistedWorkspaces, "persistedWorkspaces") \ + X(FileSource::Local, Windows::Foundation::Collections::IMap, ShellSessions, "shellSessions") \ X(FileSource::Shared, bool, SSHFolderGenerated, "sshFolderGenerated", false) \ X(FileSource::Shared, bool, AgentFreCompleted, "agentFreCompleted", false) \ X(FileSource::Shared, bool, AgentWelcomeShown, "agentWelcomeShown", false) @@ -87,6 +88,14 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation Model::WindowLayout TakeWorkspace(const hstring& name); Windows::Foundation::Collections::IMapView AllPersistedWorkspaces(); + // Durable shell sessions. Unlike workspaces/window-layouts, these are + // named, user-restorable-on-demand snapshots that are NOT cleared after + // restore, so a tab can be reopened repeatedly. Keyed by tab name. + void SaveShellSession(const hstring& name, const Model::WindowLayout& layout); + bool RemoveShellSession(const hstring& name); + Model::WindowLayout GetShellSession(const hstring& name); + Windows::Foundation::Collections::IMapView AllShellSessions(); + // State getters/setters #define MTSM_APPLICATION_STATE_GEN(source, type, name, key, ...) \ type name() const noexcept; \ diff --git a/src/cascadia/TerminalSettingsModel/ApplicationState.idl b/src/cascadia/TerminalSettingsModel/ApplicationState.idl index f35711fab8..04cc8b81b4 100644 --- a/src/cascadia/TerminalSettingsModel/ApplicationState.idl +++ b/src/cascadia/TerminalSettingsModel/ApplicationState.idl @@ -42,6 +42,11 @@ namespace Microsoft.Terminal.Settings.Model WindowLayout TakeWorkspace(String name); Windows.Foundation.Collections.IMapView AllPersistedWorkspaces(); + void SaveShellSession(String name, WindowLayout layout); + Boolean RemoveShellSession(String name); + WindowLayout GetShellSession(String name); + Windows.Foundation.Collections.IMapView AllShellSessions(); + String SettingsHash; Windows.Foundation.Collections.IVector PersistedWindowLayouts; Windows.Foundation.Collections.IVector RecentCommands; 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/inc/IntelligentTerminalPaths.h b/src/cascadia/inc/IntelligentTerminalPaths.h index 9c8763a90f..240ef37adc 100644 --- a/src/cascadia/inc/IntelligentTerminalPaths.h +++ b/src/cascadia/inc/IntelligentTerminalPaths.h @@ -58,6 +58,39 @@ 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-sessions sidecar index (`shell-sessions.json`) the agent-pane + // TUI reads. 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/src/app.rs b/tools/wta/src/app.rs index 3ac3cbbbd9..25d17c1acf 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -1543,6 +1543,14 @@ pub struct TabSession { /// Highlighted row in `App::available_agents`. pub agent_picker_selected: usize, + /// True while the `/shell-sessions` restore picker is open for this tab. + pub shell_sessions_picker_open: bool, + /// Highlighted row in `shell_sessions` below. + pub shell_sessions_picker_selected: usize, + /// Snapshot of the durable shell sessions, loaded from the C++-written + /// `shell-sessions.json` sidecar when the picker opens. Empty otherwise. + pub shell_sessions: Vec, + // 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 +1606,7 @@ impl TabSession { && !self.paste_pending && !self.model_picker_open && !self.agent_picker_open + && !self.shell_sessions_picker_open } pub fn clear_recommendations(&mut self) { @@ -2993,6 +3002,84 @@ impl App { self.publish_agent_status(); } + // ── /shell-sessions picker ────────────────────────────────────────── + + /// True while the shell-session restore picker is up for the active tab. + fn shell_sessions_picker_visible(&self) -> bool { + self.current_tab().shell_sessions_picker_open + } + + /// `/shell-sessions` — open a picker of durable shell sessions Windows + /// Terminal saved on tab close. Enter restores the highlighted tab (its + /// layout, working directory, and scrollback) via a protocol event. + fn cmd_shell_sessions(&mut self) { + let sessions = crate::shell_sessions::load(); + if sessions.is_empty() { + let tab = self.current_tab_mut(); + 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.current_tab_mut(); + tab.shell_sessions = sessions; + tab.shell_sessions_picker_selected = 0; + tab.shell_sessions_picker_open = true; + } + + fn close_shell_sessions_picker(&mut self) { + let tab = self.current_tab_mut(); + tab.shell_sessions_picker_open = false; + tab.shell_sessions.clear(); + } + + 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 by name via a one-way `restore_shell_session` protocol event. WT + /// owns the authoritative layout + scrollback, so the helper just names + /// the session and lets the C++ side rebuild the tab. + fn commit_shell_session_pick(&mut self) { + let name = { + let tab = self.current_tab(); + let idx = tab.shell_sessions_picker_selected; + tab.shell_sessions.get(idx).map(|entry| entry.name.clone()) + }; + self.close_shell_sessions_picker(); + let Some(name) = name else { + return; + }; + let evt = serde_json::json!({ + "type": "event", + "method": "restore_shell_session", + "params": { "name": name }, + }); + send_wt_protocol_event(evt.to_string()); + tracing::info!(target: "shell_sessions", %name, "restore_shell_session event published"); + 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 @@ -7097,6 +7184,20 @@ impl App { return; } + // Shell-session restore picker (`/shell-sessions`): same modal shape as + // the model picker — arrows move, Enter restores the highlighted tab, + // Esc dismisses. Swallow everything else. + if self.shell_sessions_picker_visible() { + 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::Esc => self.close_shell_sessions_picker(), + _ => {} + } + return; + } + if self.current_tab().paste_pending { tracing::debug!(target: "agent_paste", "ignoring key while paste is pending"); return; @@ -7771,6 +7872,19 @@ impl App { }) } + /// Per-frame state for the `/shell-sessions` picker modal, or `None` when + /// it's not open on the active tab. + pub fn shell_sessions_popup_state(&self) -> Option> { + let tab = self.current_tab(); + if !tab.shell_sessions_picker_open || tab.shell_sessions.is_empty() { + return None; + } + Some(crate::ui::ShellSessionsPopupState { + sessions: &tab.shell_sessions, + selected: tab.shell_sessions_picker_selected, + }) + } + /// 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 +8024,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(), } } 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/main.rs b/tools/wta/src/main.rs index 08ec505619..538b8be951 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -31,6 +31,7 @@ mod session_mgmt; mod session_registry; mod session_watcher; mod shell; +mod shell_sessions; mod telemetry; #[cfg(test)] mod test_support; diff --git a/tools/wta/src/shell_sessions.rs b/tools/wta/src/shell_sessions.rs new file mode 100644 index 0000000000..e2df5fff84 --- /dev/null +++ b/tools/wta/src/shell_sessions.rs @@ -0,0 +1,109 @@ +//! Durable shell-session index reader for the `/shell-sessions` picker. +//! +//! Windows Terminal (C++) writes `shell-sessions.json` into the shared +//! IntelligentTerminal `LocalState` directory whenever a tab is closed (see +//! `TerminalPage::_WriteShellSessionsIndexEntry`). This module reads that +//! lightweight sidecar so the agent-pane TUI can render the picker without +//! having to parse WT's `state.json` layout format. +//! +//! The authoritative layout + scrollback live on the C++ side; restoring is a +//! one-way `restore_shell_session` protocol event keyed by the session name. + +use serde::Deserialize; + +use crate::runtime_paths::intelligent_terminal_root; + +/// One saved shell session, as written by the C++ side. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ShellSessionEntry { + /// The closed tab's title — this is both the display label and the key the + /// C++ side looks up in `ApplicationState::GetShellSession`. + pub name: String, + /// Working directory captured at save time, for display only. May be empty + /// when the shell never reported one (no OSC 9;9 shell integration). + #[serde(default)] + pub cwd: String, + /// Unix seconds when the session was saved. Used to sort newest-first. + #[serde(default)] + pub saved_at: i64, +} + +/// On-disk shape of `shell-sessions.json`. +#[derive(Debug, Clone, Deserialize, Default)] +struct ShellSessionsIndex { + #[serde(default)] + sessions: Vec, +} + +/// The index file's name inside `intelligent_terminal_root()`. +const INDEX_FILE_NAME: &str = "shell-sessions.json"; + +/// Read the saved shell sessions, most-recently-saved first. +/// +/// Returns an empty vector when the index file is missing or unreadable — an +/// absent file just means the user hasn't closed any tabs yet, so callers +/// surface a "no saved sessions" message rather than an error. +pub fn load() -> Vec { + let Some(path) = intelligent_terminal_root().map(|root| root.join(INDEX_FILE_NAME)) else { + return Vec::new(); + }; + + let contents = match std::fs::read_to_string(&path) { + Ok(contents) => contents, + Err(err) => { + // `NotFound` is the expected "nothing saved yet" case; only louder + // errors (permissions, etc.) are worth a log line. + if err.kind() != std::io::ErrorKind::NotFound { + tracing::warn!(target: "shell_sessions", %err, "failed to read shell-sessions.json"); + } + return Vec::new(); + } + }; + + let mut index: ShellSessionsIndex = match serde_json::from_str(&contents) { + Ok(index) => index, + Err(err) => { + tracing::warn!(target: "shell_sessions", %err, "failed to parse shell-sessions.json"); + return Vec::new(); + } + }; + + // Newest first so the picker's top row is the most recently closed tab. + index.sessions.sort_by(|a, b| b.saved_at.cmp(&a.saved_at)); + index.sessions +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_and_sorts_newest_first() { + let json = r#"{ + "sessions": [ + { "name": "old", "cwd": "C:\\a", "saved_at": 100 }, + { "name": "new", "cwd": "C:\\b", "saved_at": 200 } + ] + }"#; + let mut index: ShellSessionsIndex = serde_json::from_str(json).unwrap(); + index.sessions.sort_by(|a, b| b.saved_at.cmp(&a.saved_at)); + assert_eq!(index.sessions[0].name, "new"); + assert_eq!(index.sessions[1].name, "old"); + assert_eq!(index.sessions[0].cwd, "C:\\b"); + } + + #[test] + fn missing_optional_fields_default() { + let json = r#"{ "sessions": [ { "name": "only-name" } ] }"#; + let index: ShellSessionsIndex = serde_json::from_str(json).unwrap(); + assert_eq!(index.sessions[0].name, "only-name"); + assert_eq!(index.sessions[0].cwd, ""); + assert_eq!(index.sessions[0].saved_at, 0); + } + + #[test] + fn empty_or_missing_sessions_is_empty() { + let index: ShellSessionsIndex = serde_json::from_str("{}").unwrap(); + assert!(index.sessions.is_empty()); + } +} diff --git a/tools/wta/src/ui/layout.rs b/tools/wta/src/ui/layout.rs index a08eda8317..45b4fd0f2b 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_popup, }; pub fn render(frame: &mut Frame, app: &mut App) { @@ -233,6 +233,10 @@ pub fn render(frame: &mut Frame, app: &mut App) { agent_popup::render_popup(frame, agent_state, chunks[6]); } + if let Some(shell_sessions_state) = app.shell_sessions_popup_state() { + shell_sessions_popup::render_popup(frame, shell_sessions_state, chunks[6]); + } + // `/help` overlay sits on top of everything so the user can always // dismiss it with Esc. command_popup::render_help_overlay(frame, app, area); diff --git a/tools/wta/src/ui/mod.rs b/tools/wta/src/ui/mod.rs index f5960a879d..662b4aabf7 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_popup; 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_popup::ShellSessionsPopupState; pub use shimmer::CYCLE_FRAMES as ACTIVITY_CYCLE_FRAMES; diff --git a/tools/wta/src/ui/shell_sessions_popup.rs b/tools/wta/src/ui/shell_sessions_popup.rs new file mode 100644 index 0000000000..37476d71ed --- /dev/null +++ b/tools/wta/src/ui/shell_sessions_popup.rs @@ -0,0 +1,63 @@ +//! `/shell-sessions` restore picker modal. +//! +//! Opened by the `/shell-sessions` slash command (`App::cmd_shell_sessions`), +//! this overlay lists the durable shell sessions Windows Terminal saved on tab +//! close (read from the `shell-sessions.json` sidecar — see +//! `crate::shell_sessions`). Enter asks WT to restore the highlighted tab (its +//! layout, working directory, and scrollback). Modeled on the `/model` picker +//! (`model_popup.rs`): anchored above the input box, arrow keys move the +//! highlight, Enter commits, Esc dismisses (all handled in `App::handle_key`). + +use ratatui::prelude::*; +use ratatui::widgets::{Clear, List, ListItem, ListState}; + +use super::popup; +use crate::shell_sessions::ShellSessionEntry; +use crate::theme; + +const POPUP_MAX_VISIBLE: usize = 8; + +/// Per-frame state captured from the [`App`](crate::app::App). +pub struct ShellSessionsPopupState<'a> { + /// Saved sessions, newest first (sorted in `shell_sessions::load`). + pub sessions: &'a [ShellSessionEntry], + /// Highlighted row, an index into `sessions`. + pub selected: usize, +} + +/// Render the shell-session picker just above `input_area`, falling back to +/// below when there isn't room. No-op on an empty list. +pub fn render_popup(frame: &mut Frame, state: ShellSessionsPopupState<'_>, input_area: Rect) { + if state.sessions.is_empty() { + return; + } + + let visible = state.sessions.len().min(POPUP_MAX_VISIBLE) as u16; + let area = popup::anchored_above(frame, input_area, visible); + + frame.render_widget(Clear, area); + + let items: Vec = state + .sessions + .iter() + .map(|entry| { + let mut spans = vec![Span::styled(format!(" {}", entry.name), theme::INPUT_TEXT)]; + // Show the working directory dimmed after the name, when known. + if !entry.cwd.is_empty() { + spans.push(Span::styled(format!(" {}", entry.cwd), theme::DIM)); + } + ListItem::new(Line::from(spans)) + }) + .collect(); + + let list = List::new(items) + // TODO(localize): extract to a `shell_sessions_picker.title` catalog key. + .block(popup::block("Restore shell session (↑ ↓ • Enter • Esc)".to_string())) + .highlight_style(theme::SELECTED) + .highlight_symbol("> "); + + let mut list_state = ListState::default(); + list_state.select(Some(state.selected.min(state.sessions.len() - 1))); + + frame.render_stateful_widget(list, area, &mut list_state); +} From 71cacb1661d138433f821737b4a7fc059578cc15 Mon Sep 17 00:00:00 2001 From: DDKinger Date: Sat, 18 Jul 2026 11:35:06 +0800 Subject: [PATCH 02/14] feat(durable-sessions): add Durable session settings block to Startup Add a "Durable session" group to Settings > Startup (per the design mock) with an expandable "Restore sessions" card holding three checkboxes, all on by default: - Restore shell sessions when terminal relaunches - Restore agent session when terminal relaunches - Continue running commands when terminal relaunches These decide what a tab saves on close and what is restored the next time a durable session is opened. - MTSMSettings.h / GlobalAppSettings.idl: three new global bool settings (durableSession.restoreShellSessions / restoreAgentSession / keepRunningCommands), auto-serialized via the X-macro, default true. - LaunchViewModel(.idl/.h): projected observable properties. - Launch.xaml + Resources.resw (en-US): the group header, expander and three checkboxes. - Gate step 1 on the first setting: _SaveShellSessionForTab (save on tab close) and OnRestoreShellSessionRequested (restore) early-return when DurableRestoreShellSessions is off. The remaining two settings are wired for later steps (agent-session restore, keep-running). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 73a56ccd-2121-4e3d-b5f4-a1f5c6f2d373 --- src/cascadia/TerminalApp/TabManagement.cpp | 8 +++++++ src/cascadia/TerminalApp/TerminalPage.cpp | 9 +++++++ .../TerminalSettingsEditor/Launch.xaml | 20 ++++++++++++++++ .../TerminalSettingsEditor/LaunchViewModel.h | 3 +++ .../LaunchViewModel.idl | 3 +++ .../Resources/en-US/Resources.resw | 24 +++++++++++++++++++ .../GlobalAppSettings.idl | 7 ++++++ .../TerminalSettingsModel/MTSMSettings.h | 3 +++ 8 files changed, 77 insertions(+) diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index c97c28e9af..5eeca02d37 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -681,6 +681,14 @@ namespace winrt::TerminalApp::implementation // 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(); diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 4183a889ef..5c76acce0f 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -5201,6 +5201,15 @@ namespace winrt::TerminalApp::implementation { _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; 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) \ From a0fcd05abc00dfee08bc7f10b0fe027820a7388d Mon Sep 17 00:00:00 2001 From: DDKinger Date: Sun, 19 Jul 2026 12:29:15 +0800 Subject: [PATCH 03/14] refactor(durable-sessions): move shell-session store into master-owned SQLite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the state.json ShellSessions map + shell-sessions.json sidecar with a single SQLite store owned exclusively by wta-master. C++ and helpers never touch the DB; all changes flow over existing channels: - SAVE (tab close): C++ raises a save_shell_session WT event; master's WT-event subscriber upserts the row. - LIST (/shell-sessions): helper sends the intellterm.wta/shell_sessions/list ext method; master answers from SQLite (row carries layout_json so Enter needs no second round-trip). - RESTORE (Enter): helper forwards layout_json in estore_shell_session; C++ deserializes and replays it. Why: state.json grew unbounded (a row per tab close, vs user-driven workspaces), and the JSON sidecar duplicated data that could drift. A single master-owned DB removes both problems without cross-process / cross-language locking or the elevated/non-elevated split. Storage split: SQLite holds { name, cwd, saved_at, layout_json, buffer_guids }. Scrollback stays as files (terminal core writes via a file handle; too large to ship through JSON-RPC), moved to \shell-session-buffers\{guid}.txt so master can reference and TTL-clean them alongside their row. TTL: expired sessions (15-day retention) and their scrollback files are swept at master startup, but at most once per day — a meta.last_ttl_sweep timestamp in the DB gates it, so repeated WT relaunches don't re-scan. Rust: - Cargo.toml: add rusqlite (bundled). NOTE: run build/scripts/Generate-WtaThirdPartyNotices.ps1 to regen cgmanifest.json + NOTICE.md for the new dep (follow-up commit). - master/shell_sessions_db.rs: open/migrate, upsert, list, ttl_sweep, ttl_sweep_if_due (7 unit tests). - session_registry: shell_sessions/list ext method + WtaExtRequest variant. - master/mod.rs: save-event handler, ext list handler, once-a-day TTL sweep. - app.rs / client.rs: picker fetches via ShellSessionsList (async round-trip), Enter forwards layout_json. - remove the shell_sessions.rs sidecar reader. C++: - TabManagement.cpp: save via event; buffers -> StateDir subdir; drop the state.json write + sidecar writer. - TerminalPage.cpp: restore reads layout_json from the event; buffer restore path -> StateDir subdir. - ApplicationState .h/.idl/.cpp: remove the ShellSessions map + 4 methods. Rust: cargo test all green (1128 + 7 db tests). C++: compiles clean (the only build break is the pre-existing v143/v145 CLI11 link mismatch, unrelated). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 73a56ccd-2121-4e3d-b5f4-a1f5c6f2d373 --- src/cascadia/TerminalApp/TabManagement.cpp | 133 +++--- src/cascadia/TerminalApp/TerminalPage.cpp | 65 +-- src/cascadia/TerminalApp/TerminalPage.h | 15 +- .../ApplicationState.cpp | 72 ---- .../TerminalSettingsModel/ApplicationState.h | 9 - .../ApplicationState.idl | 5 - src/cascadia/inc/IntelligentTerminalPaths.h | 5 +- tools/wta/Cargo.lock | 100 +++++ tools/wta/Cargo.toml | 5 + tools/wta/src/app.rs | 85 +++- tools/wta/src/main.rs | 1 - tools/wta/src/master/mod.rs | 217 ++++++++++ tools/wta/src/master/shell_sessions_db.rs | 384 ++++++++++++++++++ tools/wta/src/protocol/acp/client.rs | 50 +++ tools/wta/src/session_registry.rs | 67 +++ tools/wta/src/shell_sessions.rs | 109 ----- tools/wta/src/ui/shell_sessions_popup.rs | 17 +- 17 files changed, 1012 insertions(+), 327 deletions(-) create mode 100644 tools/wta/src/master/shell_sessions_db.rs delete mode 100644 tools/wta/src/shell_sessions.rs diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index 5eeca02d37..953f296b2f 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -708,36 +708,78 @@ namespace winrt::TerminalApp::implementation name = L"Shell session"; } - ApplicationState::SharedInstance().SaveShellSession(name, layout); - _PersistShellSessionBuffers(tabImpl); + // 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); - // Record the current working directory for the picker's display. winrt::hstring cwd; if (const auto activeControl = tabImpl->GetActiveTerminalControl()) { cwd = activeControl.WorkingDirectory(); } - _WriteShellSessionsIndexEntry(name, cwd); + + // 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["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); + 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: // - Persist each terminal pane's scrollback to a durable - // `shellsession_buffer_{guid}.txt` file next to `state.json`. The - // dedicated prefix keeps these files clear of the window-close cleanup in - // `WindowEmperor::_finalizeSessionPersistence`, which only sweeps - // `buffer_*` / `elevated_*`. The GUID matches `NewTerminalArgs.SessionId` - // in the saved layout, so restore reconnects each pane to its scrollback. - void TerminalPage::_PersistShellSessionBuffers(winrt::com_ptr tabImpl) + // `{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; + return guids; } - const std::filesystem::path settingsDirectory{ std::wstring_view{ CascadiaSettings::SettingsDirectory() } }; + 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 @@ -762,75 +804,16 @@ namespace winrt::TerminalApp::implementation return; } - const auto filename = fmt::format(FMT_COMPILE(L"shellsession_buffer_{}.txt"), sessionId); - const auto path = settingsDirectory / filename; + 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); } }); - } - - // Method Description: - // - Upsert one entry (by name) in the `shell-sessions.json` sidecar the - // agent-pane `/shell-sessions` TUI reads to render the picker. The - // authoritative layout lives in `state.json` (ApplicationState); this - // lightweight index just carries display metadata (name, cwd, saved_at) - // so the Rust side doesn't have to parse WT layouts. Written to the shared - // IntelligentTerminal LocalState dir (same root as agent-pane-sessions.jsonl). - void TerminalPage::_WriteShellSessionsIndexEntry(const winrt::hstring& name, const winrt::hstring& cwd) - { - const auto stateDir = ::IntelligentTerminal::StateDir(); - if (stateDir.empty()) - { - return; - } - - std::error_code ec; - std::filesystem::create_directories(stateDir, ec); - const auto indexPath = stateDir / L"shell-sessions.json"; - - Json::Value root{ Json::objectValue }; - if (const auto existing = til::io::read_file_as_utf8_string_if_exists(indexPath); !existing.empty()) - { - Json::CharReaderBuilder rb; - std::string errs; - std::istringstream is{ existing }; - if (!Json::parseFromStream(rb, is, &root, &errs) || !root.isObject()) - { - root = Json::Value{ Json::objectValue }; - } - } - - const auto nameUtf8 = winrt::to_string(name); - const auto savedAt = std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()) - .count(); - - // Rebuild the array, dropping any prior entry with the same name so the - // freshest snapshot wins (tab names are the session key). - Json::Value updated{ Json::arrayValue }; - if (root.isMember("sessions") && root["sessions"].isArray()) - { - for (const auto& entry : root["sessions"]) - { - if (entry.isObject() && entry.get("name", "").asString() != nameUtf8) - { - updated.append(entry); - } - } - } - - Json::Value entry{ Json::objectValue }; - entry["name"] = nameUtf8; - entry["cwd"] = winrt::to_string(cwd); - entry["saved_at"] = static_cast(savedAt); - updated.append(std::move(entry)); - root["sessions"] = std::move(updated); - Json::StreamWriterBuilder wb; - wb["indentation"] = " "; - til::io::write_utf8_string_to_file_atomic(indexPath, Json::writeString(wb, root)); + return guids; } // Removes the tab (both TerminalControl and XAML). diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 5c76acce0f..75db857d9b 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -5189,13 +5189,14 @@ namespace winrt::TerminalApp::implementation _RequestAgentStateForTab(newTab, std::nullopt, /*pane_open*/ true); } - // Inbound event from WTA: {method:"restore_shell_session", params:{name}}. - // Sent by the agent-pane `/shell-sessions` picker's Enter handler. We look - // up the durable layout saved under `name` (on tab close, see - // `_SaveShellSessionForTab`) and replay its startup actions to rebuild the - // tab — working directory and pane splits. Each pane's scrollback is - // reconnected from its `shellsession_buffer_{guid}.txt` file: we pre-mark - // the layout's session GUIDs in `_pendingShellSessionBufferIds` so the + // Inbound event from WTA: {method:"restore_shell_session", params:{name, layout_json}}. + // 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. void TerminalPage::OnRestoreShellSessionRequested(hstring eventJson) { @@ -5226,16 +5227,26 @@ namespace winrt::TerminalApp::implementation return; } const auto nameStr = evt["params"].get("name", "").asString(); - if (nameStr.empty()) + const auto layoutJsonStr = evt["params"].get("layout_json", "").asString(); + if (layoutJsonStr.empty()) { - _agentPaneLog("OnRestoreShellSessionRequested: empty name — ignoring"); + _agentPaneLog("OnRestoreShellSessionRequested: missing layout_json — ignoring"); return; } - const auto layout = ApplicationState::SharedInstance().GetShellSession(winrt::to_hstring(nameStr)); + 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: no saved session named '" + nameStr + "'"); + _agentPaneLog("OnRestoreShellSessionRequested: null layout for '" + nameStr + "'"); return; } const auto tabLayout = layout.TabLayout(); @@ -5246,7 +5257,7 @@ namespace winrt::TerminalApp::implementation } // Pre-mark each pane's session GUID so the buffer-restore path pulls - // scrollback from the durable `shellsession_buffer_` file. + // scrollback from the durable `shell-session-buffers\{guid}.txt` file. std::vector actions; actions.reserve(tabLayout.Size()); for (const auto& action : tabLayout) @@ -7846,26 +7857,32 @@ namespace winrt::TerminalApp::implementation { using namespace std::string_view_literals; - const auto settingsDir = CascadiaSettings::SettingsDirectory(); - const auto admin = IsRunningElevated(); - - // A durable shell-session restore reads scrollback from the - // dedicated `shellsession_buffer_` file (kept clear of the - // window-close cleanup that sweeps `buffer_*` / `elevated_*`). + // 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. - std::wstring_view filenamePrefix; + // 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); - filenamePrefix = L"shellsession_buffer_"sv; + const auto stateDir = ::IntelligentTerminal::StateDir(); + if (!stateDir.empty()) + { + path = (stateDir / L"shell-session-buffers" / (fmt::format(FMT_COMPILE(L"{}"), sessionId) + L".txt")).wstring(); + } } else { - filenamePrefix = admin ? L"elevated_"sv : L"buffer_"sv; + 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); } - const auto path = fmt::format(FMT_COMPILE(L"{}\\{}{}.txt"), settingsDir, filenamePrefix, sessionId); - 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 766cfd33d4..95d892f879 100644 --- a/src/cascadia/TerminalApp/TerminalPage.h +++ b/src/cascadia/TerminalApp/TerminalPage.h @@ -499,10 +499,10 @@ namespace winrt::TerminalApp::implementation // 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 `shellsession_buffer_` - // 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. + // 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, @@ -682,10 +682,11 @@ namespace winrt::TerminalApp::implementation 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. + // 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); - void _PersistShellSessionBuffers(winrt::com_ptr tabImpl); - void _WriteShellSessionsIndexEntry(const winrt::hstring& name, const winrt::hstring& cwd); + std::vector _PersistShellSessionBuffers(winrt::com_ptr tabImpl); void _InitializeTab(winrt::com_ptr newTabImpl, uint32_t insertPosition = -1, bool openInBackground = false); void _RegisterTerminalEvents(Microsoft::Terminal::Control::TermControl term); diff --git a/src/cascadia/TerminalSettingsModel/ApplicationState.cpp b/src/cascadia/TerminalSettingsModel/ApplicationState.cpp index 77e8fe2bbd..45e3e2fde5 100644 --- a/src/cascadia/TerminalSettingsModel/ApplicationState.cpp +++ b/src/cascadia/TerminalSettingsModel/ApplicationState.cpp @@ -460,78 +460,6 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation return nullptr; } - // Method Description: - // - Save (or overwrite) a durable shell session under `name`. Unlike - // workspaces, shell sessions persist after being restored, so the user - // can reopen the same saved tab more than once. - void ApplicationState::SaveShellSession(const hstring& name, const Model::WindowLayout& layout) - { - { - const auto state = _state.lock(); - if (!state->ShellSessions || !*state->ShellSessions) - { - state->ShellSessions = winrt::single_threaded_map(); - } - (*state->ShellSessions).Insert(name, layout); - } - _throttler(); - } - - // Method Description: - // - Remove the durable shell session stored under `name`. - // Return Value: - // - true if an entry was removed, false otherwise. - bool ApplicationState::RemoveShellSession(const hstring& name) - { - bool removed{ false }; - { - const auto state = _state.lock(); - if (state->ShellSessions && *state->ShellSessions) - { - auto map = *state->ShellSessions; - if (map.HasKey(name)) - { - map.Remove(name); - removed = true; - } - } - } - if (removed) - { - _throttler(); - } - return removed; - } - - // Method Description: - // - Look up a durable shell session by `name` WITHOUT removing it (the - // restore path is non-destructive so a session can be reopened again). - // Return Value: - // - The stored layout, or nullptr if there was none. - Model::WindowLayout ApplicationState::GetShellSession(const hstring& name) - { - const auto state = _state.lock_shared(); - if (state->ShellSessions && *state->ShellSessions) - { - auto map = *state->ShellSessions; - if (map.HasKey(name)) - { - return map.Lookup(name); - } - } - return nullptr; - } - - Windows::Foundation::Collections::IMapView ApplicationState::AllShellSessions() - { - const auto state = _state.lock_shared(); - if (state->ShellSessions && *state->ShellSessions) - { - return (*state->ShellSessions).GetView(); - } - return nullptr; - } - // Generate all getter/setters #define MTSM_APPLICATION_STATE_GEN(source, type, name, key, ...) \ type ApplicationState::name() const noexcept \ diff --git a/src/cascadia/TerminalSettingsModel/ApplicationState.h b/src/cascadia/TerminalSettingsModel/ApplicationState.h index 70b3fc88d0..73209a799a 100644 --- a/src/cascadia/TerminalSettingsModel/ApplicationState.h +++ b/src/cascadia/TerminalSettingsModel/ApplicationState.h @@ -44,7 +44,6 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation X(FileSource::Local, Windows::Foundation::Collections::IVector, AllowedCommandlines, "allowedCommandlines") \ X(FileSource::Local, std::unordered_set, DismissedBadges, "dismissedBadges") \ X(FileSource::Local, Windows::Foundation::Collections::IMap, PersistedWorkspaces, "persistedWorkspaces") \ - X(FileSource::Local, Windows::Foundation::Collections::IMap, ShellSessions, "shellSessions") \ X(FileSource::Shared, bool, SSHFolderGenerated, "sshFolderGenerated", false) \ X(FileSource::Shared, bool, AgentFreCompleted, "agentFreCompleted", false) \ X(FileSource::Shared, bool, AgentWelcomeShown, "agentWelcomeShown", false) @@ -88,14 +87,6 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation Model::WindowLayout TakeWorkspace(const hstring& name); Windows::Foundation::Collections::IMapView AllPersistedWorkspaces(); - // Durable shell sessions. Unlike workspaces/window-layouts, these are - // named, user-restorable-on-demand snapshots that are NOT cleared after - // restore, so a tab can be reopened repeatedly. Keyed by tab name. - void SaveShellSession(const hstring& name, const Model::WindowLayout& layout); - bool RemoveShellSession(const hstring& name); - Model::WindowLayout GetShellSession(const hstring& name); - Windows::Foundation::Collections::IMapView AllShellSessions(); - // State getters/setters #define MTSM_APPLICATION_STATE_GEN(source, type, name, key, ...) \ type name() const noexcept; \ diff --git a/src/cascadia/TerminalSettingsModel/ApplicationState.idl b/src/cascadia/TerminalSettingsModel/ApplicationState.idl index 04cc8b81b4..f35711fab8 100644 --- a/src/cascadia/TerminalSettingsModel/ApplicationState.idl +++ b/src/cascadia/TerminalSettingsModel/ApplicationState.idl @@ -42,11 +42,6 @@ namespace Microsoft.Terminal.Settings.Model WindowLayout TakeWorkspace(String name); Windows.Foundation.Collections.IMapView AllPersistedWorkspaces(); - void SaveShellSession(String name, WindowLayout layout); - Boolean RemoveShellSession(String name); - WindowLayout GetShellSession(String name); - Windows.Foundation.Collections.IMapView AllShellSessions(); - String SettingsHash; Windows.Foundation.Collections.IVector PersistedWindowLayouts; Windows.Foundation.Collections.IVector RecentCommands; diff --git a/src/cascadia/inc/IntelligentTerminalPaths.h b/src/cascadia/inc/IntelligentTerminalPaths.h index 240ef37adc..c8b7a9743f 100644 --- a/src/cascadia/inc/IntelligentTerminalPaths.h +++ b/src/cascadia/inc/IntelligentTerminalPaths.h @@ -67,8 +67,9 @@ namespace IntelligentTerminal // 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-sessions sidecar index (`shell-sessions.json`) the agent-pane - // TUI reads. Returns an empty path when `%LOCALAPPDATA%` is unavailable. + // 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]; 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/src/app.rs b/tools/wta/src/app.rs index 25d17c1acf..9140fc5d8a 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` @@ -1547,9 +1555,16 @@ pub struct TabSession { pub shell_sessions_picker_open: bool, /// Highlighted row in `shell_sessions` below. pub shell_sessions_picker_selected: usize, - /// Snapshot of the durable shell sessions, loaded from the C++-written - /// `shell-sessions.json` sidecar when the picker opens. Empty otherwise. - pub shell_sessions: Vec, + /// 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. @@ -3010,12 +3025,42 @@ impl App { } /// `/shell-sessions` — open a picker of durable shell sessions Windows - /// Terminal saved on tab close. Enter restores the highlighted tab (its - /// layout, working directory, and scrollback) via a protocol event. + /// 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`] opens the picker (or reports "none + /// saved yet"). Enter then restores the highlighted tab. fn cmd_shell_sessions(&mut self) { - let sessions = crate::shell_sessions::load(); + 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 open the picker (sessions present) or report that none + /// are saved yet. A stale response (superseded request id) is dropped. + 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.current_tab_mut(); + 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( @@ -3024,7 +3069,8 @@ impl App { tab.scroll_to_bottom(); return; } - let tab = self.current_tab_mut(); + let tab = self.tab_mut(&tab_id); + tab.shell_sessions_pending_request = None; tab.shell_sessions = sessions; tab.shell_sessions_picker_selected = 0; tab.shell_sessions_picker_open = true; @@ -3052,23 +3098,25 @@ impl App { } /// Commit the highlighted row: ask Windows Terminal to restore the saved - /// tab by name via a one-way `restore_shell_session` protocol event. WT - /// owns the authoritative layout + scrollback, so the helper just names - /// the session and lets the C++ side rebuild the tab. + /// 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. fn commit_shell_session_pick(&mut self) { - let name = { + let picked = { let tab = self.current_tab(); let idx = tab.shell_sessions_picker_selected; - tab.shell_sessions.get(idx).map(|entry| entry.name.clone()) + tab.shell_sessions + .get(idx) + .map(|entry| (entry.name.clone(), entry.layout_json.clone())) }; self.close_shell_sessions_picker(); - let Some(name) = name else { + let Some((name, layout_json)) = picked else { return; }; let evt = serde_json::json!({ "type": "event", "method": "restore_shell_session", - "params": { "name": name }, + "params": { "name": name, "layout_json": layout_json }, }); send_wt_protocol_event(evt.to_string()); tracing::info!(target: "shell_sessions", %name, "restore_shell_session event published"); @@ -4641,6 +4689,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", } @@ -5694,6 +5743,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(); diff --git a/tools/wta/src/main.rs b/tools/wta/src/main.rs index 538b8be951..08ec505619 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -31,7 +31,6 @@ mod session_mgmt; mod session_registry; mod session_watcher; mod shell; -mod shell_sessions; mod telemetry; #[cfg(test)] mod test_support; diff --git a/tools/wta/src/master/mod.rs b/tools/wta/src/master/mod.rs index 7c7b1fabf7..b84c40ee6f 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,7 @@ 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::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 +1541,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 +3247,203 @@ 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 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 name.is_empty() || layout_json.is_empty() { + tracing::warn!( + target: "shell_sessions", + has_name = !name.is_empty(), + has_layout = !layout_json.is_empty(), + "save_shell_session missing name/layout_json; ignoring" + ); + return; + } + let cwd = params + .get("cwd") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + 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(); + + let row = shell_sessions_db::ShellSessionRow { + name: name.to_string(), + cwd, + saved_at, + layout_json: layout_json.to_string(), + buffer_guids, + }; + + 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())) +} + +/// 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 { + name: r.name, + cwd: r.cwd, + saved_at: r.saved_at, + layout_json: r.layout_json, + }) + .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 +3668,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..0bf55a78ae --- /dev/null +++ b/tools/wta/src/master/shell_sessions_db.rs @@ -0,0 +1,384 @@ +// 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 = { name (PK), cwd, saved_at, layout_json, buffer_guids }. +// The authoritative index + WT layout JSON + the list of scrollback file +// GUIDs that belong to this session. +// * 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 { + /// The closed tab's title — the session key (upsert overwrites same-name). + pub name: String, + /// Working directory captured at save time (display only; may be empty). + pub cwd: String, + /// Unix seconds when saved. Used to sort newest-first and for TTL. + pub saved_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, +} + +/// 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 ( + 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 INDEX IF NOT EXISTS idx_sessions_saved_at ON sessions(saved_at); + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL + );", + ) + .context("initializing shell-session schema")?; + Ok(()) +} + +/// Insert or replace the row for `row.name`. +/// +/// Returns the buffer GUIDs of any **superseded** row with the same name, so +/// the caller can unlink the now-orphaned scrollback files (each tab close +/// mints fresh per-connection GUIDs, so an overwrite orphans the old files). +/// Returns an empty vec when there was no prior row. +pub fn upsert(conn: &Connection, row: &ShellSessionRow) -> Result> { + let orphaned = get_buffer_guids(conn, &row.name)?; + + let guids_json = serde_json::to_string(&row.buffer_guids) + .context("serializing buffer_guids")?; + conn.execute( + "INSERT INTO sessions (name, cwd, saved_at, layout_json, buffer_guids) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(name) DO UPDATE SET + cwd = excluded.cwd, + saved_at = excluded.saved_at, + layout_json = excluded.layout_json, + buffer_guids = excluded.buffer_guids", + rusqlite::params![row.name, row.cwd, row.saved_at, row.layout_json, guids_json], + ) + .with_context(|| format!("upserting shell session {}", row.name))?; + + Ok(orphaned) +} + +/// All saved sessions, newest first. +pub fn list(conn: &Connection) -> Result> { + let mut stmt = conn + .prepare( + "SELECT name, cwd, saved_at, layout_json, buffer_guids + FROM sessions ORDER BY saved_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 rows saved before `cutoff_unix_secs` and return the buffer GUIDs of +/// every deleted row, so the caller can unlink their scrollback files. +/// +/// 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 saved_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 saved_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 `name` (empty when absent). +fn get_buffer_guids(conn: &Connection, name: &str) -> Result> { + let json: Option = conn + .query_row( + "SELECT buffer_guids FROM sessions WHERE name = ?1", + [name], + |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 {name}"))?; + 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`]. +fn row_from_sqlite(r: &rusqlite::Row<'_>) -> rusqlite::Result { + let buffer_guids: String = r.get(4)?; + Ok(ShellSessionRow { + name: r.get(0)?, + cwd: r.get(1)?, + saved_at: r.get(2)?, + layout_json: r.get(3)?, + buffer_guids: parse_guids(&buffer_guids), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mem() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + init_schema(&conn).unwrap(); + conn + } + + fn row(name: &str, saved_at: i64, guids: &[&str]) -> ShellSessionRow { + ShellSessionRow { + name: name.to_string(), + cwd: format!("C:\\{name}"), + saved_at, + layout_json: format!("{{\"tab\":\"{name}\"}}"), + buffer_guids: guids.iter().map(|s| s.to_string()).collect(), + } + } + + #[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 upsert_same_name_overwrites_and_returns_orphaned_guids() { + let conn = mem(); + upsert(&conn, &row("tab", 100, &["old-a", "old-b"])).unwrap(); + + // Re-close the same-named tab: fresh GUIDs, old ones orphaned. + let orphaned = upsert(&conn, &row("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 name must not create a second row"); + assert_eq!(all[0].saved_at, 200); + assert_eq!(all[0].buffer_guids, vec!["new-a".to_string()]); + } + + #[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 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 (name, cwd, saved_at, layout_json, buffer_guids) + VALUES ('bad', '', 1, '{}', 'not-json')", + [], + ) + .unwrap(); + let all = list(&conn).unwrap(); + assert_eq!(all.len(), 1); + assert!(all[0].buffer_guids.is_empty()); + } +} diff --git a/tools/wta/src/protocol/acp/client.rs b/tools/wta/src/protocol/acp/client.rs index b5bfeb71f9..7a5dce912d 100644 --- a/tools/wta/src/protocol/acp/client.rs +++ b/tools/wta/src/protocol/acp/client.rs @@ -178,6 +178,12 @@ 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, + }, SessionResumeDispatched { request_id: u64, sid: acp::schema::v1::SessionId, @@ -2925,6 +2931,50 @@ 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::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..71dfb107ef 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,63 @@ 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 { + pub name: String, + #[serde(default)] + pub cwd: String, + #[serde(default)] + pub saved_at: i64, + /// Opaque WT `WindowLayout` JSON, replayed verbatim by the C++ side. + #[serde(default)] + pub layout_json: String, +} + +#[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()) +} + /// Parsed view of an inbound ACP `ExtNotification` from master, as /// recognized by the helper's live-set mirror. /// @@ -420,6 +482,9 @@ 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/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 +531,8 @@ 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_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/shell_sessions.rs b/tools/wta/src/shell_sessions.rs deleted file mode 100644 index e2df5fff84..0000000000 --- a/tools/wta/src/shell_sessions.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! Durable shell-session index reader for the `/shell-sessions` picker. -//! -//! Windows Terminal (C++) writes `shell-sessions.json` into the shared -//! IntelligentTerminal `LocalState` directory whenever a tab is closed (see -//! `TerminalPage::_WriteShellSessionsIndexEntry`). This module reads that -//! lightweight sidecar so the agent-pane TUI can render the picker without -//! having to parse WT's `state.json` layout format. -//! -//! The authoritative layout + scrollback live on the C++ side; restoring is a -//! one-way `restore_shell_session` protocol event keyed by the session name. - -use serde::Deserialize; - -use crate::runtime_paths::intelligent_terminal_root; - -/// One saved shell session, as written by the C++ side. -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct ShellSessionEntry { - /// The closed tab's title — this is both the display label and the key the - /// C++ side looks up in `ApplicationState::GetShellSession`. - pub name: String, - /// Working directory captured at save time, for display only. May be empty - /// when the shell never reported one (no OSC 9;9 shell integration). - #[serde(default)] - pub cwd: String, - /// Unix seconds when the session was saved. Used to sort newest-first. - #[serde(default)] - pub saved_at: i64, -} - -/// On-disk shape of `shell-sessions.json`. -#[derive(Debug, Clone, Deserialize, Default)] -struct ShellSessionsIndex { - #[serde(default)] - sessions: Vec, -} - -/// The index file's name inside `intelligent_terminal_root()`. -const INDEX_FILE_NAME: &str = "shell-sessions.json"; - -/// Read the saved shell sessions, most-recently-saved first. -/// -/// Returns an empty vector when the index file is missing or unreadable — an -/// absent file just means the user hasn't closed any tabs yet, so callers -/// surface a "no saved sessions" message rather than an error. -pub fn load() -> Vec { - let Some(path) = intelligent_terminal_root().map(|root| root.join(INDEX_FILE_NAME)) else { - return Vec::new(); - }; - - let contents = match std::fs::read_to_string(&path) { - Ok(contents) => contents, - Err(err) => { - // `NotFound` is the expected "nothing saved yet" case; only louder - // errors (permissions, etc.) are worth a log line. - if err.kind() != std::io::ErrorKind::NotFound { - tracing::warn!(target: "shell_sessions", %err, "failed to read shell-sessions.json"); - } - return Vec::new(); - } - }; - - let mut index: ShellSessionsIndex = match serde_json::from_str(&contents) { - Ok(index) => index, - Err(err) => { - tracing::warn!(target: "shell_sessions", %err, "failed to parse shell-sessions.json"); - return Vec::new(); - } - }; - - // Newest first so the picker's top row is the most recently closed tab. - index.sessions.sort_by(|a, b| b.saved_at.cmp(&a.saved_at)); - index.sessions -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_and_sorts_newest_first() { - let json = r#"{ - "sessions": [ - { "name": "old", "cwd": "C:\\a", "saved_at": 100 }, - { "name": "new", "cwd": "C:\\b", "saved_at": 200 } - ] - }"#; - let mut index: ShellSessionsIndex = serde_json::from_str(json).unwrap(); - index.sessions.sort_by(|a, b| b.saved_at.cmp(&a.saved_at)); - assert_eq!(index.sessions[0].name, "new"); - assert_eq!(index.sessions[1].name, "old"); - assert_eq!(index.sessions[0].cwd, "C:\\b"); - } - - #[test] - fn missing_optional_fields_default() { - let json = r#"{ "sessions": [ { "name": "only-name" } ] }"#; - let index: ShellSessionsIndex = serde_json::from_str(json).unwrap(); - assert_eq!(index.sessions[0].name, "only-name"); - assert_eq!(index.sessions[0].cwd, ""); - assert_eq!(index.sessions[0].saved_at, 0); - } - - #[test] - fn empty_or_missing_sessions_is_empty() { - let index: ShellSessionsIndex = serde_json::from_str("{}").unwrap(); - assert!(index.sessions.is_empty()); - } -} diff --git a/tools/wta/src/ui/shell_sessions_popup.rs b/tools/wta/src/ui/shell_sessions_popup.rs index 37476d71ed..e3c6d7c775 100644 --- a/tools/wta/src/ui/shell_sessions_popup.rs +++ b/tools/wta/src/ui/shell_sessions_popup.rs @@ -2,25 +2,26 @@ //! //! Opened by the `/shell-sessions` slash command (`App::cmd_shell_sessions`), //! this overlay lists the durable shell sessions Windows Terminal saved on tab -//! close (read from the `shell-sessions.json` sidecar — see -//! `crate::shell_sessions`). Enter asks WT to restore the highlighted tab (its -//! layout, working directory, and scrollback). Modeled on the `/model` picker -//! (`model_popup.rs`): anchored above the input box, arrow keys move the -//! highlight, Enter commits, Esc dismisses (all handled in `App::handle_key`). +//! close (fetched from master's SQLite store via the `shell_sessions/list` ext +//! method — see `master/shell_sessions_db.rs`). Enter asks WT to restore the +//! highlighted tab (its layout, working directory, and scrollback). Modeled on +//! the `/model` picker (`model_popup.rs`): anchored above the input box, arrow +//! keys move the highlight, Enter commits, Esc dismisses (all handled in +//! `App::handle_key`). use ratatui::prelude::*; use ratatui::widgets::{Clear, List, ListItem, ListState}; use super::popup; -use crate::shell_sessions::ShellSessionEntry; +use crate::session_registry::ShellSessionInfo; use crate::theme; const POPUP_MAX_VISIBLE: usize = 8; /// Per-frame state captured from the [`App`](crate::app::App). pub struct ShellSessionsPopupState<'a> { - /// Saved sessions, newest first (sorted in `shell_sessions::load`). - pub sessions: &'a [ShellSessionEntry], + /// Saved sessions, newest first (sorted by master's `list`). + pub sessions: &'a [ShellSessionInfo], /// Highlighted row, an index into `sessions`. pub selected: usize, } From 9dc18b08673e66f90e9821567230f234a12c0870 Mon Sep 17 00:00:00 2001 From: DDKinger Date: Sun, 19 Jul 2026 14:26:26 +0800 Subject: [PATCH 04/14] chore(wta): regenerate third-party notices for rusqlite rusqlite (added for the durable shell-session SQLite store) pulls in libsqlite3-sys, fallible-iterator, fallible-streaming-iterator, and hashlink. Regenerated cgmanifest.json + the wta-rust-deps block in NOTICE.md via build/scripts/Generate-WtaThirdPartyNotices.ps1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 73a56ccd-2121-4e3d-b5f4-a1f5c6f2d373 --- NOTICE.md | 72 +++++++++++++++++++++++++++------------ tools/wta/cgmanifest.json | 72 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 21 deletions(-) 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/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", From 1cdd4bc5314b83de0c23b4e09052b7af49416651 Mon Sep 17 00:00:00 2001 From: DDKinger Date: Sun, 19 Jul 2026 18:01:06 +0800 Subject: [PATCH 05/14] feat(durable-sessions): show /shell-sessions as a full-pane restore view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /shell-sessions picker was a small popup anchored above the input box, so chat + the "uses AI" disclaimer stayed visible behind it. Make it a full-pane view (View::ShellSessions) that takes over the agent pane like the /sessions view — chat / input / disclaimer are hidden while choosing a session to restore. - app.rs: add View::ShellSessions; /shell-sessions switches the tab into it once the async shell_sessions/list response arrives (empty result stays in chat with the "none saved" message, so no empty-page flash). Up/Down move, Enter restores, Esc returns to chat. Drop the shell_sessions_picker_open bool in favor of the view. - ui/shell_sessions_view.rs (renamed from shell_sessions_popup.rs): full-pane renderer styled to match agents_view — cyan > caret + cyan name for the selected row (no yellow highlight bar), dimmed cwd, and a right-aligned "last update" age per row. - Each row shows its last-update time via the shared gents_view::relative_age (now pub(crate)), formatted identically to the session view; rows are already newest-first (master orders by saved_at DESC). - layout.rs: render the view full-pane with an early return (like Agents); remove the bottom popup render. cargo test: all 1135 pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 73a56ccd-2121-4e3d-b5f4-a1f5c6f2d373 --- tools/wta/src/app.rs | 69 +++++++----- tools/wta/src/ui/agents_view.rs | 5 +- tools/wta/src/ui/layout.rs | 14 ++- tools/wta/src/ui/mod.rs | 4 +- tools/wta/src/ui/shell_sessions_popup.rs | 64 ----------- tools/wta/src/ui/shell_sessions_view.rs | 137 +++++++++++++++++++++++ 6 files changed, 190 insertions(+), 103 deletions(-) delete mode 100644 tools/wta/src/ui/shell_sessions_popup.rs create mode 100644 tools/wta/src/ui/shell_sessions_view.rs diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index 9140fc5d8a..407203c112 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -1551,9 +1551,8 @@ pub struct TabSession { /// Highlighted row in `App::available_agents`. pub agent_picker_selected: usize, - /// True while the `/shell-sessions` restore picker is open for this tab. - pub shell_sessions_picker_open: bool, - /// Highlighted row in `shell_sessions` below. + /// 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, /// Durable shell sessions for the picker, fetched from master's SQLite /// store (via the `shell_sessions/list` ext method) when the picker is @@ -1621,7 +1620,7 @@ impl TabSession { && !self.paste_pending && !self.model_picker_open && !self.agent_picker_open - && !self.shell_sessions_picker_open + && self.current_view != View::ShellSessions } pub fn clear_recommendations(&mut self) { @@ -2216,6 +2215,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 { @@ -3017,18 +3020,14 @@ impl App { self.publish_agent_status(); } - // ── /shell-sessions picker ────────────────────────────────────────── + // ── /shell-sessions restore view (full-pane, like /sessions) ───────── - /// True while the shell-session restore picker is up for the active tab. - fn shell_sessions_picker_visible(&self) -> bool { - self.current_tab().shell_sessions_picker_open - } - - /// `/shell-sessions` — open a 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`] opens the picker (or reports "none - /// saved yet"). Enter then restores the highlighted tab. + /// `/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 = { @@ -3045,8 +3044,10 @@ impl App { } /// Apply a `shell_sessions/list` response: if it matches the tab's pending - /// request, either open the picker (sessions present) or report that none - /// are saved yet. A stale response (superseded request id) is dropped. + /// 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, @@ -3073,13 +3074,15 @@ impl App { tab.shell_sessions_pending_request = None; tab.shell_sessions = sessions; tab.shell_sessions_picker_selected = 0; - tab.shell_sessions_picker_open = true; + tab.current_view = View::ShellSessions; } - fn close_shell_sessions_picker(&mut self) { + /// 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.shell_sessions_picker_open = false; + tab.current_view = View::Chat; tab.shell_sessions.clear(); + tab.shell_sessions_picker_selected = 0; } fn shell_sessions_picker_up(&mut self) { @@ -3109,7 +3112,7 @@ impl App { .get(idx) .map(|entry| (entry.name.clone(), entry.layout_json.clone())) }; - self.close_shell_sessions_picker(); + self.close_shell_sessions_view(); let Some((name, layout_json)) = picked else { return; }; @@ -7239,15 +7242,15 @@ impl App { return; } - // Shell-session restore picker (`/shell-sessions`): same modal shape as - // the model picker — arrows move, Enter restores the highlighted tab, - // Esc dismisses. Swallow everything else. - if self.shell_sessions_picker_visible() { + // Shell-session restore view (`/shell-sessions`): full-pane list — + // arrows move, Enter restores the highlighted tab, Esc dismisses. + // Swallow everything else while it's up. + if self.current_tab().current_view == View::ShellSessions { 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::Esc => self.close_shell_sessions_picker(), + KeyCode::Esc => self.close_shell_sessions_view(), _ => {} } return; @@ -7927,14 +7930,14 @@ impl App { }) } - /// Per-frame state for the `/shell-sessions` picker modal, or `None` when - /// it's not open on the active tab. - pub fn shell_sessions_popup_state(&self) -> Option> { + /// 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.shell_sessions_picker_open || tab.shell_sessions.is_empty() { + if tab.current_view != View::ShellSessions { return None; } - Some(crate::ui::ShellSessionsPopupState { + Some(crate::ui::ShellSessionsViewState { sessions: &tab.shell_sessions, selected: tab.shell_sessions_picker_selected, }) @@ -9982,6 +9985,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/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 45b4fd0f2b..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, shell_sessions_popup, + 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) @@ -233,10 +241,6 @@ pub fn render(frame: &mut Frame, app: &mut App) { agent_popup::render_popup(frame, agent_state, chunks[6]); } - if let Some(shell_sessions_state) = app.shell_sessions_popup_state() { - shell_sessions_popup::render_popup(frame, shell_sessions_state, chunks[6]); - } - // `/help` overlay sits on top of everything so the user can always // dismiss it with Esc. command_popup::render_help_overlay(frame, app, area); diff --git a/tools/wta/src/ui/mod.rs b/tools/wta/src/ui/mod.rs index 662b4aabf7..a928353405 100644 --- a/tools/wta/src/ui/mod.rs +++ b/tools/wta/src/ui/mod.rs @@ -12,7 +12,7 @@ mod model_popup; mod permission; mod popup; mod recommendations; -mod shell_sessions_popup; +mod shell_sessions_view; pub mod setup; pub mod shimmer; @@ -20,5 +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_popup::ShellSessionsPopupState; +pub use shell_sessions_view::ShellSessionsViewState; pub use shimmer::CYCLE_FRAMES as ACTIVITY_CYCLE_FRAMES; diff --git a/tools/wta/src/ui/shell_sessions_popup.rs b/tools/wta/src/ui/shell_sessions_popup.rs deleted file mode 100644 index e3c6d7c775..0000000000 --- a/tools/wta/src/ui/shell_sessions_popup.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! `/shell-sessions` restore picker modal. -//! -//! Opened by the `/shell-sessions` slash command (`App::cmd_shell_sessions`), -//! this overlay lists 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`). Enter asks WT to restore the -//! highlighted tab (its layout, working directory, and scrollback). Modeled on -//! the `/model` picker (`model_popup.rs`): anchored above the input box, arrow -//! keys move the highlight, Enter commits, Esc dismisses (all handled in -//! `App::handle_key`). - -use ratatui::prelude::*; -use ratatui::widgets::{Clear, List, ListItem, ListState}; - -use super::popup; -use crate::session_registry::ShellSessionInfo; -use crate::theme; - -const POPUP_MAX_VISIBLE: usize = 8; - -/// Per-frame state captured from the [`App`](crate::app::App). -pub struct ShellSessionsPopupState<'a> { - /// Saved sessions, newest first (sorted by master's `list`). - pub sessions: &'a [ShellSessionInfo], - /// Highlighted row, an index into `sessions`. - pub selected: usize, -} - -/// Render the shell-session picker just above `input_area`, falling back to -/// below when there isn't room. No-op on an empty list. -pub fn render_popup(frame: &mut Frame, state: ShellSessionsPopupState<'_>, input_area: Rect) { - if state.sessions.is_empty() { - return; - } - - let visible = state.sessions.len().min(POPUP_MAX_VISIBLE) as u16; - let area = popup::anchored_above(frame, input_area, visible); - - frame.render_widget(Clear, area); - - let items: Vec = state - .sessions - .iter() - .map(|entry| { - let mut spans = vec![Span::styled(format!(" {}", entry.name), theme::INPUT_TEXT)]; - // Show the working directory dimmed after the name, when known. - if !entry.cwd.is_empty() { - spans.push(Span::styled(format!(" {}", entry.cwd), theme::DIM)); - } - ListItem::new(Line::from(spans)) - }) - .collect(); - - let list = List::new(items) - // TODO(localize): extract to a `shell_sessions_picker.title` catalog key. - .block(popup::block("Restore shell session (↑ ↓ • Enter • Esc)".to_string())) - .highlight_style(theme::SELECTED) - .highlight_symbol("> "); - - let mut list_state = ListState::default(); - list_state.select(Some(state.selected.min(state.sessions.len() - 1))); - - frame.render_stateful_widget(list, area, &mut list_state); -} 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..6b354e31ea --- /dev/null +++ b/tools/wta/src/ui/shell_sessions_view.rs @@ -0,0 +1,137 @@ +//! `/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, +} + +/// 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 `saved_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; + let caret = if selected { + Span::styled("> ", Style::default().fg(ACCENT_CYAN)) + } else { + Span::raw(" ") + }; + let name_style = if selected { + Style::default().fg(ACCENT_CYAN) + } else { + theme::INPUT_TEXT + }; + + // "last update" age from the saved_at unix timestamp. + let age = if entry.saved_at > 0 { + relative_age(UNIX_EPOCH + Duration::from_secs(entry.saved_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]); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "↑ ↓ move • Enter restore • Esc cancel", + theme::DIM, + ))), + chunks[4], + ); +} From 17e2490a17897f9c96894cd21c5ec26f0fb0d720 Mon Sep 17 00:00:00 2001 From: DDKinger Date: Sun, 19 Jul 2026 19:25:45 +0800 Subject: [PATCH 06/14] feat(durable-sessions): delete a shell session (D) with confirmation Add a clean delete to the /shell-sessions restore view. Press D on the highlighted row to arm a confirmation (the row turns red and a prompt replaces the hint); Enter confirms, Esc cancels. Deleting is clean - both the SQLite row and its on-disk scrollback files are removed. Flow (helper -> master, master owns the store): - app.rs: View::ShellSessions gains a delete-confirm sub-state (shell_sessions_pending_delete). D arms it; Enter dispatches MasterExtRequest::ShellSessionsDelete { name } and optimistically drops the row locally (falling back to chat when the list empties); Esc cancels. - client.rs: fire-and-forget dispatch of the delete ext-request. - session_registry.rs: intellterm.wta/shell_sessions/delete ext method (const, params, build/parse, WtaExtRequest variant + parse branch). - master/mod.rs: handle_shell_sessions_delete deletes the DB row and unlinks its scrollback files (unlink_shell_session_buffers). - shell_sessions_db.rs: delete(name) removes the row and returns its buffer GUIDs for cleanup (+2 unit tests). - shell_sessions_view.rs: confirm prompt (yellow) + red target row; hint gains "D delete". cargo test: all 1137 pass (incl. new delete DB tests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 73a56ccd-2121-4e3d-b5f4-a1f5c6f2d373 --- tools/wta/src/app.rs | 76 ++++++++++++++++++++++- tools/wta/src/master/mod.rs | 34 ++++++++++ tools/wta/src/master/shell_sessions_db.rs | 33 ++++++++++ tools/wta/src/protocol/acp/client.rs | 25 ++++++++ tools/wta/src/session_registry.rs | 34 ++++++++++ tools/wta/src/ui/shell_sessions_view.rs | 31 ++++++--- 6 files changed, 223 insertions(+), 10 deletions(-) diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index 407203c112..13dc74417b 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -1554,6 +1554,9 @@ pub struct TabSession { /// 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. @@ -3083,6 +3086,49 @@ impl App { 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 name = { + 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.name.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(name) = name { + let _ = self.master_request_tx.send( + crate::protocol::acp::client::MasterExtRequest::ShellSessionsDelete { name }, + ); + } + + // 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) { @@ -7243,13 +7289,32 @@ impl App { } // Shell-session restore view (`/shell-sessions`): full-pane list — - // arrows move, Enter restores the highlighted tab, Esc dismisses. - // Swallow everything else while it's up. + // 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(), _ => {} } @@ -7937,9 +8002,16 @@ impl App { 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, }) } diff --git a/tools/wta/src/master/mod.rs b/tools/wta/src/master/mod.rs index b84c40ee6f..8743434f7b 100644 --- a/tools/wta/src/master/mod.rs +++ b/tools/wta/src/master/mod.rs @@ -1471,6 +1471,7 @@ impl HelperHandler { 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.name).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 @@ -3369,6 +3370,39 @@ async fn handle_shell_sessions_list() -> acp::Result 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, name)) + { + Ok(guids) => { + tracing::info!( + target: "shell_sessions", + name = %name, + buffers = guids.len(), + "deleted durable shell session" + ); + unlink_shell_session_buffers(&guids); + } + Err(e) => tracing::warn!( + target: "shell_sessions", + name = %name, + 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())) +} + /// 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 { diff --git a/tools/wta/src/master/shell_sessions_db.rs b/tools/wta/src/master/shell_sessions_db.rs index 0bf55a78ae..1f64af5b77 100644 --- a/tools/wta/src/master/shell_sessions_db.rs +++ b/tools/wta/src/master/shell_sessions_db.rs @@ -126,6 +126,16 @@ pub fn list(conn: &Connection) -> Result> { Ok(rows) } +/// Delete the row named `name` 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, name: &str) -> Result> { + let orphaned = get_buffer_guids(conn, name)?; + conn.execute("DELETE FROM sessions WHERE name = ?1", [name]) + .with_context(|| format!("deleting shell session {name}"))?; + Ok(orphaned) +} + /// Delete rows saved before `cutoff_unix_secs` and return the buffer GUIDs of /// every deleted row, so the caller can unlink their scrollback files. /// @@ -322,6 +332,29 @@ mod tests { assert_eq!(list(&conn).unwrap().len(), 1); } + #[test] + fn delete_removes_row_and_returns_its_guids() { + let conn = mem(); + upsert(&conn, &row("keep", 200, &["k1"])).unwrap(); + upsert(&conn, &row("drop", 100, &["d1", "d2"])).unwrap(); + + let mut guids = delete(&conn, "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_name_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(); diff --git a/tools/wta/src/protocol/acp/client.rs b/tools/wta/src/protocol/acp/client.rs index 7a5dce912d..de1c7ee68c 100644 --- a/tools/wta/src/protocol/acp/client.rs +++ b/tools/wta/src/protocol/acp/client.rs @@ -184,6 +184,12 @@ pub enum MasterExtRequest { ShellSessionsList { request_id: u64, }, + /// Clean-delete one durable shell session (DB row + its scrollback files), + /// by name. Fire-and-forget: the helper removes the row from its local list + /// optimistically, master does the authoritative delete. + ShellSessionsDelete { + name: String, + }, SessionResumeDispatched { request_id: u64, sid: acp::schema::v1::SessionId, @@ -2975,6 +2981,25 @@ fn dispatch_master_ext_request( } } } + MasterExtRequest::ShellSessionsDelete { name } => { + // 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(&name); + match conn.ext_method(wire).await { + Ok(_) => tracing::info!( + target: "shell_sessions", + %name, + "shell_sessions/delete acknowledged by master" + ), + Err(err) => tracing::warn!( + target: "shell_sessions", + %name, + error = ?err, + "shell_sessions/delete 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 71dfb107ef..28e41e9594 100644 --- a/tools/wta/src/session_registry.rs +++ b/tools/wta/src/session_registry.rs @@ -383,6 +383,35 @@ pub fn parse_shell_sessions_list_response( 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 name (its key). +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct ShellSessionsDeleteParams { + pub name: String, +} + +/// Build an `ExtRequest` for `intellterm.wta/shell_sessions/delete`. +pub fn build_shell_sessions_delete_request(name: &str) -> acp::schema::v1::ExtRequest { + let json = serde_json::to_string(&ShellSessionsDeleteParams { + name: name.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()) +} + /// Parsed view of an inbound ACP `ExtNotification` from master, as /// recognized by the helper's live-set mirror. /// @@ -485,6 +514,9 @@ pub enum WtaExtRequest { /// `_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/session_hook` — a real per-session hook event. SessionHook(crate::agent_sessions::SessionEvent), /// `_intellterm.wta/session_born_bound` — a #266 binding-only registration @@ -533,6 +565,8 @@ pub fn parse_ext_request(req: acp::schema::v1::ExtRequest) -> WtaExtRequest { 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_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/shell_sessions_view.rs b/tools/wta/src/ui/shell_sessions_view.rs index 6b354e31ea..636aa6ba79 100644 --- a/tools/wta/src/ui/shell_sessions_view.rs +++ b/tools/wta/src/ui/shell_sessions_view.rs @@ -31,6 +31,9 @@ pub struct ShellSessionsViewState<'a> { 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 @@ -79,12 +82,18 @@ pub fn render(frame: &mut Frame, state: ShellSessionsViewState<'_>, area: Rect) .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 { - Span::styled("> ", Style::default().fg(ACCENT_CYAN)) + let color = if pending_delete { Color::Red } else { ACCENT_CYAN }; + Span::styled("> ", Style::default().fg(color)) } else { Span::raw(" ") }; - let name_style = if selected { + 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 @@ -127,11 +136,17 @@ pub fn render(frame: &mut Frame, state: ShellSessionsViewState<'_>, area: Rect) frame.render_widget(List::new(items), chunks[2]); - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - "↑ ↓ move • Enter restore • Esc cancel", + // 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, - ))), - chunks[4], - ); + )), + }; + frame.render_widget(Paragraph::new(hint), chunks[4]); } From 4f6cf2de3dc2d73ce7a77e937a293670116989d6 Mon Sep 17 00:00:00 2001 From: DDKinger Date: Sun, 19 Jul 2026 20:15:53 +0800 Subject: [PATCH 07/14] fix(durable-sessions): stop deleting scrollback on re-save of a restored tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second restore of a saved session came up with no scrollback. Root cause: a restored tab reuses its pane session GUIDs, so closing/re-saving it writes the same shell-session-buffers/{guid}.txt files C++ just refreshed. But upsert() returned the previous row's *entire* GUID set as "orphaned", so master then unlinked those freshly-written buffer files — leaving the DB row pointing at deleted files, hence an empty second restore. Fix: upsert() now orphans only the set difference (old GUIDs not referenced by the new row), so re-saving with identical (or overlapping) GUIDs deletes nothing. Added tests for the identical-reuse and partial-overlap cases; the existing disjoint-overwrite test still holds. Note: rows saved before this fix whose buffer files were already deleted are unrecoverable — re-save those sessions. cargo test: shell_sessions_db all pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 73a56ccd-2121-4e3d-b5f4-a1f5c6f2d373 --- tools/wta/src/master/shell_sessions_db.rs | 44 +++++++++++++++++++---- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/tools/wta/src/master/shell_sessions_db.rs b/tools/wta/src/master/shell_sessions_db.rs index 1f64af5b77..cbe69ce97c 100644 --- a/tools/wta/src/master/shell_sessions_db.rs +++ b/tools/wta/src/master/shell_sessions_db.rs @@ -86,12 +86,22 @@ fn init_schema(conn: &Connection) -> Result<()> { /// Insert or replace the row for `row.name`. /// -/// Returns the buffer GUIDs of any **superseded** row with the same name, so -/// the caller can unlink the now-orphaned scrollback files (each tab close -/// mints fresh per-connection GUIDs, so an overwrite orphans the old files). -/// Returns an empty vec when there was no prior row. +/// 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). pub fn upsert(conn: &Connection, row: &ShellSessionRow) -> Result> { - let orphaned = get_buffer_guids(conn, &row.name)?; + let previous = get_buffer_guids(conn, &row.name)?; + 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")?; @@ -298,7 +308,8 @@ mod tests { let conn = mem(); upsert(&conn, &row("tab", 100, &["old-a", "old-b"])).unwrap(); - // Re-close the same-named tab: fresh GUIDs, old ones orphaned. + // Re-close the same-named tab with entirely fresh GUIDs: both old ones + // are orphaned (not referenced by the new row). let orphaned = upsert(&conn, &row("tab", 200, &["new-a"])).unwrap(); assert_eq!(orphaned, vec!["old-a".to_string(), "old-b".to_string()]); @@ -308,6 +319,27 @@ mod tests { 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("tab", 100, &["a", "b"])).unwrap(); + let orphaned = upsert(&conn, &row("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("tab", 100, &["a", "b"])).unwrap(); + // New row keeps `a`, drops `b`, adds `c` → only `b` is orphaned. + let orphaned = upsert(&conn, &row("tab", 200, &["a", "c"])).unwrap(); + assert_eq!(orphaned, vec!["b".to_string()]); + } + #[test] fn ttl_sweep_deletes_expired_and_returns_their_guids() { let conn = mem(); From 5cd67f98c036b5919e41e4192dbb61a4920d76bb Mon Sep 17 00:00:00 2001 From: DDKinger Date: Fri, 17 Jul 2026 21:19:00 +0800 Subject: [PATCH 08/14] feat(durable-sessions): step2 - capture & resume the agent session on restore Builds on step1. When a tab with an agent pane is saved on close, record the agent pane's WT session GUID (pane_session_id); when the shell session is restored, resolve it to the ACP session id and resume that conversation into the rebuilt tab via session/load. C++ (Windows Terminal): - _SaveShellSessionForTab: if the tab has an agent pane (FindAgentPane, which also finds a stashed/hidden one), record its GuidToPlainString(GetSessionId) as agent_pane_session_id in the shell-sessions.json sidecar. - OnRestoreShellSessionRequested now reads optional agent_session_id/agent_cwd and defers to _RestoreShellSessionCoro: a coroutine that replays the layout actions, then (restart-style) tears down the tab's pre-warmed fresh agent pane and opens a RESUMED one via _AutoCreateHiddenAgentPaneShared with --initial-load-session-id. Teardown+create run in one synchronous UI-thread block so the pre-warm race can't leave two panes. Rust (wta): - agent_pane_origin::resolve_latest_session_for_pane: reverse-lookup pane_session_id -> latest ACP session_id from the persistent agent-pane-sessions.jsonl (append order, case-insensitive), so it works across a Terminal restart. - ShellSessionEntry gains agent_pane_session_id; commit_shell_session_pick resolves it and includes agent_session_id (+agent_cwd) in the restore_shell_session event. Falls back to shell-only restore when unresolved. - Tests for the resolver and the new sidecar field. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 99c4f6fb-6ea5-4902-aa34-a68733db9393 --- src/cascadia/TerminalApp/TabManagement.cpp | 20 ++++ src/cascadia/TerminalApp/TerminalPage.cpp | 72 +++++++++++++- src/cascadia/TerminalApp/TerminalPage.h | 1 + tools/wta/src/agent_pane_origin.rs | 82 +++++++++++++++- tools/wta/src/app.rs | 53 ++++++++++- tools/wta/src/master/mod.rs | 9 ++ tools/wta/src/master/shell_sessions_db.rs | 105 +++++++++++++++++++-- tools/wta/src/session_registry.rs | 6 ++ 8 files changed, 329 insertions(+), 19 deletions(-) diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index 953f296b2f..85b83078ae 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -718,6 +718,20 @@ namespace winrt::TerminalApp::implementation cwd = activeControl.WorkingDirectory(); } + // If the tab has an agent pane, 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. + // FindAgentPane() also finds a stashed (hidden) agent pane, so a + // toggled-closed-but-alive agent session is captured too. + winrt::hstring agentPaneSessionId; + if (const auto agentPane = tabImpl->FindAgentPane()) + { + if (const auto sid = agentPane->GetSessionId(); sid != winrt::guid{}) + { + agentPaneSessionId = winrt::hstring{ ::Microsoft::Console::Utils::GuidToPlainString(sid) }; + } + } + // 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 @@ -741,6 +755,12 @@ namespace winrt::TerminalApp::implementation 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; diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 75db857d9b..3cee9fb395 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -5189,7 +5189,8 @@ namespace winrt::TerminalApp::implementation _RequestAgentStateForTab(newTab, std::nullopt, /*pane_open*/ true); } - // Inbound event from WTA: {method:"restore_shell_session", params:{name, layout_json}}. + // 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 @@ -5198,6 +5199,10 @@ namespace winrt::TerminalApp::implementation // `\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"); @@ -5226,13 +5231,16 @@ namespace winrt::TerminalApp::implementation _agentPaneLog("OnRestoreShellSessionRequested: missing params object"); return; } - const auto nameStr = evt["params"].get("name", "").asString(); - const auto layoutJsonStr = evt["params"].get("layout_json", "").asString(); + 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 @@ -5282,7 +5290,63 @@ namespace winrt::TerminalApp::implementation } _agentPaneLog("OnRestoreShellSessionRequested: rebuilding tab for session '" + nameStr + "'"); - ProcessStartupActions(std::move(actions)); + _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: diff --git a/src/cascadia/TerminalApp/TerminalPage.h b/src/cascadia/TerminalApp/TerminalPage.h index 95d892f879..ad6a7892dd 100644 --- a/src/cascadia/TerminalApp/TerminalPage.h +++ b/src/cascadia/TerminalApp/TerminalPage.h @@ -687,6 +687,7 @@ namespace winrt::TerminalApp::implementation // `save_shell_session` event; WT never persists it into state.json. void _SaveShellSessionForTab(const winrt::TerminalApp::Tab& tab); std::vector _PersistShellSessionBuffers(winrt::com_ptr tabImpl); + safe_void_coroutine _RestoreShellSessionCoro(std::vector actions, std::string agentSessionId, std::string agentCwd); void _InitializeTab(winrt::com_ptr newTabImpl, uint32_t insertPosition = -1, bool openInBackground = false); void _RegisterTerminalEvents(Microsoft::Terminal::Control::TermControl term); 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 13dc74417b..a4ef4e1ebc 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -3150,22 +3150,65 @@ impl App { /// 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) - .map(|entry| (entry.name.clone(), entry.layout_json.clone())) + tab.shell_sessions.get(idx).cloned() }; self.close_shell_sessions_view(); - let Some((name, layout_json)) = picked else { + 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": { "name": name, "layout_json": layout_json }, + "params": serde_json::Value::Object(params), }); send_wt_protocol_event(evt.to_string()); tracing::info!(target: "shell_sessions", %name, "restore_shell_session event published"); diff --git a/tools/wta/src/master/mod.rs b/tools/wta/src/master/mod.rs index 8743434f7b..9c4a3b729c 100644 --- a/tools/wta/src/master/mod.rs +++ b/tools/wta/src/master/mod.rs @@ -3334,6 +3334,13 @@ fn save_shell_session_from_event(params: &serde_json::Value) { .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 { name: name.to_string(), @@ -3341,6 +3348,7 @@ fn save_shell_session_from_event(params: &serde_json::Value) { 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)) { @@ -3417,6 +3425,7 @@ fn load_shell_sessions_for_list() -> Vec { diff --git a/tools/wta/src/master/shell_sessions_db.rs b/tools/wta/src/master/shell_sessions_db.rs index cbe69ce97c..e08e888b66 100644 --- a/tools/wta/src/master/shell_sessions_db.rs +++ b/tools/wta/src/master/shell_sessions_db.rs @@ -40,6 +40,11 @@ pub struct ShellSessionRow { /// `\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 @@ -72,7 +77,8 @@ fn init_schema(conn: &Connection) -> Result<()> { cwd TEXT NOT NULL DEFAULT '', saved_at INTEGER NOT NULL DEFAULT 0, layout_json TEXT NOT NULL, - buffer_guids TEXT NOT NULL DEFAULT '[]' + buffer_guids TEXT NOT NULL DEFAULT '[]', + agent_pane_session_id TEXT ); CREATE INDEX IF NOT EXISTS idx_sessions_saved_at ON sessions(saved_at); CREATE TABLE IF NOT EXISTS meta ( @@ -81,6 +87,24 @@ fn init_schema(conn: &Connection) -> Result<()> { );", ) .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)?; + Ok(()) +} + +/// Add the `agent_pane_session_id` column to a pre-existing `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(()) } @@ -106,14 +130,22 @@ pub fn upsert(conn: &Connection, row: &ShellSessionRow) -> Result> { let guids_json = serde_json::to_string(&row.buffer_guids) .context("serializing buffer_guids")?; conn.execute( - "INSERT INTO sessions (name, cwd, saved_at, layout_json, buffer_guids) - VALUES (?1, ?2, ?3, ?4, ?5) + "INSERT INTO sessions (name, cwd, saved_at, layout_json, buffer_guids, agent_pane_session_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(name) DO UPDATE SET cwd = excluded.cwd, saved_at = excluded.saved_at, layout_json = excluded.layout_json, - buffer_guids = excluded.buffer_guids", - rusqlite::params![row.name, row.cwd, row.saved_at, row.layout_json, guids_json], + buffer_guids = excluded.buffer_guids, + agent_pane_session_id = excluded.agent_pane_session_id", + rusqlite::params![ + row.name, + row.cwd, + row.saved_at, + row.layout_json, + guids_json, + row.agent_pane_session_id + ], ) .with_context(|| format!("upserting shell session {}", row.name))?; @@ -124,7 +156,7 @@ pub fn upsert(conn: &Connection, row: &ShellSessionRow) -> Result> { pub fn list(conn: &Connection) -> Result> { let mut stmt = conn .prepare( - "SELECT name, cwd, saved_at, layout_json, buffer_guids + "SELECT name, cwd, saved_at, layout_json, buffer_guids, agent_pane_session_id FROM sessions ORDER BY saved_at DESC", ) .context("preparing shell-session list query")?; @@ -266,6 +298,7 @@ fn row_from_sqlite(r: &rusqlite::Row<'_>) -> rusqlite::Result { saved_at: r.get(2)?, layout_json: r.get(3)?, buffer_guids: parse_guids(&buffer_guids), + agent_pane_session_id: r.get(5)?, }) } @@ -286,6 +319,7 @@ mod tests { saved_at, layout_json: format!("{{\"tab\":\"{name}\"}}"), buffer_guids: guids.iter().map(|s| s.to_string()).collect(), + agent_pane_session_id: None, } } @@ -446,4 +480,63 @@ mod tests { 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 ensure_agent_pane_session_id_column_migrates_legacy_table() { + // Simulate a DB created before the column existed, then run the + // migration path (init_schema calls it) and confirm reads/writes work. + 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', '', 1, '{}', '[]')", + [], + ) + .unwrap(); + + // Idempotent: running the migration twice must not error. + ensure_agent_pane_session_id_column(&conn).unwrap(); + ensure_agent_pane_session_id_column(&conn).unwrap(); + + // The pre-existing row reads back with a NULL (None) agent id. + let all = list(&conn).unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].agent_pane_session_id, None); + + // And new upserts can now store an agent id. + let mut r = row("fresh", 200, &["g1"]); + r.agent_pane_session_id = Some("pane-2".to_string()); + upsert(&conn, &r).unwrap(); + let all = list(&conn).unwrap(); + assert_eq!(all[0].name, "fresh"); + assert_eq!(all[0].agent_pane_session_id.as_deref(), Some("pane-2")); + } } diff --git a/tools/wta/src/session_registry.rs b/tools/wta/src/session_registry.rs index 28e41e9594..7cb8d71d12 100644 --- a/tools/wta/src/session_registry.rs +++ b/tools/wta/src/session_registry.rs @@ -347,6 +347,12 @@ pub struct ShellSessionInfo { /// 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)] From 936f14ecf022ace4a979ba5fc090d11bb8ceb78a Mon Sep 17 00:00:00 2001 From: DDKinger Date: Fri, 17 Jul 2026 21:25:23 +0800 Subject: [PATCH 09/14] feat(durable-sessions): step3 - only resume the agent pane the user had open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on step2. Display-fidelity fix: capture the agent session for a durable shell session ONLY when the agent pane is visible (open) at save time, not when it is stashed/hidden. Every tab pre-warms a stashed agent pane with a fresh, empty ACP session (_InitializeTab). Step2 captured any agent pane FindAgentPane() returned (including the stashed pre-warm), which would resurrect an agent the user never engaged with and pop an empty pane on restore. Requiring !IsHidden() scopes capture to an agent the user actually had open, so restore faithfully re-shows the conversation the user was looking at (rehydrated in-place by ACP session/load — the existing turn/conversation chat display is reused as-is). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 99c4f6fb-6ea5-4902-aa34-a68733db9393 --- src/cascadia/TerminalApp/TabManagement.cpp | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index 85b83078ae..352e88edd8 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -718,13 +718,19 @@ namespace winrt::TerminalApp::implementation cwd = activeControl.WorkingDirectory(); } - // If the tab has an agent pane, 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. - // FindAgentPane() also finds a stashed (hidden) agent pane, so a - // toggled-closed-but-alive agent session is captured too. + // 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()) + if (const auto agentPane = tabImpl->FindAgentPane(); agentPane && !agentPane->IsHidden()) { if (const auto sid = agentPane->GetSessionId(); sid != winrt::guid{}) { From 734536f1da9296c0777e8c699a0d843f2524dc2f Mon Sep 17 00:00:00 2001 From: DDKinger Date: Sun, 19 Jul 2026 20:37:43 +0800 Subject: [PATCH 10/14] feat(durable-sessions): step4 - durable workspace scrollback (#440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on step3. Extends Windows Terminal workspaces with durable-session scrollback: reopening a saved workspace now restores terminal contents, not just the layout + working directories (which upstream already persists). - TerminalPage::PersistState: when saving a named-window workspace, also persist each terminal pane's scrollback via _PersistWorkspaceBuffers() to the standard buffer_/elevated_ files, so the normal restore path rehydrates them on reopen (NewTerminalArgs.SessionId already travels in the workspace layout). - WindowEmperor::_finalizeSessionPersistence: the app-close cleanup that sweeps buffer_*/elevated_* now KEEPS files referenced by any saved workspace (extracted from AllPersistedWorkspaces layouts). Without this a saved-but- closed workspace's buffers were deleted, losing its scrollback on reopen. Agent sessions in workspaces reuse the step2/step3 machinery (capture the open agent pane's session, resume via session/load) and are the next increment — workspaces already restore layout+cwd (upstream) and now scrollback (here). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 99c4f6fb-6ea5-4902-aa34-a68733db9393 --- src/cascadia/TerminalApp/TerminalPage.cpp | 64 +++++++++++++++++++ src/cascadia/TerminalApp/TerminalPage.h | 1 + .../WindowsTerminal/WindowEmperor.cpp | 41 ++++++++++++ 3 files changed, 106 insertions(+) diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 3cee9fb395..3c787db370 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -6259,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; @@ -6279,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. diff --git a/src/cascadia/TerminalApp/TerminalPage.h b/src/cascadia/TerminalApp/TerminalPage.h index ad6a7892dd..84058bf2f3 100644 --- a/src/cascadia/TerminalApp/TerminalPage.h +++ b/src/cascadia/TerminalApp/TerminalPage.h @@ -688,6 +688,7 @@ namespace winrt::TerminalApp::implementation void _SaveShellSessionForTab(const winrt::TerminalApp::Tab& tab); 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); 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. From 5688ad98f9f995ca75215565a74f96f1aaedb46a Mon Sep 17 00:00:00 2001 From: DDKinger Date: Mon, 20 Jul 2026 09:50:24 +0800 Subject: [PATCH 11/14] chore(spelling): allow "rusqlite" and fix "Otherwise,"/"preexisting" patterns check-spelling flagged items on this branch: - "rusqlite" (the new SQLite crate) is not in any dictionary -> add it to .github/actions/spelling/allow/allow.txt (alongside other crate names). - a doc comment ". Otherwise deletes ..." matched the line_forbidden rule that wants a comma -> "Otherwise,". - two "pre-existing" comments (from the step 2/4 agent-pane-session-id migration + test) matched the rule that wants "preexisting". The stale "shellsession" alerts are from step 1's removed shellsession_buffer_ prefix and clear on the next scan; other "pre-existing" hits are in base-branch files this work does not touch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 73a56ccd-2121-4e3d-b5f4-a1f5c6f2d373 --- .github/actions/spelling/allow/allow.txt | 1 + tools/wta/src/master/shell_sessions_db.rs | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) 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/tools/wta/src/master/shell_sessions_db.rs b/tools/wta/src/master/shell_sessions_db.rs index e08e888b66..e2ac23f935 100644 --- a/tools/wta/src/master/shell_sessions_db.rs +++ b/tools/wta/src/master/shell_sessions_db.rs @@ -94,7 +94,7 @@ fn init_schema(conn: &Connection) -> Result<()> { Ok(()) } -/// Add the `agent_pane_session_id` column to a pre-existing `sessions` table +/// 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 @@ -226,7 +226,7 @@ pub enum TtlSweepOutcome { /// /// 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 +/// 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. @@ -526,7 +526,7 @@ mod tests { ensure_agent_pane_session_id_column(&conn).unwrap(); ensure_agent_pane_session_id_column(&conn).unwrap(); - // The pre-existing row reads back with a NULL (None) agent id. + // The preexisting row reads back with a NULL (None) agent id. let all = list(&conn).unwrap(); assert_eq!(all.len(), 1); assert_eq!(all[0].agent_pane_session_id, None); From c3325d45ecceb40df61a09aabacf36a8eccf1292 Mon Sep 17 00:00:00 2001 From: DDKinger Date: Mon, 20 Jul 2026 11:47:02 +0800 Subject: [PATCH 12/14] fix(durable-sessions): skip saving tabs with no saveable content A brand-new tab opened and closed without running any command (and with no scrollback) has nothing worth restoring. Because sessions are keyed by tab title, an empty "PowerShell" snapshot also overwrites a real, same-named saved session on close. Add _TabHasSaveableContent: a tab is saved only when a terminal pane ran a command (OSC 133 command history), accumulated scrollback beyond the viewport, or has an open agent pane with a live session. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 73a56ccd-2121-4e3d-b5f4-a1f5c6f2d373 --- src/cascadia/TerminalApp/TabManagement.cpp | 71 ++++++++++++++++++++++ src/cascadia/TerminalApp/TerminalPage.h | 1 + 2 files changed, 72 insertions(+) diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index 352e88edd8..333b5f071b 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -699,6 +699,16 @@ namespace winrt::TerminalApp::implementation 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))); @@ -777,6 +787,67 @@ namespace winrt::TerminalApp::implementation 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 by-title session list isn't + // polluted with (and real same-named sessions aren't overwritten by) empty + // snapshots. A tab is saveable when any of its terminal panes: + // * ran at least one command (shell-integration command history — the + // primary signal, 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; + } + // Primary: shell integration recorded at least one executed command. + if (const auto history = control.CommandHistory(); history && history.History().Size() > 0) + { + 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 diff --git a/src/cascadia/TerminalApp/TerminalPage.h b/src/cascadia/TerminalApp/TerminalPage.h index 84058bf2f3..d5032f120b 100644 --- a/src/cascadia/TerminalApp/TerminalPage.h +++ b/src/cascadia/TerminalApp/TerminalPage.h @@ -686,6 +686,7 @@ namespace winrt::TerminalApp::implementation // 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(); From 52622380d64d1f16ca79419fc7a129c3b31dc8da Mon Sep 17 00:00:00 2001 From: DDKinger Date: Mon, 20 Jul 2026 11:47:18 +0800 Subject: [PATCH 13/14] refactor(durable-sessions): key store on session_id, split timestamps Change the shell-session SQLite primary key from the tab title to a stable session_id (the anchor terminal pane's WT session GUID = buffer_guids[0]), so different tabs may share a name and a restored tab (which reuses its pane GUIDs) updates the same row instead of duplicating. Split the single saved_at column into updated_at (drives list ordering, newest first) and last_used_at (drives the 15-day TTL, bumped on restore via a new shell_sessions/touch ext method). Add a migration that rebuilds legacy title-keyed tables, deriving session_id from buffer_guids[0] and seeding both timestamps from saved_at. C++ _SaveShellSessionForTab now computes and emits params["session_id"]. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 73a56ccd-2121-4e3d-b5f4-a1f5c6f2d373 --- src/cascadia/TerminalApp/TabManagement.cpp | 24 ++ tools/wta/src/app.rs | 19 +- tools/wta/src/master/mod.rs | 57 +++- tools/wta/src/master/shell_sessions_db.rs | 304 ++++++++++++++++----- tools/wta/src/protocol/acp/client.rs | 32 ++- tools/wta/src/session_registry.rs | 51 +++- tools/wta/src/ui/shell_sessions_view.rs | 8 +- 7 files changed, 391 insertions(+), 104 deletions(-) diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index 333b5f071b..e477df1d36 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -748,6 +748,29 @@ namespace winrt::TerminalApp::implementation } } + // 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 @@ -761,6 +784,7 @@ namespace winrt::TerminalApp::implementation 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); diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index a4ef4e1ebc..460d8874df 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -3093,13 +3093,13 @@ impl App { /// 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 name = { + 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.name.clone()) + tab.shell_sessions.get(idx).map(|e| e.session_id.clone()) }; // Clear the confirmation and optimistically drop the row locally; the @@ -3119,9 +3119,9 @@ impl App { } } - if let Some(name) = name { + if let Some(session_id) = session_id.filter(|s| !s.is_empty()) { let _ = self.master_request_tx.send( - crate::protocol::acp::client::MasterExtRequest::ShellSessionsDelete { name }, + crate::protocol::acp::client::MasterExtRequest::ShellSessionsDelete { session_id }, ); } @@ -3212,6 +3212,17 @@ impl App { }); 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`. diff --git a/tools/wta/src/master/mod.rs b/tools/wta/src/master/mod.rs index 9c4a3b729c..33b9619f42 100644 --- a/tools/wta/src/master/mod.rs +++ b/tools/wta/src/master/mod.rs @@ -1471,7 +1471,8 @@ impl HelperHandler { 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.name).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 @@ -3302,17 +3303,21 @@ fn save_shell_session_from_event(params: &serde_json::Value) { 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 name.is_empty() || layout_json.is_empty() { + if session_id.is_empty() || layout_json.is_empty() { tracing::warn!( target: "shell_sessions", - has_name = !name.is_empty(), + has_session_id = !session_id.is_empty(), has_layout = !layout_json.is_empty(), - "save_shell_session missing name/layout_json; ignoring" + "save_shell_session missing session_id/layout_json; ignoring" ); return; } @@ -3321,6 +3326,8 @@ fn save_shell_session_from_event(params: &serde_json::Value) { .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()) @@ -3343,9 +3350,11 @@ fn save_shell_session_from_event(params: &serde_json::Value) { .map(str::to_string); let row = shell_sessions_db::ShellSessionRow { + session_id: session_id.to_string(), name: name.to_string(), cwd, - saved_at, + updated_at: saved_at, + last_used_at: saved_at, layout_json: layout_json.to_string(), buffer_guids, agent_pane_session_id, @@ -3382,15 +3391,15 @@ async fn handle_shell_sessions_list() -> acp::Result acp::Result { +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, name)) + .and_then(|conn| shell_sessions_db::delete(&conn, session_id)) { Ok(guids) => { tracing::info!( target: "shell_sessions", - name = %name, + session_id = %session_id, buffers = guids.len(), "deleted durable shell session" ); @@ -3398,7 +3407,7 @@ async fn handle_shell_sessions_delete(name: &str) -> acp::Result tracing::warn!( target: "shell_sessions", - name = %name, + session_id = %session_id, err = %e, "failed to delete shell session" ), @@ -3411,6 +3420,33 @@ async fn handle_shell_sessions_delete(name: &str) -> acp::Result 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 { @@ -3421,9 +3457,10 @@ fn load_shell_sessions_for_list() -> Vec rows .into_iter() .map(|r| crate::session_registry::ShellSessionInfo { + session_id: r.session_id, name: r.name, cwd: r.cwd, - saved_at: r.saved_at, + updated_at: r.updated_at, layout_json: r.layout_json, agent_pane_session_id: r.agent_pane_session_id, }) diff --git a/tools/wta/src/master/shell_sessions_db.rs b/tools/wta/src/master/shell_sessions_db.rs index e2ac23f935..83845a8981 100644 --- a/tools/wta/src/master/shell_sessions_db.rs +++ b/tools/wta/src/master/shell_sessions_db.rs @@ -12,9 +12,11 @@ // needed. // // What lives here vs. on disk: -// * SQLite row = { name (PK), cwd, saved_at, layout_json, buffer_guids }. -// The authoritative index + WT layout JSON + the list of scrollback file -// GUIDs that belong to this session. +// * 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 @@ -28,12 +30,21 @@ use rusqlite::Connection; /// One durable shell session as stored in the DB. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ShellSessionRow { - /// The closed tab's title — the session key (upsert overwrites same-name). + /// 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 when saved. Used to sort newest-first and for TTL. - pub saved_at: i64, + /// 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 @@ -73,14 +84,15 @@ pub fn open(db_path: &std::path::Path) -> Result { fn init_schema(conn: &Connection) -> Result<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS sessions ( - name TEXT PRIMARY KEY, + session_id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', cwd TEXT NOT NULL DEFAULT '', - saved_at INTEGER NOT NULL DEFAULT 0, + 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 INDEX IF NOT EXISTS idx_sessions_saved_at ON sessions(saved_at); CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value INTEGER NOT NULL @@ -91,6 +103,16 @@ fn init_schema(conn: &Connection) -> Result<()> { // 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(()) } @@ -108,7 +130,49 @@ fn ensure_agent_pane_session_id_column(conn: &Connection) -> Result<()> { Ok(()) } -/// Insert or replace the row for `row.name`. +/// 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 @@ -118,8 +182,11 @@ fn ensure_agent_pane_session_id_column(conn: &Connection) -> Result<()> { /// `{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.name)?; + 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 @@ -130,34 +197,51 @@ pub fn upsert(conn: &Connection, row: &ShellSessionRow) -> Result> { let guids_json = serde_json::to_string(&row.buffer_guids) .context("serializing buffer_guids")?; conn.execute( - "INSERT INTO sessions (name, cwd, saved_at, layout_json, buffer_guids, agent_pane_session_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(name) DO UPDATE SET + "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, - saved_at = excluded.saved_at, + 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.saved_at, + row.updated_at, + row.last_used_at, row.layout_json, guids_json, row.agent_pane_session_id ], ) - .with_context(|| format!("upserting shell session {}", row.name))?; + .with_context(|| format!("upserting shell session {}", row.session_id))?; Ok(orphaned) } -/// All saved sessions, newest first. +/// 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 name, cwd, saved_at, layout_json, buffer_guids, agent_pane_session_id - FROM sessions ORDER BY saved_at DESC", + "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 @@ -168,25 +252,27 @@ pub fn list(conn: &Connection) -> Result> { Ok(rows) } -/// Delete the row named `name` 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, name: &str) -> Result> { - let orphaned = get_buffer_guids(conn, name)?; - conn.execute("DELETE FROM sessions WHERE name = ?1", [name]) - .with_context(|| format!("deleting shell session {name}"))?; +/// 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 saved before `cutoff_unix_secs` and return the buffer GUIDs of -/// every deleted row, so the caller can unlink their scrollback files. +/// 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 saved_at < ?1") + .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)) @@ -199,7 +285,7 @@ pub fn ttl_sweep(conn: &Connection, cutoff_unix_secs: i64) -> Result .collect::>() }; - conn.execute("DELETE FROM sessions WHERE saved_at < ?1", [cutoff_unix_secs]) + conn.execute("DELETE FROM sessions WHERE last_used_at < ?1", [cutoff_unix_secs]) .context("deleting expired shell sessions")?; Ok(orphaned) @@ -267,19 +353,19 @@ fn record_ttl_sweep(conn: &Connection, now: i64) -> Result<()> { Ok(()) } -/// Look up the buffer GUIDs currently stored under `name` (empty when absent). -fn get_buffer_guids(conn: &Connection, name: &str) -> Result> { +/// 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 name = ?1", - [name], + "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 {name}"))?; + .with_context(|| format!("looking up buffer guids for {session_id}"))?; Ok(json.map(|j| parse_guids(&j)).unwrap_or_default()) } @@ -289,16 +375,19 @@ fn parse_guids(json: &str) -> Vec { serde_json::from_str::>(json).unwrap_or_default() } -/// Map one SQLite row to a [`ShellSessionRow`]. +/// 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(4)?; + let buffer_guids: String = r.get(6)?; Ok(ShellSessionRow { - name: r.get(0)?, - cwd: r.get(1)?, - saved_at: r.get(2)?, - layout_json: r.get(3)?, + 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(5)?, + agent_pane_session_id: r.get(7)?, }) } @@ -312,11 +401,25 @@ mod tests { conn } - fn row(name: &str, saved_at: i64, guids: &[&str]) -> ShellSessionRow { + // 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}"), - saved_at, + 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, @@ -338,18 +441,29 @@ mod tests { } #[test] - fn upsert_same_name_overwrites_and_returns_orphaned_guids() { + 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("tab", 100, &["old-a", "old-b"])).unwrap(); + upsert(&conn, &row_with_id("s", "tab", 100, &["old-a", "old-b"])).unwrap(); - // Re-close the same-named tab with entirely fresh GUIDs: both old ones - // are orphaned (not referenced by the new row). - let orphaned = upsert(&conn, &row("tab", 200, &["new-a"])).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 name must not create a second row"); - assert_eq!(all[0].saved_at, 200); + 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()]); } @@ -359,8 +473,8 @@ mod tests { // 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("tab", 100, &["a", "b"])).unwrap(); - let orphaned = upsert(&conn, &row("tab", 200, &["a", "b"])).unwrap(); + 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()]); } @@ -368,12 +482,51 @@ mod tests { #[test] fn upsert_partial_overlap_orphans_only_dropped_guids() { let conn = mem(); - upsert(&conn, &row("tab", 100, &["a", "b"])).unwrap(); + 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("tab", 200, &["a", "c"])).unwrap(); + 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(); @@ -401,10 +554,10 @@ mod tests { #[test] fn delete_removes_row_and_returns_its_guids() { let conn = mem(); - upsert(&conn, &row("keep", 200, &["k1"])).unwrap(); - upsert(&conn, &row("drop", 100, &["d1", "d2"])).unwrap(); + 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, "drop").unwrap(); + let mut guids = delete(&conn, "s-drop").unwrap(); guids.sort(); assert_eq!(guids, vec!["d1".to_string(), "d2".to_string()]); @@ -414,7 +567,7 @@ mod tests { } #[test] - fn delete_missing_name_is_noop() { + fn delete_missing_session_is_noop() { let conn = mem(); upsert(&conn, &row("keep", 200, &["k1"])).unwrap(); assert!(delete(&conn, "nope").unwrap().is_empty()); @@ -471,8 +624,8 @@ mod tests { fn corrupt_buffer_guids_cell_yields_empty_not_panic() { let conn = mem(); conn.execute( - "INSERT INTO sessions (name, cwd, saved_at, layout_json, buffer_guids) - VALUES ('bad', '', 1, '{}', 'not-json')", + "INSERT INTO sessions (session_id, name, cwd, updated_at, last_used_at, layout_json, buffer_guids) + VALUES ('s', 'bad', '', 1, 1, '{}', 'not-json')", [], ) .unwrap(); @@ -500,9 +653,11 @@ mod tests { } #[test] - fn ensure_agent_pane_session_id_column_migrates_legacy_table() { - // Simulate a DB created before the column existed, then run the - // migration path (init_schema calls it) and confirm reads/writes work. + 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 ( @@ -517,26 +672,27 @@ mod tests { .unwrap(); conn.execute( "INSERT INTO sessions (name, cwd, saved_at, layout_json, buffer_guids) - VALUES ('legacy', '', 1, '{}', '[]')", + VALUES ('legacy', 'C:\\x', 42, '{}', '[\"anchor-guid\"]')", [], ) .unwrap(); - // Idempotent: running the migration twice must not error. - ensure_agent_pane_session_id_column(&conn).unwrap(); - ensure_agent_pane_session_id_column(&conn).unwrap(); + // Run the full migration (idempotent — must survive a second call). + init_schema(&conn).unwrap(); + init_schema(&conn).unwrap(); - // The preexisting row reads back with a NULL (None) agent id. 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); - // And new upserts can now store an agent id. + // 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(); - let all = list(&conn).unwrap(); - assert_eq!(all[0].name, "fresh"); - assert_eq!(all[0].agent_pane_session_id.as_deref(), Some("pane-2")); + 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 de1c7ee68c..0a9d095ecd 100644 --- a/tools/wta/src/protocol/acp/client.rs +++ b/tools/wta/src/protocol/acp/client.rs @@ -185,10 +185,15 @@ pub enum MasterExtRequest { request_id: u64, }, /// Clean-delete one durable shell session (DB row + its scrollback files), - /// by name. Fire-and-forget: the helper removes the row from its local list - /// optimistically, master does the authoritative delete. + /// by session_id. Fire-and-forget: the helper removes the row from its local + /// list optimistically, master does the authoritative delete. ShellSessionsDelete { - name: String, + 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, @@ -2981,25 +2986,38 @@ fn dispatch_master_ext_request( } } } - MasterExtRequest::ShellSessionsDelete { name } => { + 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(&name); + let wire = crate::session_registry::build_shell_sessions_delete_request(&session_id); match conn.ext_method(wire).await { Ok(_) => tracing::info!( target: "shell_sessions", - %name, + %session_id, "shell_sessions/delete acknowledged by master" ), Err(err) => tracing::warn!( target: "shell_sessions", - %name, + %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 7cb8d71d12..25cb0ddb1b 100644 --- a/tools/wta/src/session_registry.rs +++ b/tools/wta/src/session_registry.rs @@ -339,11 +339,17 @@ pub struct ShellSessionsListParams {} /// `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 saved_at: i64, + pub updated_at: i64, /// Opaque WT `WindowLayout` JSON, replayed verbatim by the C++ side. #[serde(default)] pub layout_json: String, @@ -395,16 +401,16 @@ pub fn parse_shell_sessions_list_response( /// 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 name (its key). +/// 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 name: String, + pub session_id: String, } /// Build an `ExtRequest` for `intellterm.wta/shell_sessions/delete`. -pub fn build_shell_sessions_delete_request(name: &str) -> acp::schema::v1::ExtRequest { +pub fn build_shell_sessions_delete_request(session_id: &str) -> acp::schema::v1::ExtRequest { let json = serde_json::to_string(&ShellSessionsDeleteParams { - name: name.to_string(), + session_id: session_id.to_string(), }) .expect("ShellSessionsDeleteParams is trivially serializable"); let raw = serde_json::value::RawValue::from_string(json) @@ -418,6 +424,36 @@ pub fn parse_shell_sessions_delete_params( 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. /// @@ -523,6 +559,9 @@ pub enum WtaExtRequest { /// `_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 @@ -573,6 +612,8 @@ pub fn parse_ext_request(req: acp::schema::v1::ExtRequest) -> WtaExtRequest { 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/shell_sessions_view.rs b/tools/wta/src/ui/shell_sessions_view.rs index 636aa6ba79..1dc16f5fd1 100644 --- a/tools/wta/src/ui/shell_sessions_view.rs +++ b/tools/wta/src/ui/shell_sessions_view.rs @@ -74,7 +74,7 @@ pub fn render(frame: &mut Frame, state: ShellSessionsViewState<'_>, area: Rect) // 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 `saved_at DESC`). + // (master's `list` orders by `updated_at DESC`). let row_width = chunks[2].width as usize; let items: Vec = state .sessions @@ -99,9 +99,9 @@ pub fn render(frame: &mut Frame, state: ShellSessionsViewState<'_>, area: Rect) theme::INPUT_TEXT }; - // "last update" age from the saved_at unix timestamp. - let age = if entry.saved_at > 0 { - relative_age(UNIX_EPOCH + Duration::from_secs(entry.saved_at as u64)) + // "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() }; From 08173e6322007b39d4b95a783a6bd624ebdf51bf Mon Sep 17 00:00:00 2001 From: DDKinger Date: Mon, 20 Jul 2026 13:16:22 +0800 Subject: [PATCH 14/14] fix(durable-sessions): treat a running full-screen TUI as saveable content A tab where the user launched an interactive CLI (e.g. typed "copilot") was not being saved on close. Such a CLI runs on the alternate screen buffer, so the main buffer stops growing (BufferHeight == ViewHeight) and command marks aren't recorded; the still-running command is also excluded from CommandHistory. With all three heuristics missing, _TabHasSaveableContent returned false and the tab was dropped. Expose an InAltBuffer signal (Terminal -> ControlCore -> TermControl via ICoreState) and treat an active alternate screen buffer as saveable. Also count a non-empty current commandline, covering a running non-TUI command. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 73a56ccd-2121-4e3d-b5f4-a1f5c6f2d373 --- src/cascadia/TerminalApp/TabManagement.cpp | 33 +++++++++++++++----- src/cascadia/TerminalControl/ControlCore.cpp | 7 +++++ src/cascadia/TerminalControl/ControlCore.h | 1 + src/cascadia/TerminalControl/ICoreState.idl | 1 + src/cascadia/TerminalControl/TermControl.cpp | 5 +++ src/cascadia/TerminalControl/TermControl.h | 1 + src/cascadia/TerminalCore/Terminal.cpp | 10 ++++++ src/cascadia/TerminalCore/Terminal.hpp | 2 ++ 8 files changed, 53 insertions(+), 7 deletions(-) diff --git a/src/cascadia/TerminalApp/TabManagement.cpp b/src/cascadia/TerminalApp/TabManagement.cpp index e477df1d36..5132cbdbdc 100644 --- a/src/cascadia/TerminalApp/TabManagement.cpp +++ b/src/cascadia/TerminalApp/TabManagement.cpp @@ -814,11 +814,15 @@ namespace winrt::TerminalApp::implementation // 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 by-title session list isn't - // polluted with (and real same-named sessions aren't overwritten by) empty - // snapshots. A tab is saveable when any of its terminal panes: - // * ran at least one command (shell-integration command history — the - // primary signal, works for PowerShell/pwsh with OSC 133 marks), or + // 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. @@ -843,12 +847,27 @@ namespace winrt::TerminalApp::implementation { return; } - // Primary: shell integration recorded at least one executed command. - if (const auto history = control.CommandHistory(); history && history.History().Size() > 0) + // 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()) 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