# APP-3736: Allow specifying tab-close behavior in tab configs — Tech Spec Product spec: `specs/APP-3736/PRODUCT.md` ## Problem Tab configs can declare how to open a tab, including pane layout, startup commands, and templated params, but there was no declarative way to run cleanup when a tab created from a config is later closed. This was especially painful for worktree configs, which can create a worktree on open but previously left users to manually remove the worktree directory and branch. ## Relevant Code - `app/src/tab_configs/tab_config.rs` — `TabConfigOnClose`, `ResolvedTabCloseBehavior`, `TabConfig::on_close`, `render_tab_close_behavior`, `build_template_contexts` - `app/src/tab_configs/mod.rs` — exports `render_tab_close_behavior` and `ResolvedTabCloseBehavior` - `app/src/tab.rs` — stores `resolved_tab_close_behavior` on `TabData` - `app/src/workspace/view.rs` — resolves close behavior when opening a tab config, runs cleanup during tab close, and executes close commands with `LocalCommandExecutor` - `app/src/tab_configs/session_config.rs` — initializes `on_close: None` for generated configs that do not define close behavior - `app/src/tab_configs/tab_config_tests.rs` — parser and template-rendering coverage for `[on_close]` ## Current State `TabConfig` already supports templated titles, pane `directory`, pane startup commands, and params. Rendering uses two template contexts: unquoted values for paths/titles and shell-quoted values for commands. `TabData` is the in-memory state container for each tab. Before this change, it tracked pane group state, colors, mouse state, and telemetry flags, but not any tab-config-specific close behavior. Tab close flows already funnel through `Workspace::close_tabs`, which optionally shows an unsaved-state confirmation dialog, cancels tab renaming, removes tabs in reverse index order, and emits telemetry once tabs are actually closed. ## Changes ### 1. Tab-config schema and resolved close behavior `TabConfig` now has an optional top-level `on_close: Option`. `TabConfigOnClose` mirrors the close-time command contract: - `directory: Option` — optional working directory for cleanup commands - `commands: Vec` — ordered commands to run on tab close `ResolvedTabCloseBehavior` stores the fully rendered cleanup plan for one opened tab instance: - `directory: Option` - `commands: Vec` The resolved form is intentionally separate from `TabConfigOnClose` so close-time execution can use the exact values chosen when that tab was opened, without re-reading config files or re-prompting for params. ### 2. Shared template-context builder `build_template_contexts` now centralizes construction of both template contexts used by tab config rendering: - `unquoted_context`: raw param values, plus `autogenerated_branch_name` when provided - `quoted_context`: shell-quoted param values, plus a shell-quoted `autogenerated_branch_name` when provided `render_tab_config` and `render_tab_close_behavior` both use this helper so open-time and close-time template substitution follow the same quoting rules: - titles and pane `directory` use unquoted values - commands use shell-quoted values This avoids duplicating context setup and preserves existing shell-quoting behavior for command templates with spaces. ### 3. Render and attach close behavior when opening tab configs `Workspace::open_tab_config_with_params` now resolves both: - the tab title + pane layout via `render_tab_config` - the optional close behavior via `render_tab_close_behavior` After calling `add_tab_with_pane_layout`, the method writes the resolved close behavior into `self.tabs[self.active_tab_index].resolved_tab_close_behavior`. It also applies the tab color from the config as before. This means each tab instance carries its own rendered cleanup commands, including manual param values and the generated branch name from `maybe_generate_worktree_name`. ### 4. Store close behavior on tab state `TabData` has a new field: - `resolved_tab_close_behavior: Option` `TabData::new` initializes it to `None`, so ordinary tabs and tabs not opened from configs have no close hook and keep the existing close behavior. The field is stored only in memory on the live `TabData`; it is not serialized into workspace snapshots. ### 5. Run cleanup from the existing tab-close flow `Workspace::close_tabs` now collects each closing tab's `resolved_tab_close_behavior` before removing any tabs: 1. Build `tab_indices_vec` 2. Show the existing unsaved-state confirmation dialog if needed 3. Cancel any in-progress tab rename 4. Take (consume) each selected tab's resolved close behavior and mark `cleanup_was_run = true` on the tab 5. Call `run_tab_close_cleanup` for each collected behavior 6. Remove tabs in reverse index order as before Collecting the close behaviors first avoids depending on tab indices after tabs have been removed. ### 6. Async best-effort cleanup execution on native local-tty builds On native builds with `local_tty`, `run_tab_close_cleanup` reads the current `LocalShellState` and spawns `execute_tab_close_behavior` with: - shell type - shell path - PATH from the active local shell environment If `LocalShellState` is unavailable, cleanup is skipped and a warning is logged. `execute_tab_close_behavior` constructs a `LocalCommandExecutor` and executes the resolved cleanup commands sequentially. For each command, it passes: - the resolved `directory`, if present - environment variables containing `HOME` and `PATH` when available - `ExecuteCommandOptions { run_command_in_same_shell_as_session: true }` If a command exits successfully, execution continues to the next command. If a command returns a non-zero status, execution stops and returns an error containing the failed command and command output. The spawned callback logs that error with `log::warn!`. The workspace does not await this task before removing the tab, so cleanup is intentionally best-effort and non-blocking from the user's perspective. If cleanup fails, or if `LocalShellState` is unavailable, `run_tab_close_cleanup` also adds a persistent `DismissibleToast::error(...)` to the workspace toast stack so the failure is visible without blocking tab close. On builds without native local-tty support, `run_tab_close_cleanup` is a no-op stub. ### 7. Default `on_close` value for generated configs `build_tab_config` and `tab_config_from_pane_snapshot` now initialize `on_close: None` so generated startup/session configs and saved-from-live-tab configs preserve current behavior unless a user explicitly edits the TOML to add `[on_close]`. ### 8. Tests `tab_config_tests.rs` adds coverage for: - parsing `[on_close]` in a worktree config - `render_tab_close_behavior` substituting manual params - `render_tab_close_behavior` substituting `autogenerated_branch_name` - shell-quoting command params with spaces while leaving pane `directory` unquoted Existing render tests continue to cover open-time title/layout rendering and fallback behavior for invalid pane trees. ## End-to-End Flow 1. User selects a tab config from the new-session menu. 2. If the config has params, the params modal collects values; otherwise defaults are used. If any pane sets `worktree_name_autogenerated = true`, `maybe_generate_worktree_name` creates a unique branch name. 3. `open_tab_config_with_params` renders the pane template, title, and optional close behavior, opens the tab, and stores the resolved close behavior on the new `TabData`. 4. User later closes that tab. 5. `close_tabs` gathers each tab's resolved close behavior and calls `run_tab_close_cleanup` before removing the tabs. 6. On native local-tty builds, cleanup commands run asynchronously through `LocalCommandExecutor`. Tabs disappear immediately; failures are logged and stop any remaining cleanup commands for that tab. ## Risks and Limitations - **Single-fire cleanup:** `resolved_tab_close_behavior` is consumed (`.take()`n) before the tab is removed, so re-closing an undo-restored tab does not fire cleanup again. Tabs restored from workspace snapshots also do not recover a prior close hook because the field is not serialized. - **Undo-close CWD fallback:** when a tab with `cleanup_was_run = true` is restored via undo-close, `restore_closed_tab` iterates its terminal panes and calls `cd_home_if_dir_missing` on each. If the CWD no longer exists (e.g., the worktree was removed by cleanup), the shell falls back to the home directory. - **Race between cleanup and undo-close:** cleanup runs asynchronously via `ctx.spawn`, so it may not have finished by the time the user triggers undo-close. In that case, the CWD still exists at restoration time and the fallback is a no-op. If the user then runs commands, the directory may disappear mid-session once cleanup finishes. - **Best-effort async cleanup:** Because tab removal does not wait for cleanup completion, users can immediately create another worktree or branch with the same name and race against the cleanup task. - **Non-blocking failure toast only:** Cleanup failures show a persistent toast and log a warning, but there is still no blocking retry/undo flow in the close path. - **Depends on local shell availability:** If `LocalShellState` is unavailable, cleanup is skipped. - **Command environment is intentionally minimal:** Cleanup gets `HOME` and `PATH`, but not a full clone of the pane's process environment. ## Testing and Validation - Run targeted tab-config tests covering parser and rendering behavior, especially `[on_close]` and shell-quoting cases. - Run targeted workspace tests for the close-cleanup failure path to verify persistent error toasts are shown. - Manually verify a config that removes only a worktree on close. - Manually verify a config that removes a worktree and then deletes the branch on close. - Manually verify that a failing cleanup command does not block tab close, shows a persistent error toast, and prevents later cleanup commands from running. - Manually verify that tabs opened from configs without `[on_close]` keep existing close behavior. - Run `cargo fmt` and a Rust test/lint pass appropriate for the touched modules before sending the branch for review. ## Follow-ups - Persist close behavior in tab/workspace snapshots if restored tabs should continue running `[on_close]`. - Consider generating `[on_close]` automatically in `build_worktree_config_toml` if default worktree configs should clean themselves up without manual edits.