From 26b9f60437aaa42d2244da387e53bee6e964b01c Mon Sep 17 00:00:00 2001 From: Daniels-Main Date: Mon, 20 Jul 2026 16:46:09 +0200 Subject: [PATCH] feat: add embedded terminal workspaces Add persistent PTY-backed terminal sessions with shell selection and terminal appearance settings. Introduce work tabs for terminals and files, shared select controls, repository-aware state, and supporting tests, documentation, fonts, and dependency updates. --- Cargo.lock | 99 +- PRD.md | 56 +- README.md | 34 +- ROADMAP.md | 43 + TASKS.md | 54 +- crates/strand-tauri/Cargo.toml | 1 + crates/strand-tauri/src/commands.rs | 61 +- crates/strand-tauri/src/main.rs | 18 +- crates/strand-tauri/src/state.rs | 3 + crates/strand-tauri/src/terminal.rs | 968 ++++++++++++++++++ docs/learnings.md | 73 +- pnpm-lock.yaml | 16 + ui/package.json | 2 + ui/public/fonts/fonts.css | 18 + ui/public/fonts/jetbrains-mono-OFL.txt | 93 ++ ...etbrains-mono-terminal-italic-variable.ttf | Bin 0 -> 322360 bytes .../jetbrains-mono-terminal-variable.ttf | Bin 0 -> 304208 bytes ui/src/App.tsx | 37 +- ui/src/components/PierreTree.tsx | 10 +- ui/src/components/Select.tsx | 26 + ui/src/components/Sidebar.tsx | 54 +- ui/src/lib/db.ts | 12 + ui/src/lib/embeddedShell.test.ts | 25 + ui/src/lib/embeddedShell.ts | 79 ++ ui/src/lib/fileEditing.test.ts | 14 + ui/src/lib/fileEditing.ts | 6 + ui/src/lib/i18n.ts | 63 ++ ui/src/lib/keys.ts | 14 +- ui/src/lib/menu.ts | 4 +- ui/src/lib/tauri.ts | 24 + ui/src/lib/types.ts | 25 + ui/src/lib/workTabs.test.ts | 104 ++ ui/src/lib/workTabs.ts | 160 +++ ui/src/main.tsx | 2 + ui/src/stores/repo.ts | 12 +- ui/src/stores/settings.test.ts | 7 +- ui/src/stores/settings.ts | 61 +- ui/src/stores/work.ts | 231 +++++ ui/src/stores/workspaces.ts | 35 + ui/src/styles/features.css | 327 ++++++ ui/src/views/BranchNetworkDialog.tsx | 13 +- ui/src/views/Commits.tsx | 22 + ui/src/views/CompareRefsDialog.tsx | 9 +- ui/src/views/FileView.tsx | 168 ++- ui/src/views/RebaseEditor.tsx | 5 +- ui/src/views/SettingsDialog.tsx | 5 +- ui/src/views/Work.tsx | 704 +++++++++++++ ui/src/views/WorktreeDialog.tsx | 9 +- ui/src/views/WorktreeMergeDialog.tsx | 5 +- ui/src/views/settings/AiSection.tsx | 9 +- ui/src/views/settings/AppearanceSection.tsx | 9 + ui/src/views/settings/HostingSection.tsx | 5 +- ui/src/views/settings/IntegrationsSection.tsx | 5 +- ui/src/views/settings/KeyboardSection.tsx | 1 + ui/src/views/settings/TerminalSection.tsx | 294 ++++++ ui/src/views/settings/shared.tsx | 6 +- website/docs/commits-and-history.md | 17 +- website/docs/everyday-git.md | 2 +- website/docs/getting-started.md | 22 +- website/docs/index.md | 1 + website/docs/keyboard-and-palette.md | 30 +- website/docs/manifest.json | 1 + website/docs/repositories-and-workspaces.md | 2 +- website/docs/reviewing-agent-changes.md | 6 +- website/docs/settings.md | 24 +- website/docs/work.md | 99 ++ website/docs/worktrees.md | 12 +- website/index.html | 1 + 68 files changed, 4177 insertions(+), 180 deletions(-) create mode 100644 crates/strand-tauri/src/terminal.rs create mode 100644 ui/public/fonts/jetbrains-mono-OFL.txt create mode 100644 ui/public/fonts/jetbrains-mono-terminal-italic-variable.ttf create mode 100644 ui/public/fonts/jetbrains-mono-terminal-variable.ttf create mode 100644 ui/src/components/Select.tsx create mode 100644 ui/src/lib/embeddedShell.test.ts create mode 100644 ui/src/lib/embeddedShell.ts create mode 100644 ui/src/lib/fileEditing.test.ts create mode 100644 ui/src/lib/fileEditing.ts create mode 100644 ui/src/lib/workTabs.test.ts create mode 100644 ui/src/lib/workTabs.ts create mode 100644 ui/src/stores/work.ts create mode 100644 ui/src/views/Work.tsx create mode 100644 ui/src/views/settings/TerminalSection.tsx create mode 100644 website/docs/work.md diff --git a/Cargo.lock b/Cargo.lock index fd9a7a8..95a001c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -440,7 +440,7 @@ checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "borsh-derive", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", ] [[package]] @@ -667,6 +667,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -1118,6 +1124,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1189,7 +1201,7 @@ dependencies = [ "rustc_version", "toml 1.1.2+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -1324,6 +1336,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.29" @@ -3589,6 +3612,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.31.3" @@ -3597,7 +3632,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.11.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", ] @@ -4017,7 +4052,7 @@ checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" dependencies = [ "android_system_properties", "log", - "nix", + "nix 0.31.3", "objc2", "objc2-foundation", "objc2-ui-kit", @@ -4305,6 +4340,27 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -4449,7 +4505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -4491,7 +4547,7 @@ version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2", @@ -5350,6 +5406,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serial2" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -5429,6 +5496,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -5862,6 +5939,7 @@ dependencies = [ "flate2", "libc", "minisign-verify", + "portable-pty", "reqwest", "serde", "serde_json", @@ -8027,6 +8105,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/PRD.md b/PRD.md index 394a238..a0531b0 100644 --- a/PRD.md +++ b/PRD.md @@ -86,11 +86,14 @@ The app has two primary regions plus a topbar and a status bar: a **left sidebar - **Stash** — split button: stash all, stash staged, pop, apply. - **Branch** — current branch + dropdown to switch / create. - **Open in…** — editor, terminal, file manager, web (GitHub/GitLab URL). -- **Work** — toggle that focuses the main pane on Local Changes (staging mode). +- **Work** is the startup destination for lightweight working-tree editing, file inspection, and + repository-scoped embedded terminals. Local Changes remains the separate + staging/commit workspace. - **Repo tabs** — multi-repo, like browser tabs. Reorderable, persisted across launches. ### Sidebar (left) — navigation source +- **Work** — mixed file/terminal tabs for the active repository. - **Local Changes** — working tree + index status. Shows a badge with file count when there are uncommitted changes. - **Pull Requests** — hosted pull requests for the current repository, starting with GitHub and Azure DevOps; GitLab and Bitbucket follow through the same @@ -110,6 +113,34 @@ The app has two primary regions plus a topbar and a status bar: a **left sidebar The main pane fills the right side and shows different content based on the sidebar selection. +#### When **"Work"** is selected — documents and terminals + +Work has one peer tab strip for files, directories, and embedded +shells. A single file click/focus owns one replaceable italic preview; Enter, +double-click, Files **Open**, History, Blame, and palette selection pin it. +File tabs keep Content, rendered Preview, History, Compare, or Blame per tab, +and only the active file document mounts or fetches. File tabs survive view and +repository switches during the process but are not restored after relaunch. +The peer strip preserves tab width, scrolls horizontally by wheel, exposes an +overflow jump list, and uses the Files tree's icons for file tabs. + +Terminal renderers and PTYs survive view, workspace, and repository switches. +Their descriptors restore after relaunch without starting a process; explicitly +selecting a restored tab starts a fresh shell at that worktree root. Natural +exit keeps the transcript and offers Relaunch. Closing the tab removes its +descriptor; final repository close confirms before stopping live terminals, +and app exit always drains every terminal process tree. Settings → Terminal +chooses a global embedded shell plus a repository-family override, terminal +font, and font size. New-terminal affordances also expose installed shells and +Windows WSL distributions for a one-off tab choice. This is +separate from the external **Open in terminal** integration. PTY dimensions +must match the fitted renderer before startup and after resize so full-screen +alternate-screen tools can occupy the complete Work pane. The embedded terminal +advertises modern xterm/true-color capability and agent-specific compatibility +hints where they are inert for other commands; Claude Code opens its full +dashboard and alternate-screen renderer rather than the post-onboarding mini +logo. + #### When **"Local Changes"** is selected — staging workspace A three-section vertical layout with a commit form pinned to the bottom: @@ -128,12 +159,13 @@ This is the main workspace for committing — all staging, diff inspection, disc - Commit metadata (message, author, date, parents, sign status). - Changed files list + `@pierre/diffs` rendering. -#### When **a file is selected** (from the sidebar Files tree) — four tabs +#### When **a file is selected** (from the sidebar Files tree) — Work document modes -1. **Content** — file content at the current revision (working tree by default, or at the selected commit). Existing UTF-8 working-tree files are syntax-highlighted and editable; historical revisions, binaries, and oversized files are read-only. -2. **History** — commits that touched this file, with a per-commit diff preview. Click a commit to jump to it in the graph. -3. **Compare** — pick any two revisions (commits, branches, tags, or "working tree") and render the diff for this file using `@pierre/diffs`. -4. **Blame** — per-line author + commit attribution. Click a line to jump to the commit that introduced it. +1. **Content** — editable existing UTF-8 working-tree text, or read-only content at a selected revision. +2. **Preview** — safe rendered Markdown/SVG and image preview where supported. +3. **History** — commits that touched this file, with a per-commit diff preview. Click a commit to jump to it in the graph. +4. **Compare** — pick two revisions and render the file diff with `@pierre/diffs`. +5. **Blame** — per-line author + commit attribution. Click a line to jump to the commit that introduced it. If the file has uncommitted changes, the Content tab shows the working-tree version with a banner offering to switch to the unstaged diff. Tab state persists per-file across the session. @@ -216,19 +248,23 @@ Priority levels: **P0** = required for first public release, **P1** = required b | Git status badges (M/A/D/R/U) | P0 | Built into `@pierre/trees`. | | Folder descendant-changed indicator | P0 | Built-in. | | Search / filter by name | P0 | Built-in. | -| Four-tab file view (see below) | P0 | The main pane when a file is selected from the sidebar tree. | +| Work file documents (see below) | P0 | Preview/pinned tabs when a file is selected from Files. | | Drag-and-drop to move files | P1 | Renames tracked as Git renames. | | Custom right-click menu | P0 | Open in editor, copy path, history, blame, ignore, reveal in finder, etc. | | Compact / default / relaxed density | P1 | Pierre supports this; expose as a setting. | -**Four-tab file view.** Clicking a file in the sidebar tree opens it in the main pane with four tabs: +**Work file documents.** Files open in Work with Content, Preview (when +supported), History, Compare, and Blame modes. Existing complete UTF-8 working- +tree files support lightweight syntax-highlighted editing; historical revisions, +binaries, oversized files, and non-UTF-8 text stay read-only. -- **Content** — file content at the current revision (working tree by default, or at the selected commit). Existing UTF-8 working-tree files are syntax-highlighted and editable; historical revisions, binaries, and oversized files are read-only. +- **Content** — editable syntax-highlighted working-tree text or read-only content at a selected revision. +- **Preview** — rendered Markdown/SVG or an image/directory presentation where supported. - **History** — commits that touched this file, with per-commit diff previews. Click a commit to jump to it in the graph. - **Compare** — pick any two revisions (commits, branches, tags, or "working tree") and render the diff for this file using `@pierre/diffs`. - **Blame** — per-line author + commit attribution. Click a line to jump to the commit that introduced it. -Tab state persists per-file across the session so jumping back to a file restores the last-used view. +The inner mode persists per pinned Work tab during the session. File tabs do not restore after relaunch. ### 6.6 More features diff --git a/README.md b/README.md index c73326d..57c957b 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ shortcuts"). | `Mod+K` | Command palette | | `Mod+O` | Open repository | | `Mod+,` | Settings | -| `Mod+1…5` | Local Changes · All Commits · Reflog · Review · Worktrees | +| `Mod+1…7` | Work · Local Changes · All Commits · Reflog · Review · Worktrees · Workspace Review | | `Mod+Tab` / `Mod+Shift+Tab` | Next / previous repository | | `Mod+E` | Switch repository (quick-switcher) | | `Mod+P` | Push | @@ -42,7 +42,9 @@ shortcuts"). Surface-local keys (not rebindable, documented in Settings → Keyboard): `Mod+Enter` commit · `Mod+F` search in file or diff · `/` search commits · `j`/`k` walk the file list · `n`/`p` step change blocks · `Shift+J`/`Shift+K` scroll the -diff · palette `↑↓`/`↵`/`⇥`/`Esc`. +diff · Work tabs `Ctrl/⌘+PageUp`/`PageDown` or `←→`/`Home`/`End`/`Delete`, +with `F6` to leave a terminal · +palette `↑↓`/`↵`/`⇥`/`Esc`. Strand is a native, cross-platform Git client (Tauri 2 + Rust + React) with a dedicated surface for reviewing an agent's changes: whole-file-context @@ -54,7 +56,22 @@ keyboard alone, and the mouse stays first-class. ## Features -- **Review view (⌘4)** — read an agent's changes as whole files with the +- **Work view (⌘1)** — Strand's default startup workspace combines editable + working-tree file documents and embedded shells in one tab strip. Files get VS Code-style + preview/pin behavior and retain Content, rendered Preview, History, Compare, + Blame, image, and directory modes. Multiple terminals run at the repository + root and keep output, scrollback, and selection across view, repository, and + workspace switches, and full-screen terminal apps receive the fitted PTY + grid. Claude Code starts with its complete dashboard and alternate-screen + renderer in a configurable terminal font and size. Work tabs keep their width in a wheel-scrollable strip with an overflow + selector and tree-matched file icons. Only descriptors restore after + relaunch; selecting one starts a fresh process. The New Terminal split button + can launch a one-off native or WSL shell, while Settings → Terminal provides + a global default, paired repository and shell selectors for per-repository + overrides, and typography without changing the separate external + **Open in terminal** action. Linked worktrees share one override. + +- **Review view (⌘5)** — read an agent's changes as whole files with the edits inline, not isolated hunks. A file-tree queue tracks what you've reviewed, a pinnable baseline captures everything since a commit — including work the agent already staged or committed — and a change map @@ -117,7 +134,7 @@ keyboard alone, and the mouse stays first-class. builds recover the user's CLI `PATH` from the Unix login shell or persisted Windows environment, so package-manager and version-manager installs work without a custom path. Azure inline comments still need iteration tracking. -- **Worktrees (⌘5)** — an AI-agent dashboard for every worktree with stable +- **Worktrees (⌘6)** — an AI-agent dashboard for every worktree with stable repo naming, branch/session labels, dirty count, ±lines, "touched 3m ago" activity, disk size, ahead/behind, and one-click Review pinned where the branch diverged from main. Worktree tabs of one repo group together instead @@ -162,8 +179,8 @@ keyboard alone, and the mouse stays first-class. - **Command palette (⌘K)** — fuzzy search across commands, branches, tags, files, commits, and recent repos, with scope filtering and full keyboard + screen-reader operability. -- **File view** — edit existing UTF-8 working-tree files with syntax - highlighting, or inspect read-only historical source; `--follow` history, +- **Work file documents** — edit syntax-highlighted working-tree files or + inspect historical source read-only; `--follow` history, compare any two revisions, blame, and rendered previews for markdown and SVG; the Files tree uses the local filesystem listing directly, including muted Git-ignored paths, while overlaying current Git-state colors and recognizable language @@ -186,8 +203,9 @@ keyboard alone, and the mouse stays first-class. switching, and managing are all in ⌘K, including importing a VS Code `.code-workspace`), native desktop menus (global on macOS, in-window on Windows/Linux), open the repository or a chosen file in your editor, open a - terminal, settings (⌘,) for appearance / diff / - git / hosting / integrations / AI, in-app updates. + terminal, a configurable startup space, settings (⌘,) for appearance / + terminal / diff / git / hosting / integrations / AI, consistent + keyboard-native dropdowns, and in-app updates. - **AI commit messages** — suggest subject + body from staged changes (or all unstaged changes when nothing is staged) via your ChatGPT subscription (Codex CLI, `gpt-5.6-luna`) or Claude Code CLI diff --git a/ROADMAP.md b/ROADMAP.md index 0d13166..7b6c75e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2238,6 +2238,49 @@ Windows, macOS universal, Linux, updater-identity, notarization, Sigstore, and helper job, producing an 18-asset GitHub draft. It remains unpublished behind the same publisher, legal, updater-rehearsal, and real-platform gates as 1.0.0. +**Work view and embedded terminals shipped (2026-07-20):** Work is now the +startup destination and first numbered view. Its per-repository mixed tab strip +implements replaceable file previews, pinned documents with editable working- +tree Content, read-only revisions, per-tab Preview/History/Compare/Blame state, +and multiple xterm.js terminals. +The process-wide `portable-pty` runtime streams bounded ordered output through +Tauri channels, resolves direct shell argv against Strand's recovered desktop +PATH before applying the repository cwd, and tears down complete process trees +with Unix process groups or Windows Job Objects. Terminal renderers survive +view/repository/workspace switches; only dormant descriptors restore after +relaunch. Integrations now carries global and `common_dir` shell choices, and a +true final repository close confirms before draining terminals while workspace +hiding leaves them alive. The Work strip now preserves tab width with +wheel-scroll, active-tab reveal, a complete overflow selector, and Files-tree +icons. Embedded PTYs are fitted before launch and resynchronized after creation +and resize so full-screen alternate-screen agents occupy the complete pane; +`Ctrl/⌘+PageUp` and `Ctrl/⌘+PageDown` cycle peers without leaving the agent. +The terminal now has a dedicated Settings section with a live font preview, +configurable font family and 10–32px sizing while retaining complete JetBrains +Mono fallback glyphs. Its split new-terminal action can pin a one-off native +shell or an installed WSL distribution to the saved tab descriptor; the main +action continues to use the global/repository default. WSL discovery is lazy +and launch passes the repository through direct `--distribution`/`--cd` argv. +The terminal also supplies Claude Code's full-logo +and fullscreen-renderer compatibility environment, keeping the complete agent +dashboard visible from a repository-root shell after onboarding notices have +been consumed. + +**Terminal repository overrides and Windows PowerShell follow-up (2026-07-20):** +Settings → Terminal now pairs a repository selector with a shell selector so +every open repository family remains configurable without a long override +matrix; linked worktrees retain one shared override. Windows terminal +launch also removes canonical `\\?\` executable prefixes at the ConPTY boundary, +fixing Windows PowerShell 5.1's interactive .NET Framework initialization while +preserving normal profiles and shell behavior. The same custom-chevron native +`Select` now owns dropdown spacing and disabled presentation throughout +Settings, comparison views, rebase, branch networking, and worktree dialogs. + +**Configurable startup space shipped (2026-07-20):** Settings → Appearance now +lets users start in Work, Local Changes, Review, Pull Requests, or All Commits. +The synchronous settings store seeds the repository view during initialization, +so the choice survives relaunch without adding work to session restoration. + --- ## Cross-cutting tracks (run in parallel with all milestones) diff --git a/TASKS.md b/TASKS.md index f30f480..f4ae110 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1111,6 +1111,52 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- merge dialog's base picker; consider preferring the branch another worktree has checked out, or main-ish names, on exact rank ties. +### Work view + embedded terminals +- ☑ Work is the startup/sidebar/menu/palette destination; Files becomes the + active sidebar lens and numbered navigation is Work `Mod+1` through + Workspace Review `Mod+7` (`View`, `Sidebar`, `COMMANDS`, native View menu). +- ☑ Per-repository mixed `WorkTab` model with one replaceable preview, pin + promotion/deduplication, peer ordering, close fallback, path-mutation + reconciliation, and stable Work return IDs (`stores/work.ts`, + `lib/workTabs.ts`). File tabs remain session-only. +- ☑ Embeddable file documents retain working-tree editing plus Content/Preview/ + History/Compare/Blame/image/directory behavior while mounting only the active + file (`FileDocument`, `Work`); revisions remain read-only. Pierre activation + now carries exact row path/kind. +- ☑ xterm.js + Fit renderer layer with 5,000-line scrollback, roving tab focus, + F6 escape, fast peer cycling, fitted startup/resize PTY synchronization, + shell-control ownership, exited transcript, and Relaunch (`Work.tsx`). + Fixed-width tabs wheel-scroll, auto-reveal, expose an overflow selector, and + render `TreeFileIcon` symbols. Renderers survive view/repository/workspace + switches. Configurable terminal font/10–32px sizing with a live Settings + preview, complete JetBrains Mono fallback glyphs, and `configure_terminal_environment` + provides xterm/true-color capability and Claude Code full-dashboard/ + alternate-screen compatibility. +- ☑ `portable-pty` native runtime and Tauri channels: direct resolved argv, + recovered PATH, bounded ordered output/input/resize, per-session reader, + repository cwd/open validation, Unix process groups, Windows kill-on-close + Job Objects, and shutdown drain (`terminal.rs`, terminal IPC commands). +- ☑ Terminal descriptor restore without process start; global embedded-shell + default, `common_dir` override, platform presets/custom argv, and non-spawning + availability check in Settings → Terminal. External terminal integration + remains separate. +- ☑ Repository overrides are managed with paired repository and shell + selectors; linked worktrees deduplicate through their shared `common_dir` + (`TerminalSection`). The app-wide native dropdown treatment is shared by + every settings, compare, rebase, branch, and worktree selector (`Select`). +- ☑ Windows terminal executable paths drop canonical `\\?\` prefixes before + ConPTY launch so Windows PowerShell 5.1 can initialize .NET Framework + (`normalize_windows_program_path`, PTY startup regression test). +- ☑ Per-terminal shell split picker with persisted explicit choices and lazy + installed-WSL discovery on Windows; WSL launches the chosen distribution at + the repository through direct `wsl.exe --distribution … --cd …` argv + (`TerminalSection`, `NewTerminalButton`, `terminal_wsl_distributions`). +- ☑ Workspace-aware final close confirmation stops live terminals and clears + Work state/descriptors; non-final workspace removal and workspace hiding do + not stop them (`workspaces.ts`). +- ☐ Run interactive terminal/process-tree E2E on real macOS and Linux builds, + plus high-output and many-restored-descriptor performance baselines. + ### File view (4-tab) - ☑ Tab strip + header (opened via `selectFile` from the Files tab / palette; a Close action returns to Local Changes) @@ -1122,7 +1168,9 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- to unchanged text during every edit—there is no plain-text refresh frame—and reconstructs CRLF token streams without tripping the plain-text fallback. Writes are explicit only, via the disk save icon or Mod+S; blur and idle time - never save a draft. + never save a draft. Focus/watcher refreshes keep the mounted editor and token + map in place, and a refresh that finishes after typing starts cannot replace + the draft (`ContentTab` loaded-source/dirty guards). Mod+F searches the source with wrap-around match navigation and virtualized-line scrolling (`FileSearchBar` + `searchFileText`). - ☑ Preview tab — rendered view for renderable text files, tab only offered @@ -1183,7 +1231,9 @@ Detailed comparison and sequencing: [`docs/git-client-1.0-audit.md`](./docs/git- fill the pane width — - ☑ **Appearance**: theme + accent (moved from the old single-section dialog), plus the previously UI-less `density` / `uiFont` / `monoFont` store fields - (segmented `SegRow` + selects in `views/settings/shared.tsx`) + (segmented `SegRow` + selects in `views/settings/shared.tsx`), and a + persisted startup-space picker (`startupSpace`, applied when the repo store + initializes) - ☑ **Diff**: default layout (`defaultDiffLayout`, seeds repos without a per-repo `diff-mode:` row — `loadRepoDiffMode` falls back to it), diff font (`--diffs-font-family`, pierces Pierre's shadow DOM), change indicators diff --git a/crates/strand-tauri/Cargo.toml b/crates/strand-tauri/Cargo.toml index 2215a64..1e34274 100644 --- a/crates/strand-tauri/Cargo.toml +++ b/crates/strand-tauri/Cargo.toml @@ -47,6 +47,7 @@ tokio.workspace = true tracing.workspace = true tracing-subscriber = { version = "0.3", features = ["env-filter"] } tempfile = "3" +portable-pty = "0.9" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index 47df794..2d3a4e8 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -65,7 +65,63 @@ impl From for CmdError { } } -type CmdResult = std::result::Result; +pub(crate) type CmdResult = std::result::Result; + +#[tauri::command(async)] +pub fn repo_terminal_create( + path: String, + shell: crate::terminal::EmbeddedShellChoice, + cols: u16, + rows: u16, + on_event: Channel, + state: State<'_, AppState>, +) -> CmdResult { + let is_open = state + .open_paths + .lock() + .map_err(|_| CmdError { message: "open repository registry poisoned".into() })? + .contains(&path); + if !is_open { + return Err(CmdError { message: "embedded terminals require an open repository".into() }); + } + state.terminals.create(path, shell, cols, rows, on_event) +} + +#[tauri::command(async)] +pub fn terminal_write(id: String, data: String, state: State<'_, AppState>) -> CmdResult<()> { + state.terminals.write(&id, &data) +} + +#[tauri::command(async)] +pub fn terminal_resize(id: String, cols: u16, rows: u16, state: State<'_, AppState>) -> CmdResult<()> { + state.terminals.resize(&id, cols, rows) +} + +#[tauri::command(async)] +pub fn terminal_close(id: String, state: State<'_, AppState>) -> CmdResult<()> { + state.terminals.close(&id) +} + +#[tauri::command(async)] +pub fn repo_terminal_close_all(path: String, state: State<'_, AppState>) -> CmdResult<()> { + state.terminals.close_all(Some(&path)); + Ok(()) +} + +#[tauri::command(async)] +pub fn repo_terminal_count(path: String, state: State<'_, AppState>) -> usize { + state.terminals.count(&path) +} + +#[tauri::command(async)] +pub fn terminal_shell_check(shell: crate::terminal::EmbeddedShellChoice) -> crate::terminal::ShellCheck { + crate::terminal::shell_check(shell) +} + +#[tauri::command(async)] +pub fn terminal_wsl_distributions() -> Vec { + crate::terminal::wsl_distributions() +} /// Run CPU/disk-bound work on tokio's blocking pool (see the module comment /// for why repo-size-scaled reads don't stay sync). `label` names the op in @@ -147,6 +203,9 @@ pub fn repo_unwatch(path: String, state: State<'_, AppState>) -> CmdResult<()> { if let Ok(mut watchers) = state.watchers.lock() { watchers.remove(&path); } + if let Ok(mut paths) = state.open_paths.lock() { + paths.remove(&path); + } Ok(()) } diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index 944ab77..ae0c386 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -7,6 +7,7 @@ mod hosting; mod path_env; mod pull_requests; mod state; +mod terminal; use tauri::Manager; @@ -72,6 +73,14 @@ fn main() { .manage(state::AppState::default()) .invoke_handler(tauri::generate_handler![ commands::repo_open, + commands::repo_terminal_create, + commands::terminal_write, + commands::terminal_resize, + commands::terminal_close, + commands::repo_terminal_close_all, + commands::repo_terminal_count, + commands::terminal_shell_check, + commands::terminal_wsl_distributions, commands::repo_init, commands::repo_meta, commands::repo_status, @@ -258,6 +267,11 @@ fn main() { } Ok(()) }) - .run(tauri::generate_context!()) - .expect("error while running strand"); + .build(tauri::generate_context!()) + .expect("error while building strand") + .run(|app, event| { + if matches!(event, tauri::RunEvent::Exit | tauri::RunEvent::ExitRequested { .. }) { + app.state::().terminals.close_all(None); + } + }); } diff --git a/crates/strand-tauri/src/state.rs b/crates/strand-tauri/src/state.rs index cead4e8..39b8b07 100644 --- a/crates/strand-tauri/src/state.rs +++ b/crates/strand-tauri/src/state.rs @@ -22,6 +22,9 @@ pub struct AppState { /// In-flight cancellable ops (clone/fetch/pull/push), keyed by the /// frontend-generated op id. pub ops: Mutex>, + /// Live embedded PTYs. Their ownership is process-wide so switching views, + /// repositories, or workspaces never tears down a shell. + pub terminals: crate::terminal::TerminalManager, } #[derive(Clone)] diff --git a/crates/strand-tauri/src/terminal.rs b/crates/strand-tauri/src/terminal.rs new file mode 100644 index 0000000..5a7367a --- /dev/null +++ b/crates/strand-tauri/src/terminal.rs @@ -0,0 +1,968 @@ +//! Embedded terminal runtime. +//! +//! Shells are resolved to absolute executables before the repository becomes +//! their cwd. Each live PTY is process-owned (never component-owned), so the +//! frontend can keep an xterm renderer mounted while Strand switches views or +//! repositories without interrupting the process. + +use std::{ + collections::HashMap, + io::{Read, Write}, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, +}; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize}; +use serde::{Deserialize, Serialize}; +use tauri::ipc::Channel; +use uuid::Uuid; + +use crate::{ + ai::bin::resolve_cli, + commands::{CmdError, CmdResult}, + path_env, +}; + +const MAX_INPUT_BYTES: usize = 64 * 1024; +const MAX_DIMENSION: u16 = 1_000; +const READ_CHUNK_BYTES: usize = 8 * 1024; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum EmbeddedShellChoice { + System, + Preset { id: String }, + Wsl { distribution: String }, + Custom { command: String }, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalHandle { + pub id: String, + pub label: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ShellCheck { + pub available: bool, + pub label: String, + pub executable: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum TerminalEvent { + Output { data: String }, + Exit { code: u32 }, + Error { message: String }, +} + +struct ResolvedShell { + program: PathBuf, + args: Vec, + label: String, +} + +struct TerminalSession { + repo_path: String, + master: Mutex>>, + writer: Mutex>>, + killer: Mutex>, + closed: AtomicBool, + #[cfg(unix)] + process_group: Option, + #[cfg(windows)] + _job: WindowsJob, +} + +#[derive(Clone, Default)] +pub struct TerminalManager { + sessions: Arc>>>, +} + +impl TerminalManager { + pub fn create( + &self, + repo_path: String, + shell: EmbeddedShellChoice, + cols: u16, + rows: u16, + on_event: Channel, + ) -> CmdResult { + validate_size(cols, rows)?; + let resolved = resolve_shell(&shell, Some(Path::new(&repo_path)))?; + let pty = native_pty_system() + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| cmd_err(format!("could not create terminal: {e}")))?; + + let mut command = CommandBuilder::new(&resolved.program); + command.args(&resolved.args); + command.cwd(Path::new(&repo_path)); + configure_terminal_environment(&mut command); + if let Some(path) = path_env::effective_path() { + command.env("PATH", path); + } + + let mut child = pty + .slave + .spawn_command(command) + .map_err(|e| cmd_err(format!("could not start {}: {e}", resolved.label)))?; + drop(pty.slave); + + #[cfg(unix)] + let process_group = pty.master.process_group_leader(); + #[cfg(windows)] + let job = WindowsJob::assign(&*child).map_err(|e| { + let _ = child.kill(); + cmd_err(format!("could not secure terminal process tree: {e}")) + })?; + + let reader = pty + .master + .try_clone_reader() + .map_err(|e| cmd_err(format!("could not read terminal output: {e}")))?; + let writer = pty + .master + .take_writer() + .map_err(|e| cmd_err(format!("could not open terminal input: {e}")))?; + let killer = child.clone_killer(); + let id = Uuid::new_v4().to_string(); + let session = Arc::new(TerminalSession { + repo_path, + master: Mutex::new(Some(pty.master)), + writer: Mutex::new(Some(writer)), + killer: Mutex::new(killer), + closed: AtomicBool::new(false), + #[cfg(unix)] + process_group, + #[cfg(windows)] + _job: job, + }); + self.sessions + .lock() + .map_err(|_| cmd_err("terminal registry poisoned"))? + .insert(id.clone(), Arc::clone(&session)); + + let sessions = Arc::clone(&self.sessions); + let thread_id = id.clone(); + std::thread::Builder::new() + .name(format!("strand-terminal-{}", &id[..8])) + .spawn(move || terminal_reader(thread_id, session, sessions, reader, child, on_event)) + .map_err(|e| { + self.close(&id).ok(); + cmd_err(format!("could not start terminal reader: {e}")) + })?; + + Ok(TerminalHandle { + id, + label: resolved.label, + }) + } + + pub fn write(&self, id: &str, data: &str) -> CmdResult<()> { + if data.len() > MAX_INPUT_BYTES { + return Err(cmd_err(format!( + "terminal input exceeds {MAX_INPUT_BYTES} bytes" + ))); + } + let session = self.session(id)?; + let mut writer = session + .writer + .lock() + .map_err(|_| cmd_err("terminal input poisoned"))?; + let writer = writer + .as_mut() + .ok_or_else(|| cmd_err("terminal is not running"))?; + writer + .write_all(data.as_bytes()) + .and_then(|_| writer.flush()) + .map_err(|e| cmd_err(format!("terminal write failed: {e}"))) + } + + pub fn resize(&self, id: &str, cols: u16, rows: u16) -> CmdResult<()> { + validate_size(cols, rows)?; + let session = self.session(id)?; + let result = session + .master + .lock() + .map_err(|_| cmd_err("terminal resize poisoned"))? + .as_ref() + .ok_or_else(|| cmd_err("terminal is not running"))? + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| cmd_err(format!("terminal resize failed: {e}"))); + result + } + + pub fn close(&self, id: &str) -> CmdResult<()> { + let session = self + .sessions + .lock() + .map_err(|_| cmd_err("terminal registry poisoned"))? + .remove(id); + if let Some(session) = session { + terminate(&session); + } + Ok(()) + } + + pub fn close_all(&self, repo_path: Option<&str>) -> usize { + let sessions = match self.sessions.lock() { + Ok(mut all) => { + let ids: Vec<_> = all + .iter() + .filter(|(_, session)| repo_path.is_none_or(|path| session.repo_path == path)) + .map(|(id, _)| id.clone()) + .collect(); + ids.into_iter() + .filter_map(|id| all.remove(&id)) + .collect::>() + } + Err(_) => return 0, + }; + let count = sessions.len(); + for session in sessions { + terminate(&session); + } + count + } + + pub fn count(&self, repo_path: &str) -> usize { + self.sessions + .lock() + .map(|all| { + all.values() + .filter(|session| session.repo_path == repo_path) + .count() + }) + .unwrap_or(0) + } + + fn session(&self, id: &str) -> CmdResult> { + self.sessions + .lock() + .map_err(|_| cmd_err("terminal registry poisoned"))? + .get(id) + .cloned() + .ok_or_else(|| cmd_err("terminal is not running")) + } +} + +fn terminal_reader( + id: String, + session: Arc, + sessions: Arc>>>, + mut reader: Box, + mut child: Box, + on_event: Channel, +) { + enum RuntimeEvent { + Output(Vec), + ReaderDone(Option), + Exit(Result), + } + + // Reading and waiting must be independent: on Windows ConPTY keeps its + // output pipe open while Strand owns the pseudoconsole. A small bounded + // fan-in lets us forward chunks continuously and drain buffered output + // before the exit event without allowing unbounded native buffering. + let (send, receive) = std::sync::mpsc::sync_channel(64); + let output_send = send.clone(); + std::thread::spawn(move || { + let mut chunk = [0u8; READ_CHUNK_BYTES]; + loop { + match reader.read(&mut chunk) { + Ok(0) => { + let _ = output_send.send(RuntimeEvent::ReaderDone(None)); + return; + } + Ok(read) => { + if output_send + .send(RuntimeEvent::Output(chunk[..read].to_vec())) + .is_err() + { + return; + } + } + Err(error) => { + let _ = output_send.send(RuntimeEvent::ReaderDone(Some(error.to_string()))); + return; + } + } + } + }); + let wait_session = Arc::clone(&session); + std::thread::spawn(move || { + let result = child + .wait() + .map(|status| status.exit_code()) + .map_err(|error| error.to_string()); + let _ = send.send(RuntimeEvent::Exit(result)); + close_pty_handles(&wait_session); + }); + + let mut exit_result = None; + let mut reader_done = None; + while let Ok(event) = receive.recv() { + match event { + RuntimeEvent::Output(bytes) => { + let _ = on_event.send(TerminalEvent::Output { + data: BASE64.encode(bytes), + }); + } + RuntimeEvent::ReaderDone(error) => reader_done = Some(error), + RuntimeEvent::Exit(result) => exit_result = Some(result), + } + if let (Some(result), Some(_)) = (&exit_result, &reader_done) { + if !session.closed.load(Ordering::Acquire) { + match result { + Ok(code) => { + let _ = on_event.send(TerminalEvent::Exit { code: *code }); + } + Err(message) => { + let _ = on_event.send(TerminalEvent::Error { + message: message.clone(), + }); + } + } + } + break; + } + } + if let Ok(mut all) = sessions.lock() { + all.remove(&id); + } +} + +fn terminate(session: &TerminalSession) { + if session.closed.swap(true, Ordering::AcqRel) { + return; + } + #[cfg(unix)] + if let Some(group) = session.process_group { + // SAFETY: portable-pty reports the session/process-group leader for + // this PTY. A negative pid targets the complete group, including + // children launched by the interactive shell. + unsafe { libc::kill(-group, libc::SIGKILL) }; + } + if let Ok(mut killer) = session.killer.lock() { + let _ = killer.kill(); + } + close_terminal_input(session); + // On Windows dropping `_job` after `KILL_ON_JOB_CLOSE` terminates every + // process the shell added to its job, even if the shell spawned children. +} + +fn close_terminal_input(session: &TerminalSession) { + if let Ok(mut writer) = session.writer.lock() { + writer.take(); + } +} + +fn close_pty_handles(session: &TerminalSession) { + close_terminal_input(session); + if let Ok(mut master) = session.master.lock() { + master.take(); + } +} + +fn validate_size(cols: u16, rows: u16) -> CmdResult<()> { + if cols == 0 || rows == 0 || cols > MAX_DIMENSION || rows > MAX_DIMENSION { + return Err(cmd_err(format!( + "terminal size must be between 1 and {MAX_DIMENSION} columns/rows" + ))); + } + Ok(()) +} + +fn configure_terminal_environment(command: &mut CommandBuilder) { + command.env("TERM", "xterm-256color"); + command.env("COLORTERM", "truecolor"); + command.env("TERM_PROGRAM", "Strand"); + command.env("TERM_PROGRAM_VERSION", env!("CARGO_PKG_VERSION")); + + // Claude Code normally collapses its welcome dashboard after its setup + // tips and release notes have been seen. Strand is primarily an agent + // workspace, so keep the complete dashboard visible and opt into Claude's + // alternate-screen renderer. These variables are inert for other shells + // and CLI agents. + command.env("CLAUDE_CODE_FORCE_FULL_LOGO", "1"); + command.env("CLAUDE_CODE_NO_FLICKER", "1"); +} + +fn resolve_shell(choice: &EmbeddedShellChoice, cwd: Option<&Path>) -> CmdResult { + let (program, args, label) = match choice { + EmbeddedShellChoice::System => return resolve_system_shell(), + EmbeddedShellChoice::Preset { id } => { + let (program, label) = + preset(id).ok_or_else(|| cmd_err("unknown embedded shell preset"))?; + (program.to_string(), Vec::new(), label.to_string()) + } + EmbeddedShellChoice::Custom { command } => { + let argv = strand_core::external::build_argv(command, &[]).map_err(CmdError::from)?; + let (program, args) = argv + .split_first() + .ok_or_else(|| cmd_err("custom shell command is empty"))?; + (program.clone(), args.to_vec(), shell_label(program)) + } + #[cfg(windows)] + EmbeddedShellChoice::Wsl { distribution } => { + let distribution = distribution.trim(); + if distribution.is_empty() { + return Err(cmd_err("WSL distribution is empty")); + } + let wsl = resolve_wsl_program() + .ok_or_else(|| cmd_err("Windows Subsystem for Linux was not found"))?; + let mut args = vec!["--distribution".into(), distribution.into()]; + if let Some(cwd) = cwd { + args.extend(["--cd".into(), cwd.to_string_lossy().into_owned()]); + } + ( + wsl.to_string_lossy().into_owned(), + args, + format!("WSL · {distribution}"), + ) + } + #[cfg(not(windows))] + EmbeddedShellChoice::Wsl { .. } => { + return Err(cmd_err("WSL terminals are only available on Windows")); + } + }; + resolved(program, args, label) +} + +/// Installed WSL distributions suitable for an interactive terminal. WSL +/// writes redirected list output as UTF-16LE, even when the desktop locale is +/// UTF-8, so decode the bytes explicitly instead of treating them as UTF-8. +#[cfg(windows)] +pub fn wsl_distributions() -> Vec { + let Some(wsl) = resolve_wsl_program() else { + return Vec::new(); + }; + let Ok(output) = std::process::Command::new(wsl) + .args(["--list", "--quiet"]) + .output() + else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + let mut distributions = Vec::new(); + for name in decode_wsl_output(&output.stdout).lines().map(str::trim) { + if name.is_empty() + || name.eq_ignore_ascii_case("docker-desktop") + || name.eq_ignore_ascii_case("docker-desktop-data") + || distributions + .iter() + .any(|existing: &String| existing.eq_ignore_ascii_case(name)) + { + continue; + } + distributions.push(name.to_string()); + } + distributions +} + +#[cfg(windows)] +fn resolve_wsl_program() -> Option { + std::env::var_os("SystemRoot") + .map(PathBuf::from) + .map(|root| root.join("System32").join("wsl.exe")) + .and_then(|path| resolve_cli("wsl", path.to_str())) + .or_else(|| resolve_cli("wsl", None)) + .map(normalize_windows_program_path) +} + +#[cfg(not(windows))] +pub fn wsl_distributions() -> Vec { + Vec::new() +} + +fn decode_wsl_output(bytes: &[u8]) -> String { + let looks_utf16 = bytes.starts_with(&[0xff, 0xfe]) + || (bytes.len() >= 2 + && bytes.len() % 2 == 0 + && bytes.chunks_exact(2).filter(|pair| pair[1] == 0).count() > bytes.len() / 8); + if !looks_utf16 { + return String::from_utf8_lossy(bytes).into_owned(); + } + let words = bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .skip_while(|word| *word == 0xfeff); + char::decode_utf16(words) + .map(|character| character.unwrap_or(char::REPLACEMENT_CHARACTER)) + .collect() +} + +fn resolve_system_shell() -> CmdResult { + #[cfg(windows)] + let candidates = [ + ("pwsh", "PowerShell 7"), + ("powershell", "Windows PowerShell"), + ("cmd", "Command Prompt"), + ]; + #[cfg(unix)] + let candidates = [("sh", "System shell")]; + + #[cfg(unix)] + if let Some(shell) = std::env::var_os("SHELL").filter(|s| !s.is_empty()) { + let display = shell.to_string_lossy().into_owned(); + if let Some(program) = resolve_program(&display) { + return Ok(ResolvedShell { + label: shell_label(&display), + program, + args: Vec::new(), + }); + } + } + for (program, label) in candidates { + if let Some(program) = resolve_program(program) { + return Ok(ResolvedShell { + program, + args: Vec::new(), + label: label.into(), + }); + } + } + Err(cmd_err("no supported system shell was found")) +} + +fn resolved(program: String, args: Vec, label: String) -> CmdResult { + let executable = resolve_program(&program) + .ok_or_else(|| cmd_err(format!("embedded shell `{program}` was not found")))?; + #[cfg(windows)] + if executable + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat")) + { + return Err(cmd_err("batch files cannot be used as embedded shells")); + } + Ok(ResolvedShell { + program: executable, + args, + label, + }) +} + +fn resolve_program(program: &str) -> Option { + let path = Path::new(program); + let resolved = if path.is_absolute() || path.components().count() > 1 { + resolve_cli(program, Some(program)) + } else { + resolve_cli(program, None) + }; + #[cfg(windows)] + return resolved.map(normalize_windows_program_path); + #[cfg(not(windows))] + resolved +} + +/// `std::fs::canonicalize` produces a `\\?\` path on Windows. Modern +/// executables accept that form, but Windows PowerShell 5.1 passes its own +/// executable path into .NET Framework during interactive startup, where the +/// verbatim prefix can break configuration loading and abort the shell. +#[cfg(windows)] +fn normalize_windows_program_path(path: PathBuf) -> PathBuf { + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + + const VERBATIM: &[u16] = &[b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16]; + const VERBATIM_UNC: &[u16] = &[ + b'\\' as u16, + b'\\' as u16, + b'?' as u16, + b'\\' as u16, + b'U' as u16, + b'N' as u16, + b'C' as u16, + b'\\' as u16, + ]; + let encoded = path.as_os_str().encode_wide().collect::>(); + if encoded.starts_with(VERBATIM_UNC) { + let mut normalized = vec![b'\\' as u16, b'\\' as u16]; + normalized.extend_from_slice(&encoded[VERBATIM_UNC.len()..]); + return PathBuf::from(std::ffi::OsString::from_wide(&normalized)); + } + if encoded.starts_with(VERBATIM) { + return PathBuf::from(std::ffi::OsString::from_wide(&encoded[VERBATIM.len()..])); + } + path +} + +fn shell_label(program: &str) -> String { + Path::new(program) + .file_stem() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("Terminal") + .to_string() +} + +fn preset(id: &str) -> Option<(&'static str, &'static str)> { + match id { + #[cfg(windows)] + "pwsh" => Some(("pwsh", "PowerShell 7")), + #[cfg(windows)] + "powershell" => Some(("powershell", "Windows PowerShell")), + #[cfg(windows)] + "cmd" => Some(("cmd", "Command Prompt")), + #[cfg(unix)] + "zsh" => Some(("zsh", "zsh")), + #[cfg(unix)] + "bash" => Some(("bash", "bash")), + #[cfg(unix)] + "fish" => Some(("fish", "fish")), + #[cfg(unix)] + "sh" => Some(("sh", "sh")), + _ => None, + } +} + +pub fn shell_check(choice: EmbeddedShellChoice) -> ShellCheck { + #[cfg(windows)] + if let EmbeddedShellChoice::Wsl { distribution } = &choice { + if !wsl_distributions() + .iter() + .any(|installed| installed.eq_ignore_ascii_case(distribution)) + { + return ShellCheck { + available: false, + label: format!("WSL · {distribution}"), + executable: resolve_wsl_program().map(|path| path.to_string_lossy().into_owned()), + error: Some(format!( + "WSL distribution `{distribution}` is not installed" + )), + }; + } + } + match resolve_shell(&choice, None) { + Ok(shell) => ShellCheck { + available: true, + label: shell.label, + executable: Some(shell.program.to_string_lossy().into_owned()), + error: None, + }, + Err(error) => ShellCheck { + available: false, + label: match choice { + EmbeddedShellChoice::System => "System default".into(), + EmbeddedShellChoice::Preset { id } => { + preset(&id).map(|p| p.1).unwrap_or("Shell").into() + } + EmbeddedShellChoice::Wsl { distribution } => format!("WSL · {distribution}"), + EmbeddedShellChoice::Custom { ref command } => shell_label(command), + }, + executable: None, + error: Some(error.message), + }, + } +} + +fn cmd_err(message: impl Into) -> CmdError { + CmdError { + message: message.into(), + } +} + +#[cfg(windows)] +struct WindowsJob(windows_sys::Win32::Foundation::HANDLE); + +#[cfg(windows)] +unsafe impl Send for WindowsJob {} +#[cfg(windows)] +unsafe impl Sync for WindowsJob {} + +#[cfg(windows)] +impl WindowsJob { + fn assign(child: &dyn portable_pty::Child) -> Result { + use windows_sys::Win32::{ + Foundation::{CloseHandle, INVALID_HANDLE_VALUE}, + System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }, + }; + let process = child + .as_raw_handle() + .ok_or_else(|| std::io::Error::other("terminal process handle unavailable"))?; + // SAFETY: all pointers are either null or point to initialized Win32 + // structs for the documented duration of each call. + unsafe { + let job = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + if job.is_null() || job == INVALID_HANDLE_VALUE { + return Err(std::io::Error::last_os_error()); + } + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + (&info as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + std::mem::size_of::() as u32, + ) == 0 + || AssignProcessToJobObject(job, process.cast()) == 0 + { + let error = std::io::Error::last_os_error(); + CloseHandle(job); + return Err(error); + } + Ok(Self(job)) + } + } +} + +#[cfg(windows)] +impl Drop for WindowsJob { + fn drop(&mut self) { + // SAFETY: the handle is owned by this guard and closed exactly once. + unsafe { windows_sys::Win32::Foundation::CloseHandle(self.0) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_empty_and_oversized_terminal_dimensions() { + assert!(validate_size(0, 24).is_err()); + assert!(validate_size(80, 0).is_err()); + assert!(validate_size(1_001, 24).is_err()); + assert!(validate_size(80, 24).is_ok()); + } + + #[test] + fn terminal_environment_advertises_full_agent_capabilities() { + let mut command = CommandBuilder::new("shell"); + configure_terminal_environment(&mut command); + assert_eq!( + command.get_env("TERM"), + Some(std::ffi::OsStr::new("xterm-256color")) + ); + assert_eq!( + command.get_env("COLORTERM"), + Some(std::ffi::OsStr::new("truecolor")) + ); + assert_eq!( + command.get_env("TERM_PROGRAM"), + Some(std::ffi::OsStr::new("Strand")) + ); + assert_eq!( + command.get_env("CLAUDE_CODE_FORCE_FULL_LOGO"), + Some(std::ffi::OsStr::new("1")) + ); + assert_eq!( + command.get_env("CLAUDE_CODE_NO_FLICKER"), + Some(std::ffi::OsStr::new("1")) + ); + } + + #[test] + fn custom_shell_is_tokenized_without_an_intermediary_shell() { + let choice = EmbeddedShellChoice::Custom { + command: "sh -l".into(), + }; + #[cfg(unix)] + { + let resolved = resolve_shell(&choice, None).unwrap(); + assert_eq!(resolved.args, ["-l"]); + assert!(resolved.program.is_absolute()); + } + #[cfg(windows)] + assert!(resolve_shell(&choice, None).is_err()); + } + + #[test] + fn pty_streams_ordered_output_then_exit() { + let dir = tempfile::tempdir().unwrap(); + #[cfg(windows)] + let command = format!( + "\"{}\" /D /C echo strand-terminal", + std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".into()), + ); + #[cfg(unix)] + let command = "/bin/sh -c 'printf strand-terminal'".to_string(); + let (send, receive) = std::sync::mpsc::channel(); + let channel = Channel::new(move |body| { + if let tauri::ipc::InvokeResponseBody::Json(json) = body { + if let Ok(event) = serde_json::from_str::(&json) { + let _ = send.send(event); + } + } + Ok(()) + }); + let manager = TerminalManager::default(); + let handle = manager + .create( + dir.path().to_string_lossy().into_owned(), + EmbeddedShellChoice::Custom { command }, + 80, + 24, + channel, + ) + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut output = Vec::new(); + let mut exited = false; + while std::time::Instant::now() < deadline && !exited { + match receive.recv_timeout(std::time::Duration::from_millis(250)) { + Ok(TerminalEvent::Output { data }) => { + let bytes = BASE64.decode(data).unwrap(); + if bytes.windows(3).any(|window| window == b"[6n") { + manager.write(&handle.id, "\u{1b}[1;1R").unwrap(); + } + output.extend(bytes); + } + Ok(TerminalEvent::Exit { code }) => { + assert_eq!(code, 0); + exited = true; + } + Ok(TerminalEvent::Error { message }) => panic!("terminal error: {message}"), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(error) => panic!("terminal channel failed: {error}"), + } + } + if !exited { + panic!( + "terminal did not exit before the deadline; output={:?}", + String::from_utf8_lossy(&output) + ); + } + assert!(String::from_utf8_lossy(&output).contains("strand-terminal")); + assert_eq!(manager.count(&dir.path().to_string_lossy()), 0); + manager.close(&handle.id).unwrap(); // natural exit made close idempotent + } + + #[cfg(windows)] + #[test] + fn windows_powershell_preset_starts_in_a_pty() { + let dir = tempfile::tempdir().unwrap(); + let (send, receive) = std::sync::mpsc::channel(); + let channel = Channel::new(move |body| { + if let tauri::ipc::InvokeResponseBody::Json(json) = body { + if let Ok(event) = serde_json::from_str::(&json) { + let _ = send.send(event); + } + } + Ok(()) + }); + let manager = TerminalManager::default(); + let handle = manager + .create( + dir.path().to_string_lossy().into_owned(), + EmbeddedShellChoice::Preset { + id: "powershell".into(), + }, + 80, + 24, + channel, + ) + .unwrap(); + manager + .write(&handle.id, "Write-Output strand-powershell; exit\r\n") + .unwrap(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8); + let mut output = Vec::new(); + let mut exited = false; + while std::time::Instant::now() < deadline && !exited { + match receive.recv_timeout(std::time::Duration::from_millis(250)) { + Ok(TerminalEvent::Output { data }) => { + let bytes = BASE64.decode(data).unwrap(); + if bytes.windows(3).any(|window| window == b"[6n") { + manager.write(&handle.id, "\u{1b}[1;1R").unwrap(); + } + output.extend(bytes); + } + Ok(TerminalEvent::Exit { code }) => { + assert_eq!(code, 0, "output={:?}", String::from_utf8_lossy(&output)); + exited = true; + } + Ok(TerminalEvent::Error { message }) => panic!("terminal error: {message}"), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(error) => panic!("terminal channel failed: {error}"), + } + } + if !exited { + manager.close(&handle.id).unwrap(); + panic!( + "Windows PowerShell did not exit before the deadline; output={:?}", + String::from_utf8_lossy(&output) + ); + } + assert!( + String::from_utf8_lossy(&output).contains("strand-powershell"), + "output={:?}", + String::from_utf8_lossy(&output) + ); + } + + #[cfg(windows)] + #[test] + fn windows_terminal_program_paths_drop_verbatim_prefixes() { + assert_eq!( + normalize_windows_program_path(PathBuf::from(r"\\?\C:\Windows\shell.exe")), + PathBuf::from(r"C:\Windows\shell.exe") + ); + assert_eq!( + normalize_windows_program_path(PathBuf::from(r"\\?\UNC\server\share\shell.exe")), + PathBuf::from(r"\\server\share\shell.exe") + ); + } + + #[cfg(windows)] + #[test] + fn batch_launchers_are_rejected_as_embedded_shells() { + let dir = tempfile::tempdir().unwrap(); + let launcher = dir.path().join("shell.cmd"); + std::fs::write(&launcher, "@echo off\r\n").unwrap(); + let choice = EmbeddedShellChoice::Custom { + command: format!("\"{}\"", launcher.display()), + }; + assert!(resolve_shell(&choice, None).is_err()); + } + + #[test] + fn decodes_wsl_utf16_output_without_a_bom() { + let bytes = "Ubuntu\r\ndocker-desktop\r\n" + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + assert_eq!(decode_wsl_output(&bytes), "Ubuntu\r\ndocker-desktop\r\n"); + } + + #[cfg(windows)] + #[test] + fn wsl_choice_uses_direct_distribution_and_repository_arguments() { + let resolved = resolve_shell( + &EmbeddedShellChoice::Wsl { + distribution: "Ubuntu".into(), + }, + Some(Path::new(r"D:\GitSources\strand")), + ) + .unwrap(); + assert_eq!(resolved.label, "WSL · Ubuntu"); + assert_eq!( + resolved.args, + ["--distribution", "Ubuntu", "--cd", r"D:\GitSources\strand"] + ); + } +} diff --git a/docs/learnings.md b/docs/learnings.md index 38fd865..2b6ef39 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -522,8 +522,11 @@ loses its typography offline or behind a firewall, and `display=swap` reflows **How to apply.** `ui/public/fonts/fonts.css` mirrors Google's exact `@font-face` blocks for the **latin + latin-ext** subsets only (cyrillic/greek/ vietnamese are dropped — this is an English-language dev tool), with `src` -rewritten to local `/fonts/*.woff2`. Files in `ui/public/` are copied to `dist` -verbatim by Vite and served at the root. To add/refresh a family: fetch the +rewritten to local `/fonts/*.woff2`. The embedded terminal is the deliberate +exception: its `JetBrains Mono Terminal` variable faces must remain complete +because terminal TUIs depend on box-drawing and block glyphs outside the Latin +subsets. Files in `ui/public/` are copied to `dist` verbatim by Vite and served +at the root. To add/refresh a family: fetch the Google CSS with a modern-browser UA, keep only the latin/latin-ext blocks, download those woff2 (via `curl` — system CA), rewrite `src`, and commit the binaries (they're not git-ignored; `dist/` is). The user font picker @@ -1843,7 +1846,10 @@ oversized/binary content, and preserve a consistently-CRLF file's line endings after textarea normalization. Commit/revision content remains immutable. Keep the last valid token map projected onto the current buffer while an async syntax refresh is pending; never replace a highlighted editor with plain text -merely because its token result is one input behind. +merely because its token result is one input behind. The same rule applies to +focus/watcher refetches: keep the loaded editor mounted while the read is in +flight, then update its buffer in place so the existing token projection +survives. Reserve the empty loading surface for the initial file/source load. Web textareas and Shiki expose LF line boundaries even when a Windows checkout is CRLF. Normalize the editable in-memory buffer to LF, keep the raw last-read text separately for optimistic writes, and reconstruct token streams with the @@ -1859,3 +1865,64 @@ batched path; on Windows only, retry that exact error through system Git's `checkout-index --force -- ` with `-c core.longpaths=true`. Do not treat other checkout or filesystem errors as fallback candidates, and keep the pathspec after `--` so a repository filename cannot become an option. + +**Embedded terminals are process-owned; only their descriptors persist +(2026-07-20).** Keep PTY runtimes in process-wide native state and xterm +renderers mounted across view, repository, and workspace switches. Persist +descriptors, never processes or scrollback; restore them dormant and unselected, +then launch only after explicit activation. Resolve the executable and recovered +PATH before applying the repository cwd, pass custom commands as direct argv, +and terminate the full process group/job on tab close or app exit. Removing one +workspace owner or hiding a workspace must not stop a repository terminal; a +final repository close must confirm first. While xterm owns focus, preserve +shell controls, keep Command shortcuts app-owned on macOS and only numbered +view navigation plus the fixed Work `Ctrl+PageUp`/`Ctrl+PageDown` peer cycle +app-owned on Windows/Linux, with `F6` returning focus to the peer Work tab +strip. Fit xterm before creating the PTY and explicitly synchronize the native +grid again once its runtime ID exists; an observer can fire during async startup +and otherwise leave a full-screen alternate-screen app on the default 80x24 +grid. Claude Code deliberately replaces its complete welcome dashboard with a +mini logo after setup warnings and release notes have been consumed; this is +application state, not an xterm rendering failure. Strand's agent-first terminal +sets `CLAUDE_CODE_FORCE_FULL_LOGO=1` and the supported +`CLAUDE_CODE_NO_FLICKER=1` alternate-screen switch in +`configure_terminal_environment`; keep such compatibility variables inert for +other shells and cover them as a single tested environment contract. Mount only +the active file document so terminal continuity does not turn inactive file tabs +into a background rendering cost. + +**Terminal defaults and explicit shell choices have different lifetimes +(2026-07-20).** The primary New Terminal action follows the repository/global +default at process start; a shell chosen from its split menu is stored in that +tab's descriptor and must survive relaunch/restore independently of later +default changes. Discover installed WSL distributions lazily, decode redirected +`wsl.exe --list --quiet` output as UTF-16LE on Windows, and pass distro plus +Windows repository path through direct `--distribution`/`--cd` argv. Typography +changes update existing xterm options and then refit/resynchronize only visible +PTYs so a font preference cannot collapse a hidden terminal's native grid. + +**Legacy Windows shells cannot receive canonical verbatim executable paths +(2026-07-20).** `std::fs::canonicalize` returns `\\?\C:\…` on Windows, which is +useful for identity and safety but must be converted back to a normal drive/UNC +path at the embedded-terminal process boundary. Windows PowerShell 5.1 passes +its executable path into .NET Framework during interactive ConPTY startup; the +verbatim form can abort initialization in `System.Net.ServicePointManager` even +though non-interactive launches work. Keep canonical paths elsewhere, normalize +only shell executable argv, and retain a PTY regression test that answers the +shell's cursor-position query before asserting startup output. + +**Native dropdown chrome is an app-level primitive (2026-07-20).** Render +selects through `components/Select.tsx`, not one-off `{children} +