diff --git a/.cargo/config.toml b/.cargo/config.toml index 39168d2b..4b314a32 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -9,3 +9,33 @@ dev = "run -- --config-dir .tty7-dev" # it (resumable, packed transfer), and is present on every platform we build on. [net] git-fetch-with-cli = true + +# Link the Visual C++ runtime statically on Windows (#902). +# +# The default MSVC target links the CRT dynamically, so every shipped binary +# imports VCRUNTIME140.dll — a file Windows does not carry in the box. On a +# machine without the "Visual C++ 2015-2022 Redistributable" installed, the +# loader fails before `main` with "VCRUNTIME140.dll was not found", which is +# what winget's install validation hit (microsoft/winget-pkgs#415841). +# +# The UCRT half (`api-ms-win-crt-*`) is in-box from Windows 10 onwards and is +# not the problem; VCRUNTIME140 is the one piece that has to come from the +# redistributable. `+crt-static` pulls both in statically, so the binaries have +# no CRT imports left at all. +# +# It lives here rather than in the release workflow on purpose: CI's Windows +# `build & test` job then compiles under the same flag as a release, so a +# dependency that cannot link statically fails a pull request instead of a tag. +# Keep in mind for future work: +# * `cc`-built C/C++ dependencies pick this up through +# CARGO_CFG_TARGET_FEATURE and switch to /MT, so the CRT choice stays +# consistent across the whole link. +# * It only holds while nothing sets the RUSTFLAGS environment variable for +# a Windows build — that variable replaces these flags wholesale rather +# than adding to them. `.github/scripts/assert-no-vcruntime.ps1` is the +# backstop: it fails the build if VCRUNTIME140/MSVCP140 reappear. +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] + +[target.aarch64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..9fab6a16 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,89 @@ +name: Bug report +description: Something in tty7 doesn't work as expected +labels: ["bug"] +body: + - type: checkboxes + id: checks + attributes: + label: Before you file + options: + - label: I searched the existing issues and this isn't a duplicate. + required: true + - label: I'm on the latest tty7 release, or I've said below why I can't be. + required: true + + - type: textarea + id: summary + attributes: + label: What happened? + description: The behaviour you saw, in a sentence or two. + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: > + Numbered steps starting from a freshly opened tty7 window. If a + specific command or escape sequence triggers it, paste the exact one — + "some TUI app" is rarely enough to reproduce a terminal bug. + placeholder: | + 1. Open a new tab + 2. Run `printf '\e[?2004h'` + 3. Type a character — the pane freezes + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect instead? + validations: + required: true + + - type: input + id: version + attributes: + label: tty7 version + description: Run `tty7 --version`, or check the About window. + placeholder: "26.9.2" + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Platform + options: + - macOS (Apple Silicon) + - macOS (Intel) + - Windows + - Linux + validations: + required: true + + - type: input + id: context + attributes: + label: Shell and TUI app involved + description: > + The shell you were running, and the TUI app plus its version if one is + on screen when it happens. + placeholder: fish 3.7.1, neovim 0.10.2 + + - type: textarea + id: logs + attributes: + label: Log output + description: > + The tail of `~/.config/tty7/tty7.log` + (`%APPDATA%\tty7\tty7.log` on Windows), if it has anything from around + the time it broke. This is rendered as code, so no backticks needed. + render: shell + + - type: textarea + id: extra + attributes: + label: Anything else? + description: Screenshots, a recording, or anything else worth knowing. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index bcbeb42f..62e78b3f 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,5 @@ -blank_issues_enabled: true +blank_issues_enabled: false contact_links: - - name: Questions & ideas - url: https://github.com/l0ng-ai/tty7/discussions - about: Not sure it's a bug? Want to discuss an idea first? Start a discussion. - name: Security vulnerabilities url: https://github.com/l0ng-ai/tty7/security/advisories/new about: Please report security issues privately, not as public issues. diff --git a/.github/ISSUE_TEMPLATE/idea.yml b/.github/ISSUE_TEMPLATE/idea.yml new file mode 100644 index 00000000..8424395b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/idea.yml @@ -0,0 +1,35 @@ +name: Idea +description: Suggest an improvement or a new capability +labels: ["enhancement"] +body: + - type: checkboxes + id: checks + attributes: + label: Before you file + options: + - label: I searched the existing issues and this isn't already proposed. + required: true + + - type: textarea + id: problem + attributes: + label: What's the problem? + description: > + What you were trying to do, and where tty7 got in the way. Leave the + solution for the next box — the problem is the part we can't guess. + validations: + required: true + + - type: textarea + id: behaviour + attributes: + label: How should it behave? + description: The shape you have in mind, if you have one. + + - type: textarea + id: prior_art + attributes: + label: Prior art and workarounds + description: > + How other terminals handle it, and what you're doing today to work + around it. diff --git a/.github/ISSUE_TEMPLATE/issue.yml b/.github/ISSUE_TEMPLATE/issue.yml deleted file mode 100644 index d32ab16a..00000000 --- a/.github/ISSUE_TEMPLATE/issue.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Issue -description: Report a bug or suggest an improvement -body: - - type: dropdown - id: kind - attributes: - label: Type - options: - - Bug — something doesn't work as expected - - Idea — suggest an improvement or new capability - validations: - required: true - - type: textarea - id: detail - attributes: - label: What's going on? - description: > - For a bug: what you did, what you saw, and what you expected instead. - For an idea: the problem it solves and how it should behave. - validations: - required: true - - type: input - id: version - attributes: - label: tty7 version (bugs) - placeholder: v0.2.0 - - type: dropdown - id: platform - attributes: - label: Platform (bugs) - options: - - macOS (Apple Silicon) - - macOS (Intel) - - Windows - - Linux - - type: textarea - id: extra - attributes: - label: Anything else? - description: > - Screenshots or recordings, the exact command / escape sequence that - triggers it, the shell you were using, or the TUI app (vim, htop, …) - and its version if one is involved. diff --git a/.github/scripts/assert-no-vcruntime.ps1 b/.github/scripts/assert-no-vcruntime.ps1 new file mode 100644 index 00000000..3d25a0f8 --- /dev/null +++ b/.github/scripts/assert-no-vcruntime.ps1 @@ -0,0 +1,176 @@ +# Fail the build if a shipped Windows binary imports the Visual C++ +# redistributable (#902). +# +# A default MSVC build links the CRT dynamically, which leaves VCRUNTIME140.dll +# (and, once any C++ is linked in, MSVCP140.dll) in the import table. Neither +# ships with Windows, so on a machine that has never installed the "Visual C++ +# 2015-2022 Redistributable" the loader fails before `main` with +# +# The code execution cannot proceed because VCRUNTIME140.dll was not found. +# +# That is what winget's install validation hit on 26.8.2 +# (microsoft/winget-pkgs#415841). `.cargo/config.toml` fixes it by linking the +# CRT statically; this script is the guard that keeps it fixed, because the +# failure is invisible on any developer or CI machine — they all have the +# redistributable, installed by Visual Studio or by some other package. +# +# The `api-ms-win-crt-*` imports are a different thing and are fine: those are +# the Universal CRT, an in-box Windows component since Windows 10. They are +# only present at all when the CRT is linked dynamically, so a statically +# linked binary has none of these imports either. +# +# Usage: assert-no-vcruntime.ps1 [ ...] +# Each path is a PE file, or a directory whose *.exe and *.dll are scanned. +# Microsoft's own redistributable ConPTY pair is skipped: it is prebuilt, we do +# not link it, and it is already statically linked against the CRT. +$ErrorActionPreference = 'Stop' + +$Forbidden = @('vcruntime140', 'vcruntime140_1', 'msvcp140', 'msvcp140_1', + 'msvcp140_2', 'concrt140', 'vcamp140', 'vcomp140', 'msvcr120', + 'msvcr110', 'msvcr100') +# Not ours to link, and already CRT-static (verified with dumpbin /dependents). +$Skip = @('conpty.dll', 'OpenConsole.exe') + +# Minimal PE import-table reader. Deliberately not `dumpbin`: that needs a +# Visual Studio installation on PATH, which is exactly the assumption this +# check exists to stop us making. +function Get-PEImportedModules([string]$Path) { + $bytes = [System.IO.File]::ReadAllBytes($Path) + if ($bytes.Length -lt 0x40) { throw "$Path is too small to be a PE file" } + if ($bytes[0] -ne 0x4D -or $bytes[1] -ne 0x5A) { throw "$Path is not a PE file (no MZ)" } + + $peOffset = [BitConverter]::ToInt32($bytes, 0x3C) + if ([BitConverter]::ToUInt32($bytes, $peOffset) -ne 0x00004550) { + throw "$Path is not a PE file (no PE\0\0 at $peOffset)" + } + + $coff = $peOffset + 4 + $sectionCount = [BitConverter]::ToUInt16($bytes, $coff + 2) + $optionalSize = [BitConverter]::ToUInt16($bytes, $coff + 16) + $optional = $coff + 20 + + # PE32 keeps the data directories 16 bytes earlier than PE32+ does: the + # four ImageBase/Reserved fields differ in width between the two. + $magic = [BitConverter]::ToUInt16($bytes, $optional) + switch ($magic) { + 0x10B { $dataDirs = $optional + 96 } # PE32 + 0x20B { $dataDirs = $optional + 112 } # PE32+ + default { throw "$Path has an unknown optional header magic 0x$($magic.ToString('X'))" } + } + $dirCount = [BitConverter]::ToUInt32($bytes, $dataDirs - 4) + + $sections = @() + $sectionTable = $optional + $optionalSize + for ($i = 0; $i -lt $sectionCount; $i++) { + $s = $sectionTable + ($i * 40) + $sections += [pscustomobject]@{ + VirtualAddress = [BitConverter]::ToUInt32($bytes, $s + 12) + VirtualSize = [BitConverter]::ToUInt32($bytes, $s + 8) + RawSize = [BitConverter]::ToUInt32($bytes, $s + 16) + RawAddress = [BitConverter]::ToUInt32($bytes, $s + 20) + } + } + + # The import tables store addresses as RVAs; on disk we need file offsets. + function ConvertTo-FileOffset([uint32]$rva) { + foreach ($s in $sections) { + $span = [Math]::Max($s.VirtualSize, $s.RawSize) + if ($rva -ge $s.VirtualAddress -and $rva -lt ($s.VirtualAddress + $span)) { + return [int]($s.RawAddress + ($rva - $s.VirtualAddress)) + } + } + return -1 + } + + function Read-AsciiAt([int]$offset) { + if ($offset -lt 0 -or $offset -ge $bytes.Length) { return $null } + $end = $offset + while ($end -lt $bytes.Length -and $bytes[$end] -ne 0) { $end++ } + return [System.Text.Encoding]::ASCII.GetString($bytes, $offset, $end - $offset) + } + + $modules = New-Object System.Collections.Generic.List[string] + + # Directory 1 is the import table, directory 13 the delay-load table. A + # delay-loaded VCRUNTIME140 fails at first call rather than at load, which + # is worse to diagnose, not better — so both are checked. + # import descriptor: Name RVA at +12, 20-byte entries, zero-terminated + # delay descriptor: Name RVA at +4, 32-byte entries, zero-terminated + $tables = @( + [pscustomobject]@{ Index = 1; Stride = 20; NameAt = 12 }, + [pscustomobject]@{ Index = 13; Stride = 32; NameAt = 4 } + ) + foreach ($table in $tables) { + if ($dirCount -le $table.Index) { continue } + $rva = [BitConverter]::ToUInt32($bytes, $dataDirs + ($table.Index * 8)) + if ($rva -eq 0) { continue } + $cursor = ConvertTo-FileOffset $rva + if ($cursor -lt 0) { continue } + while ($true) { + if ($cursor + $table.Stride -gt $bytes.Length) { break } + $empty = $true + for ($b = 0; $b -lt $table.Stride; $b++) { + if ($bytes[$cursor + $b] -ne 0) { $empty = $false; break } + } + if ($empty) { break } + $nameRva = [BitConverter]::ToUInt32($bytes, $cursor + $table.NameAt) + # A bound delay-load descriptor can store a VA rather than an RVA; + # such an entry simply will not map, and is skipped. + $name = Read-AsciiAt (ConvertTo-FileOffset $nameRva) + if ($name) { $modules.Add($name) } + $cursor += $table.Stride + } + } + return $modules +} + +if ($args.Count -eq 0) { throw "usage: assert-no-vcruntime.ps1 [ ...]" } + +$targets = New-Object System.Collections.Generic.List[string] +foreach ($arg in $args) { + if (-not (Test-Path -LiteralPath $arg)) { throw "no such path: $arg" } + if (Test-Path -LiteralPath $arg -PathType Container) { + # Filtered with Where-Object rather than -Include: -Include is silently + # ignored alongside -LiteralPath, which would hand the PE reader the + # marker files and licence text sitting in the same directory. + Get-ChildItem -LiteralPath $arg -Recurse -File | + Where-Object { $_.Extension -in '.exe', '.dll' } | + ForEach-Object { $targets.Add($_.FullName) } + } else { + $targets.Add((Resolve-Path -LiteralPath $arg).Path) + } +} + +$scanned = 0 +$failures = New-Object System.Collections.Generic.List[string] +foreach ($target in $targets) { + $leaf = Split-Path -Leaf $target + if ($Skip -contains $leaf) { + Write-Output "skip $leaf (Microsoft's prebuilt redistributable ConPTY)" + continue + } + $scanned++ + $imports = Get-PEImportedModules $target + $bad = @($imports | Where-Object { + $Forbidden -contains [System.IO.Path]::GetFileNameWithoutExtension($_).ToLowerInvariant() + }) + if ($bad.Count -gt 0) { + $failures.Add("$leaf imports the Visual C++ redistributable: $($bad -join ', ')") + } else { + Write-Output "ok $leaf ($($imports.Count) imported modules, no VC++ redistributable)" + } +} + +# An empty scan must not pass: a mistyped path would otherwise report success +# without having looked at anything. +if ($scanned -eq 0) { throw "assert-no-vcruntime.ps1 found no PE files to check in: $($args -join ', ')" } + +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { Write-Output "::error::$failure" } + throw ("these binaries need the Visual C++ Redistributable and will not start " + + "without it (#902); check that .cargo/config.toml's +crt-static still " + + "applies and that nothing set RUSTFLAGS for this build " + + "($($failures.Count) binary/binaries)") +} + +Write-Output "No Visual C++ redistributable imports in $scanned binary/binaries." diff --git a/.github/scripts/check-host-boundary.sh b/.github/scripts/check-host-boundary.sh index bc102de8..e2cf94cf 100755 --- a/.github/scripts/check-host-boundary.sh +++ b/.github/scripts/check-host-boundary.sh @@ -59,6 +59,9 @@ src/ui/app.rs|std::fs::create_dir_all src/ui/ssh_prompt.rs|std::fs::read src/ui/ssh_connect.rs|std::fs::read src/ui/settings.rs|std::fs::read +# The host editor asking which of those keys is on this machine before it offers +# a passphrase box for one — the same client-side key, never a workspace path. +src/ui/settings.rs|std::fs::metadata # Shell history lives in the local user's home (`~/.zsh_history` &co.) and backs # this app's own history search. A remote pane's history is the remote shell's diff --git a/.github/scripts/verify-windows-package.ps1 b/.github/scripts/verify-windows-package.ps1 index 25fdc259..a5714041 100644 --- a/.github/scripts/verify-windows-package.ps1 +++ b/.github/scripts/verify-windows-package.ps1 @@ -74,6 +74,22 @@ function Assert-ConptyPair([string]$directory, [string]$label) { } } +# A shipped binary that imports VCRUNTIME140.dll does not start on a Windows +# machine that has never installed the Visual C++ Redistributable: the loader +# fails before `main`, with no log and no window. That is #902, and it is how +# 26.8.2 failed winget's install validation +# (microsoft/winget-pkgs#415841) while running perfectly on every machine that +# had ever seen Visual Studio. `.cargo/config.toml` links the CRT statically; +# checked here, on the payload that actually ships, because neither the build +# nor the tests can observe the difference. +function Assert-NoVcRuntime([string]$directory, [string]$label) { + try { + & (Join-Path $PSScriptRoot 'assert-no-vcruntime.ps1') $directory + } catch { + Fail "$label would not start without the VC++ Redistributable: $($_.Exception.Message)" + } +} + # ---- Portable ZIP -------------------------------------------------------- # Update rules live in the updater's extractor; the ones that can be broken by # packaging alone are re-stated here. @@ -148,6 +164,7 @@ if (-not (Test-Path -LiteralPath $Zip)) { Assert-BinaryVersion (Join-Path $unzipped 'tty7-app.exe') 'the portable tty7-app.exe' Assert-BinaryVersion (Join-Path $unzipped 'tty7-updater.exe') 'the portable tty7-updater.exe' Assert-ConptyPair $unzipped 'the portable archive' + Assert-NoVcRuntime $unzipped 'the portable archive' } finally { Remove-Item -Recurse -Force $unzipped -ErrorAction SilentlyContinue } @@ -172,6 +189,7 @@ if (-not (Test-Path -LiteralPath $Stage -PathType Container)) { Assert-BinaryVersion (Join-Path $Stage 'tty7-app.exe') 'the installed tty7-app.exe' Assert-BinaryVersion (Join-Path $Stage 'tty7-updater.exe') 'the installed tty7-updater.exe' Assert-ConptyPair $Stage 'the Inno payload' + Assert-NoVcRuntime $Stage 'the Inno payload' } # ---- Setup executable ---------------------------------------------------- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5feeab63..aad6539f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,28 @@ jobs: timeout-minutes: 30 run: cargo build --locked --target ${{ matrix.target }} + # A binary that imports VCRUNTIME140.dll cannot start on a Windows + # machine that has never installed the Visual C++ Redistributable — the + # loader fails before `main`, which is what winget's install validation + # hit on 26.8.2 (#902). `.cargo/config.toml` links the CRT statically; + # this is the check that it still does. + # + # It runs here, on the debug build, rather than only at release time: + # every machine that builds tty7 already has the redistributable, so + # nothing else in the pipeline can notice the regression, and the + # dependency can come back from a single prebuilt native library or a stray + # RUSTFLAGS (`cc`-built code follows `+crt-static` on its own). + # Named files rather than the target directory — build scripts and + # proc-macro artifacts under it are host units, which are built without + # the target's rustflags and legitimately import the CRT dynamically. + - name: Assert the Windows binaries need no VC++ redistributable + if: runner.os == 'Windows' + shell: pwsh + run: | + & ./.github/scripts/assert-no-vcruntime.ps1 ` + "target/${{ matrix.target }}/debug/tty7-app.exe" ` + "target/${{ matrix.target }}/debug/tty7.exe" + # `cargo test` has no timeout of its own, so one hung test is # indistinguishable from a slow suite until the job hits GitHub's six-hour # limit. The suite intermittently hangs here — roughly one run in ten, on diff --git a/README.md b/README.md index e2df9af9..49060f13 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Native builds for macOS, Windows, and Linux on [**Releases**](https://github.com | | | |---|---| -| **Agent-aware** | per-pane detection (20 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository | +| **Agent-aware** | per-pane detection (22 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · tab sidebar grouped by repository | | **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` streams a command and exits with its code · `split` · `send` · `wait --until free` · `capture` | | **Editor-grade input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · ⌃ R fuzzy history | | **Window** | tabs & splits · ⌘ P palette · ⌘ F scrollback search · ⌘ J panel with process tree and listening ports · 13 themes, your own YAML, iTerm2 import · IME | @@ -69,7 +69,7 @@ after a reboot. **Fork** needs both — the agent's own fork command, and the ho that tells tty7 which session to fork.
-The full support matrix, all twenty +The full support matrix, all twenty-two | Agent | Detected | Status · resume | Fork | |---|:-:|:-:|:-:| @@ -82,10 +82,12 @@ that tells tty7 which session to fork. | **Droid** | ✓ | ✓ | ✓ | | **Qwen Code** | ✓ | ✓ | ✓ | | **Goose** | ✓ | ✓ | ✓ | +| **Qoder CLI** | ✓ | ✓ | ✓ | | **Gemini** | ✓ | ✓ | | | **Copilot** | ✓ | ✓ | | | **Kimi Code** | ✓ | ✓ | | | **Pi** | ✓ | ✓ | | +| **Crush** | ✓ | ✓ | | | Aider | ✓ | | | | Amp | ✓ | | | | Cursor | ✓ | | | diff --git a/assets/icons/agents/crush.svg b/assets/icons/agents/crush.svg new file mode 100644 index 00000000..9c900100 --- /dev/null +++ b/assets/icons/agents/crush.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/agents/qodercli.svg b/assets/icons/agents/qodercli.svg new file mode 100644 index 00000000..44eb1276 --- /dev/null +++ b/assets/icons/agents/qodercli.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/crates/tty7-cli/src/output.rs b/crates/tty7-cli/src/output.rs index c44e3219..d92ab7ef 100644 --- a/crates/tty7-cli/src/output.rs +++ b/crates/tty7-cli/src/output.rs @@ -526,12 +526,15 @@ mod tests { ); // What the pane's own terminal says it is doing beats naming the agent // running it — every tab of a workspace would otherwise read alike. + // The mark the agent writes in front of that title comes off here too: + // `tab_label` reads `TabView::label`, so the table says what the tab + // strip says without either being told about the other. assert_eq!( tab_label(&view(&|v| { v.osc_title = Some("✳ fixing the switcher".into()); v.agent = Some(tty7_core::core::cli_agent::CLIAgent::Claude); })), - "✳ fixing the switcher" + "fixing the switcher" ); assert_eq!( tab_label(&view( diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 63298afb..4720232c 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -43,6 +43,15 @@ fn effective_agent(agent: &str, ran_by_grok: bool) -> &str { } fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<&'a str> { + // Qoder also emits SessionStart after compacting the active turn. + // Preserve its status until a real turn or session boundary arrives. + if agent == "qodercli" + && event == "session-start" + && let Ok(payload) = serde_json::from_str::(stdin_json) + && payload.get("source").and_then(|value| value.as_str()) == Some("compact") + { + return None; + } if matches!(agent, "copilot" | "grok" | "droid" | "gemini") && event == "notification" { let blocks = stdin_json.contains("elicitation_dialog") || (matches!(agent, "copilot" | "droid") && stdin_json.contains("permission_prompt")) @@ -257,10 +266,12 @@ pub enum HookAgent { Qwen, Goose, Kimi, + QoderCLI, + Crush, } impl HookAgent { - pub const ALL: [HookAgent; 13] = [ + pub const ALL: [HookAgent; 15] = [ HookAgent::Claude, HookAgent::Codex, HookAgent::TraeCode, @@ -274,6 +285,8 @@ impl HookAgent { HookAgent::Qwen, HookAgent::Goose, HookAgent::Kimi, + HookAgent::QoderCLI, + HookAgent::Crush, ]; /// The hooks behind a detected agent process, if it has any. @@ -296,6 +309,8 @@ impl HookAgent { CLIAgent::Qwen => Some(HookAgent::Qwen), CLIAgent::Goose => Some(HookAgent::Goose), CLIAgent::Kimi => Some(HookAgent::Kimi), + CLIAgent::QoderCLI => Some(HookAgent::QoderCLI), + CLIAgent::Crush => Some(HookAgent::Crush), CLIAgent::Aider | CLIAgent::Amp | CLIAgent::Cursor @@ -317,6 +332,8 @@ impl HookAgent { HookAgent::Gemini => Some(GEMINI_HOOK_EVENTS), HookAgent::Droid => Some(DROID_HOOK_EVENTS), HookAgent::Qwen => Some(QWEN_HOOK_EVENTS), + HookAgent::QoderCLI => Some(QODER_HOOK_EVENTS), + HookAgent::Crush => Some(CRUSH_HOOK_EVENTS), HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi @@ -337,6 +354,14 @@ impl HookAgent { } } + /// Whether this agent's hook-map entries carry `command` and `matcher` at + /// the top level rather than nesting a `hooks` array of `{type, command}` + /// objects inside the matcher. Claude's shape is the latter; Crush flattened + /// it, and still calls itself Claude-Code-compatible on the wire. + fn flat_hook_map(self) -> bool { + matches!(self, HookAgent::Crush) + } + pub fn slug(self) -> &'static str { match self { HookAgent::Claude => "claude", @@ -352,6 +377,8 @@ impl HookAgent { HookAgent::Qwen => "qwen", HookAgent::Goose => "goose", HookAgent::Kimi => "kimi", + HookAgent::QoderCLI => "qodercli", + HookAgent::Crush => "crush", } } @@ -370,6 +397,8 @@ impl HookAgent { HookAgent::Qwen => "Qwen Code", HookAgent::Goose => "Goose", HookAgent::Kimi => "Kimi Code", + HookAgent::QoderCLI => "Qoder CLI", + HookAgent::Crush => "Crush", } } @@ -402,6 +431,8 @@ impl HookAgent { target.under_home(&[".agents", "plugins", "tty7", "hooks", "hooks.json"]) } HookAgent::Kimi => target.kimi_config_path(), + HookAgent::QoderCLI => target.qoder_settings_path(), + HookAgent::Crush => target.crush_settings_path(), } } @@ -494,6 +525,29 @@ impl<'a> HookTarget<'a> { self.under_home(&[".kimi-code", "config.toml"]) } + fn qoder_settings_path(&self) -> PathBuf { + if self.is_local() + && let Some(dir) = std::env::var_os("QODER_CONFIG_DIR").filter(|d| !d.is_empty()) + { + return PathBuf::from(dir).join("settings.json"); + } + self.under_home(&[".qoder", "settings.json"]) + } + + /// The global `crush.json`. Crush resolves it through `CRUSH_GLOBAL_CONFIG` + /// when set, otherwise `$XDG_CONFIG_HOME/crush/crush.json` (and `~/.config` + /// when that is unset) — which is exactly what [`Self::xdg_config_dir`] + /// answers. The override is local-only: a local env var must not redirect a + /// remote machine's hooks. + fn crush_settings_path(&self) -> PathBuf { + if self.is_local() + && let Some(dir) = std::env::var_os("CRUSH_GLOBAL_CONFIG").filter(|d| !d.is_empty()) + { + return PathBuf::from(dir).join("crush.json"); + } + self.under(&self.xdg_config_dir(), &["crush", "crush.json"]) + } + fn traecli_hooks_path(&self) -> PathBuf { if self.is_local() { if let Some(dir) = std::env::var_os("TRAECLI_HOME").filter(|d| !d.is_empty()) { @@ -794,6 +848,30 @@ const GROK_HOOK_EVENTS: &[(&str, &str, Option<&str>)] = &[ ("SessionEnd", "session-end", None), ]; +const QODER_HOOK_EVENTS: &[(&str, &str)] = &[ + ("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt-submit"), + ("PermissionRequest", "permission-request"), + // An authorized MCP tool can still pause for user input mid-call. + ("Elicitation", "question-asked"), + ("PostToolUse", "tool-complete"), + ("Stop", "stop"), + ("StopFailure", "stop"), + ("SessionEnd", "session-end"), +]; + +/// Crush currently fires exactly one hook, `PreToolUse`, before every +/// top-level tool call and before its permission check. Its payload still +/// carries `session_id` and `cwd`, which is what resume needs. +/// +/// It maps to `tool-complete`, not `prompt-submit`: with no `Stop` to follow, +/// a turn started here would never end, and a pane stuck on working asks +/// before every close and holds the tray and `tty7 wait` on a turn long over. +/// `tool-complete` records the session and bumps the activity counter while +/// leaving the status alone. When Crush ships `UserPromptSubmit`/`Stop` and +/// friends, they slot in here. +const CRUSH_HOOK_EVENTS: &[(&str, &str)] = &[("PreToolUse", "tool-complete")]; + fn hook_map_state( target: &HookTarget, path: &Path, @@ -878,9 +956,13 @@ fn hook_map_install( continue; }; list.retain(|matcher| marker_command(matcher, &marker).is_none()); - list.push(serde_json::json!({ - "hooks": [{ "type": "command", "command": command }] - })); + if agent.flat_hook_map() { + list.push(serde_json::json!({ "command": command })); + } else { + list.push(serde_json::json!({ + "hooks": [{ "type": "command", "command": command }] + })); + } } target.write(path, serde_json::to_string_pretty(&root)?.as_bytes()) @@ -924,8 +1006,21 @@ fn hook_map_uninstall( Ok(HookOutcome::Removed) } -fn marker_command<'a>(matcher: &'a serde_json::Value, marker: &str) -> Option<&'a str> { - matcher +/// The tty7 command an entry in a hook map carries, if it is one of ours. +/// +/// Two shapes reach here. Claude and its imitators nest it at +/// `entry.hooks[].command`; Crush lifts `command` to the entry itself, the +/// same level as its `matcher`. Both are one list under `hooks.`, so +/// the state reader, the installer and the uninstaller all stay shared. +fn marker_command<'a>(entry: &'a serde_json::Value, marker: &str) -> Option<&'a str> { + if let Some(command) = entry + .get("command") + .and_then(|c| c.as_str()) + .filter(|c| c.contains(marker)) + { + return Some(command); + } + entry .get("hooks") .and_then(|h| h.as_array())? .iter() @@ -1138,6 +1233,8 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { | HookAgent::Gemini | HookAgent::Droid | HookAgent::Qwen + | HookAgent::QoderCLI + | HookAgent::Crush | HookAgent::Kimi => None, } } @@ -1402,8 +1499,11 @@ export default function (pi: ExtensionAPI) {{ // Extension load = the agent is running in this pane. No context here yet, // so the id rides on session_start instead. emit("session-start"); - pi.on("agent_start", (_event, ctx) => emit("prompt-submit", ctx)); - pi.on("agent_end", (_event, ctx) => emit("stop", ctx)); + // What the pane showed before a UI prompt put it on "waiting", so closing + // the prompt can put it back. + let turn = "session-start"; + pi.on("agent_start", (_event, ctx) => emit((turn = "prompt-submit"), ctx)); + pi.on("agent_end", (_event, ctx) => emit((turn = "stop"), ctx)); pi.on("session_shutdown", (_event, ctx) => emit("session-end", ctx)); // Last, and guarded: the three above already worked, so a Pi build that // rejects this event name must not take them — or the whole extension — @@ -1411,6 +1511,23 @@ export default function (pi: ExtensionAPI) {{ try {{ pi.on("session_start", (_event, ctx) => emit("session-start", ctx)); }} catch {{}} + // Pi-only: Oh My Pi may not expose the UI-prompt hooks, so each one is + // guarded on its own — a fork that rejects the event name must not take the + // rest of the bridge down with it. Without these the pane never reports + // "waiting for you" while a dialog is open, and the event vocabulary already + // carries question-asked / permission-request for exactly that. + try {{ + pi.on("ui_prompt_start", (event, ctx) => {{ + emit(event.kind === "confirm" ? "permission-request" : "question-asked", ctx); + }}); + }} catch {{}} + // The dialog closed: restore what it interrupted. Not a blanket + // prompt-submit — Pi also prompts while idle (/model, a command's select), + // and that would leave a finished pane reading "working" with no turn to + // ever end it. + try {{ + pi.on("ui_prompt_end", (_event, ctx) => emit(turn, ctx)); + }} catch {{}} }} "# )) @@ -1570,9 +1687,11 @@ mod tests { .chain(TRAE_CODE_HOOK_EVENTS) .chain(GEMINI_HOOK_EVENTS) .chain(DROID_HOOK_EVENTS) + .chain(QODER_HOOK_EVENTS) .chain(QWEN_HOOK_EVENTS) .chain(GOOSE_HOOK_EVENTS) .chain(KIMI_HOOK_EVENTS) + .chain(CRUSH_HOOK_EVENTS) .map(|(_, e)| *e) .chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e)) .collect(); @@ -1607,6 +1726,8 @@ mod tests { "/home/me/.agents/plugins/tty7/hooks/hooks.json", ), (HookAgent::Kimi, "/home/me/.kimi-code/config.toml"), + (HookAgent::QoderCLI, "/home/me/.qoder/settings.json"), + (HookAgent::Crush, "/home/me/.config/crush/crush.json"), ] { assert_eq!( agent.target_path(&t), @@ -1627,6 +1748,8 @@ mod tests { HookAgent::Qwen, HookAgent::Goose, HookAgent::Kimi, + HookAgent::QoderCLI, + HookAgent::Crush, ] { assert_eq!(hooks_state(&real, agent), HooksState::NotInstalled); install_hooks(&real, agent).unwrap_or_else(|e| panic!("{}: {e}", agent.slug())); @@ -1649,6 +1772,133 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn qoder_compaction_preserves_the_active_turn() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus}; + + let mut state = AgentSessionState::default(); + state.apply_event(&round_trip( + "qodercli", + "prompt-submit", + r#"{"session_id":"q-1","cwd":"/repo","prompt":"Continue the task"}"#, + )); + let before = state.clone(); + let compact = r#"{"session_id":"q-1","cwd":"/repo","source":"compact"}"#; + if let Some(event) = effective_event("qodercli", "session-start", compact) { + state.apply_event(&round_trip("qodercli", event, compact)); + } + assert_eq!(state, before, "compaction must preserve the active turn"); + + state.apply_event(&round_trip("qodercli", "tool-complete", "{}")); + assert_eq!(state.status, AgentStatus::Working); + state.apply_event(&round_trip("qodercli", "stop", "{}")); + assert_eq!(state.status, AgentStatus::Done); + + for input in [ + r#"{"source":"startup","message":"compact"}"#, + r#"{"source":"resume"}"#, + r#"{"source":"clear"}"#, + "{}", + "not JSON", + ] { + let event = effective_event("qodercli", "session-start", input) + .expect("ordinary session starts still reach the state machine"); + let mut session = before.clone(); + session.apply_event(&round_trip("qodercli", event, input)); + assert_eq!(session.status, AgentStatus::Idle, "{input}"); + } + assert_eq!( + effective_event("claude", "session-start", compact), + Some("session-start"), + "the filter is specific to Qoder" + ); + assert_eq!( + effective_event("qodercli", "prompt-submit", compact), + Some("prompt-submit") + ); + } + + #[test] + fn qoder_mcp_elicitation_waits_for_user_input() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus}; + + let apply_hook = |state: &mut AgentSessionState, hook: &str, input: &str| { + let event = HookAgent::QoderCLI + .hook_map_events() + .unwrap() + .iter() + .find_map(|(name, event)| (*name == hook).then_some(*event)) + .and_then(|event| effective_event("qodercli", event, input)); + if let Some(event) = event { + state.apply_event(&round_trip("qodercli", event, input)); + } + }; + let mut state = AgentSessionState::default(); + apply_hook( + &mut state, + "UserPromptSubmit", + r#"{"session_id":"q-1","prompt":"Look up my tickets"}"#, + ); + assert_eq!(state.status, AgentStatus::Working); + // Qoder says outright when it is blocked — `PermissionRequest` and + // `Elicitation` — so it must not also carry `Notification`, which + // fires for non-blocking alerts and would strand the pane on + // "waiting". Asserting the map has no seat for it is the check; a + // `Notification` payload put through `apply_hook` would be dropped + // for want of one and prove nothing. + assert!( + !HookAgent::QoderCLI + .hook_map_events() + .unwrap() + .iter() + .any(|(hook, _)| *hook == "Notification"), + "an unblocking alert must not read as a question" + ); + + // The MCP tool is already authorized, so no PermissionRequest precedes + // its request for more information from the user. + apply_hook( + &mut state, + "Elicitation", + r#"{ + "session_id":"q-1", + "hook_event_name":"Elicitation", + "mcp_server_name":"tickets", + "message":"Choose a project", + "mode":"form" + }"#, + ); + assert_eq!(state.status, AgentStatus::Waiting); + assert_eq!(state.message.as_deref(), Some("Choose a project")); + assert_eq!(state.session_id.as_deref(), Some("q-1")); + + apply_hook(&mut state, "PostToolUse", r#"{"session_id":"q-1"}"#); + assert_eq!(state.status, AgentStatus::Working); + assert_eq!(state.message, None); + apply_hook(&mut state, "Stop", r#"{"session_id":"q-1"}"#); + assert_eq!(state.status, AgentStatus::Done); + } + + /// Crush's lone `PreToolUse` has no `Stop` to close a turn behind it, so it + /// must not open one: the pane would read as busy until Crush exits. + #[test] + fn crush_tool_calls_record_the_session_without_starting_a_turn() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus}; + + let mut state = AgentSessionState::default(); + for (hook, event) in HookAgent::Crush.hook_map_events().unwrap() { + assert_eq!(*hook, "PreToolUse"); + let input = + r#"{"event":"PreToolUse","session_id":"c-1","cwd":"/repo","tool_name":"bash"}"#; + let event = effective_event("crush", event, input).unwrap(); + state.apply_event(&round_trip("crush", event, input)); + } + assert_eq!(state.status, AgentStatus::Idle); + assert_eq!(state.session_id.as_deref(), Some("c-1")); + assert_eq!(state.cwd.as_deref(), Some(Path::new("/repo"))); + assert_eq!(state.activity, 1); + } + /// Qwen is the one agent that reports a blocked turn outright, so it must /// not also carry the `Notification` hook the others need — that event fires /// for non-blocking alerts too and would strand the pane on "waiting". @@ -1797,6 +2047,18 @@ mod tests { }); assert!(marker_command(&theirs, "agent-hook claude").is_none()); assert!(marker_command(&serde_json::json!({}), "agent-hook claude").is_none()); + + // Crush flattens the same entry: `command` sits beside `matcher` rather + // than inside a nested `hooks` array, and the reader has to see it. + let flat = serde_json::json!({ + "matcher": "^bash$", + "command": "\"/x/tty7\" agent-hook crush prompt-submit" + }); + assert_eq!( + marker_command(&flat, "agent-hook crush"), + Some("\"/x/tty7\" agent-hook crush prompt-submit") + ); + assert!(marker_command(&flat, "agent-hook claude").is_none()); } fn local_host() -> crate::host::SharedHost { @@ -1906,6 +2168,8 @@ mod tests { "/home/me/.omp/agent/extensions/tty7/index.ts", ), (HookAgent::Kimi, "/home/me/.kimi-code/config.toml"), + (HookAgent::QoderCLI, "/home/me/.qoder/settings.json"), + (HookAgent::Crush, "/home/me/.config/crush/crush.json"), ] { assert_eq!( agent.target_path(&target), @@ -2056,12 +2320,18 @@ mod tests { "agent_start", "agent_end", "session_shutdown", + "ui_prompt_start", + "ui_prompt_end", ] { assert!( bridge.contains(&format!(r#"pi.on("{event}""#)), "{slug} bridge subscribes to {event}" ); } + assert!( + bridge.contains(r#"pi.on("ui_prompt_end", (_event, ctx) => emit(turn, ctx))"#), + "{slug} restores the pre-prompt status rather than forcing working" + ); } assert!( pi_extension_ts(&target, HookAgent::Claude).is_none(), @@ -2131,6 +2401,293 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn qoder_config_dir_controls_local_hook_lifecycle() { + const CASE_ENV: &str = "TTY7_TEST_QODER_CONFIG_CASE"; + const ROOT_ENV: &str = "TTY7_TEST_QODER_CONFIG_ROOT"; + let Ok(case) = std::env::var(CASE_ENV) else { + // Each case gets its own environment, without changing the one + // shared by the other tests or touching the user's settings. + for case in ["override", "empty", "unset"] { + let sandbox = tempfile::tempdir().unwrap(); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()); + child + .args([ + "--exact", + "core::agent_hooks::tests::qoder_config_dir_controls_local_hook_lifecycle", + "--nocapture", + ]) + .env(CASE_ENV, case) + .env(ROOT_ENV, sandbox.path()); + match case { + "override" => { + child.env("QODER_CONFIG_DIR", sandbox.path().join("custom config")) + } + "empty" => child.env("QODER_CONFIG_DIR", ""), + _ => child.env_remove("QODER_CONFIG_DIR"), + }; + let output = crate::core::proc::output_within( + crate::core::proc::hide_console(&mut child), + std::time::Duration::from_secs(30), + ) + .expect("run the isolated Qoder hook test"); + assert!( + output.status.success(), + "{case}:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + }; + + let root = PathBuf::from(std::env::var_os(ROOT_ENV).unwrap()); + let host = local_host(); + let target = HookTarget { + host: &*host, + home: root.join("home"), + exe: std::env::current_exe().unwrap(), + }; + let default_settings = target.home.join(".qoder").join("settings.json"); + let custom_settings = root.join("custom config").join("settings.json"); + let (settings, untouched) = if case == "override" { + (&custom_settings, &default_settings) + } else { + (&default_settings, &custom_settings) + }; + let user_config = serde_json::json!({ + "model": "qoder-test", + "hooks": { + "Stop": [{ "hooks": [{ "type": "command", "command": "echo user-hook" }] }] + } + }); + for path in [settings, untouched] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, user_config.to_string()).unwrap(); + } + + let agent = HookAgent::QoderCLI; + assert_eq!(agent.target_path(&target), *settings); + let remote_host = FakeRemote::shared(); + let remote = HookTarget::remote(&*remote_host, PathBuf::from("/home/me")); + assert_eq!( + agent.target_path(&remote), + PathBuf::from("/home/me/.qoder/settings.json"), + "a local override must not redirect remote hooks" + ); + + assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled); + assert_eq!( + install_hooks(&target, agent).unwrap(), + HookOutcome::Installed + ); + assert_eq!(hooks_state(&target, agent), HooksState::Installed); + assert!( + std::fs::read_to_string(settings) + .unwrap() + .contains("agent-hook qodercli") + ); + assert_eq!( + uninstall_hooks(&target, agent).unwrap(), + HookOutcome::Removed + ); + assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled); + for path in [settings, untouched] { + let actual: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!(actual, user_config, "{}", path.display()); + } + } + + /// Crush's hook map is one list under `hooks.PreToolUse` like Claude's, but + /// each entry carries `command` and `matcher` directly instead of wrapping + /// them in a nested `hooks` array. The merge has to write the flat shape and + /// still leave a user's own hooks and the rest of `crush.json` alone. + #[test] + fn crush_installs_a_flat_hook_and_preserves_user_entries() { + let host = FakeRemote::shared(); + let base = std::env::temp_dir().join(format!("tty7-crush-hooks-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let target = HookTarget::remote(&*host, base.clone()); + let config = HookAgent::Crush.target_path(&target); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + let user_config = serde_json::json!({ + "model": "crush-test", + "hooks": { + "PreToolUse": [ + { "matcher": "^bash$", "command": "echo user-hook", "timeout": 5 } + ] + } + }); + std::fs::write(&config, serde_json::to_string_pretty(&user_config).unwrap()).unwrap(); + + assert_eq!( + hooks_state(&target, HookAgent::Crush), + HooksState::NotInstalled + ); + install_hooks(&target, HookAgent::Crush).expect("install succeeds"); + assert_eq!( + hooks_state(&target, HookAgent::Crush), + HooksState::Installed + ); + install_hooks(&target, HookAgent::Crush).expect("re-install succeeds"); + + let merged: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap(); + assert_eq!(merged["model"], "crush-test"); + let entries = merged["hooks"]["PreToolUse"].as_array().unwrap(); + let ours: Vec<&serde_json::Value> = entries + .iter() + .filter(|e| { + e.get("command") + .and_then(|c| c.as_str()) + .is_some_and(|c| c.contains("agent-hook crush")) + }) + .collect(); + assert_eq!(ours.len(), 1, "exactly one tty7 entry after two installs"); + assert_eq!( + ours[0]["command"].as_str(), + Some( + target + .hook_command(HookAgent::Crush, "tool-complete") + .as_str() + ), + "the entry is flat and names the tool-complete emitter" + ); + assert!( + ours[0].get("hooks").is_none(), + "Crush does not nest a hooks array inside the entry" + ); + assert!( + entries.iter().any(|e| e + .get("command") + .and_then(|c| c.as_str()) + .is_some_and(|c| c.contains("user-hook"))), + "the user's own PreToolUse hook survives" + ); + + assert_eq!( + uninstall_hooks(&target, HookAgent::Crush).unwrap(), + HookOutcome::Removed + ); + assert_eq!( + hooks_state(&target, HookAgent::Crush), + HooksState::NotInstalled + ); + let after: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap(); + assert_eq!( + after, user_config, + "uninstall restores the user's file exactly" + ); + + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn crush_global_config_controls_local_hook_lifecycle() { + const CASE_ENV: &str = "TTY7_TEST_CRUSH_CONFIG_CASE"; + const ROOT_ENV: &str = "TTY7_TEST_CRUSH_CONFIG_ROOT"; + let Ok(case) = std::env::var(CASE_ENV) else { + // Each case gets its own environment, without changing the one + // shared by the other tests or touching the user's settings. + for case in ["override", "empty", "unset"] { + let sandbox = tempfile::tempdir().unwrap(); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()); + child + .args([ + "--exact", + "core::agent_hooks::tests::crush_global_config_controls_local_hook_lifecycle", + "--nocapture", + ]) + .env(CASE_ENV, case) + .env(ROOT_ENV, sandbox.path()) + // `crush_settings_path` falls back to `$XDG_CONFIG_HOME` + // before the sandbox home, and CI exports it. Neutralise it + // so the default lands under the home this test owns. + .env_remove("XDG_CONFIG_HOME"); + match case { + "override" => { + child.env("CRUSH_GLOBAL_CONFIG", sandbox.path().join("custom config")) + } + "empty" => child.env("CRUSH_GLOBAL_CONFIG", ""), + _ => child.env_remove("CRUSH_GLOBAL_CONFIG"), + }; + let output = crate::core::proc::output_within( + crate::core::proc::hide_console(&mut child), + std::time::Duration::from_secs(30), + ) + .expect("run the isolated Crush hook test"); + assert!( + output.status.success(), + "{case}:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + }; + + let root = PathBuf::from(std::env::var_os(ROOT_ENV).unwrap()); + let host = local_host(); + let target = HookTarget { + host: &*host, + home: root.join("home"), + exe: std::env::current_exe().unwrap(), + }; + let default_config = target.home.join(".config").join("crush").join("crush.json"); + let custom_config = root.join("custom config").join("crush.json"); + let (config, untouched) = if case == "override" { + (&custom_config, &default_config) + } else { + (&default_config, &custom_config) + }; + let user_config = serde_json::json!({ + "model": "crush-test", + "hooks": { + "PreToolUse": [ + { "command": "echo user-hook" } + ] + } + }); + for path in [config, untouched] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, serde_json::to_string(&user_config).unwrap()).unwrap(); + } + + let agent = HookAgent::Crush; + assert_eq!(agent.target_path(&target), *config); + let remote_host = FakeRemote::shared(); + let remote = HookTarget::remote(&*remote_host, PathBuf::from("/home/me")); + assert_eq!( + agent.target_path(&remote), + PathBuf::from("/home/me/.config/crush/crush.json"), + "a local override must not redirect remote hooks" + ); + + assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled); + assert_eq!( + install_hooks(&target, agent).unwrap(), + HookOutcome::Installed + ); + assert_eq!(hooks_state(&target, agent), HooksState::Installed); + assert!( + std::fs::read_to_string(config) + .unwrap() + .contains("agent-hook crush") + ); + assert_eq!( + uninstall_hooks(&target, agent).unwrap(), + HookOutcome::Removed + ); + assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled); + for path in [config, untouched] { + let actual: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!(actual, user_config, "{}", path.display()); + } + } + #[test] fn install_is_idempotent_and_preserves_user_hooks() { let dir = std::env::temp_dir().join(format!("tty7-hooks-test-{}", std::process::id())); diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index f504631f..c4340f67 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -26,10 +26,12 @@ pub enum CLIAgent { // Keep new variants at the end: daemon messages serialize this enum and // moving an existing discriminant would break mixed-version clients. TraeCode, + QoderCLI, + Crush, } impl CLIAgent { - pub const ALL: [CLIAgent; 20] = [ + pub const ALL: [CLIAgent; 22] = [ CLIAgent::Claude, CLIAgent::Codex, CLIAgent::TraeCode, @@ -50,6 +52,8 @@ impl CLIAgent { CLIAgent::Qwen, CLIAgent::OhMyPi, CLIAgent::Kimi, + CLIAgent::QoderCLI, + CLIAgent::Crush, ]; fn aliases(self) -> &'static [&'static str] { @@ -83,6 +87,18 @@ impl CLIAgent { // kimi-cli install a `kimi` — same vendor, same brand, so one // detection covers them. Only the standalone one has hooks. CLIAgent::Kimi => &["kimi", "kimi-code"], + // The npm package installs two binaries and `qoder` is the one the + // documentation tells people to run: it dispatches to the CLI for a + // bare invocation, a flag, or a prompt, and only hands off to the + // IDE for `ide`/`chat`/`serve-web`/`tunnel` or a path that exists. + // Detecting only `qodercli` would miss every session started the + // documented way, since the dispatcher is what the pty sees. An IDE + // launch is the cost: it wears the CLI's avatar for as long as the + // launcher takes to exit. + CLIAgent::QoderCLI => &["qoder", "qodercli"], + // Charm's terminal agent. One binary, and the name on `PATH` is + // the one it starts as. + CLIAgent::Crush => &["crush"], } } @@ -108,6 +124,8 @@ impl CLIAgent { CLIAgent::Qwen => "qwen", CLIAgent::OhMyPi => "omp", CLIAgent::Kimi => "kimi", + CLIAgent::QoderCLI => "qodercli", + CLIAgent::Crush => "crush", } } @@ -138,6 +156,8 @@ impl CLIAgent { CLIAgent::Qwen => "Qwen Code", CLIAgent::OhMyPi => "Oh My Pi", CLIAgent::Kimi => "Kimi Code", + CLIAgent::QoderCLI => "Qoder CLI", + CLIAgent::Crush => "Crush", } } @@ -169,9 +189,11 @@ impl CLIAgent { CLIAgent::Droid => Some(format!("droid{flags} --resume {session_id}")), CLIAgent::Copilot => Some(format!("copilot{flags} --resume {session_id}")), CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id}")), + CLIAgent::QoderCLI => Some(format!("qodercli{flags} --resume {session_id}")), CLIAgent::Pi => Some(format!("pi{flags} --session {session_id}")), CLIAgent::OhMyPi => Some(format!("omp{flags} --resume {session_id}")), CLIAgent::Kimi => Some(format!("kimi{flags} --session {session_id}")), + CLIAgent::Crush => Some(format!("crush{flags} --session {session_id}")), _ => None, } } @@ -185,6 +207,9 @@ impl CLIAgent { // "If false, chat history is not saved and --continue/--resume // will not work" — the yargs negation of `--chat-recording`. CLIAgent::Qwen => &["--no-chat-recording"], + // Print mode still emits a session id in hooks when persistence + // is disabled, but there is no saved conversation to reopen. + CLIAgent::QoderCLI => &["--no-session-persistence"], _ => &[], }; argv.iter().any(|t| ephemeral.contains(&t.as_str())) @@ -202,6 +227,9 @@ impl CLIAgent { "claude{flags} --resume {session_id} --fork-session" )), CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id} --fork-session")), + CLIAgent::QoderCLI => Some(format!( + "qodercli{flags} --resume {session_id} --fork-session" + )), CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id} --fork")), CLIAgent::OhMyPi => Some(format!("omp{flags} --fork {session_id}")), // Droid forks with a standalone flag rather than resume-plus-a-switch. @@ -229,7 +257,8 @@ impl CLIAgent { | CLIAgent::Droid | CLIAgent::Amp | CLIAgent::Qwen - | CLIAgent::Goose => Some("Fork Session"), + | CLIAgent::Goose + | CLIAgent::QoderCLI => Some("Fork Session"), _ => None, } } @@ -393,6 +422,26 @@ impl CLIAgent { "--worktree-ref", "--ref", ], + // `--resume`/`-r` restores a past session and `--continue`/`-c` the + // most recent one; `--session-id` is a third spelling of the same + // thing. All three clash with the `--resume {id}` this command + // appends, and `--fork-session` is the flag the fork variant + // appends itself. `--worktree` would create or switch trees again; + // Qoder's `-w` means `--cwd` and must survive. + CLIAgent::QoderCLI => &[ + "--resume", + "-r", + "--continue", + "-c", + "--session-id", + "--fork-session", + "--worktree", + ], + // `--session`/`-s` names the conversation to restore and + // `--continue`/`-C` the most recent one; both clash with the + // `--session {id}` this command appends. `-c` is *not* in that + // group here — Crush spells `--cwd` with it, and it must survive. + CLIAgent::Crush => &["--session", "-s", "--continue", "-C"], _ => &[], }; let mut i = 0; @@ -457,6 +506,9 @@ impl CLIAgent { // The blue of the flame in Kimi's brand mark; the glyph itself is // black, which Codex and Grok already have covered. CLIAgent::Kimi => 0x027AFF, + CLIAgent::QoderCLI => 0xFFFFFF, + // The blue-violet field Charm ships the Crush heart on. + CLIAgent::Crush => 0x6B50FF, } } @@ -475,6 +527,7 @@ impl CLIAgent { pub fn icon_rgb(self) -> u32 { match self { CLIAgent::TraeCode => 0x32F08C, + CLIAgent::QoderCLI => 0x000000, _ => 0xFFFFFF, } } @@ -496,6 +549,8 @@ impl CLIAgent { CLIAgent::OhMyPi => "icons/agents/omp.svg", CLIAgent::Qwen => "icons/agents/qwen.svg", CLIAgent::Kimi => "icons/agents/kimi.svg", + CLIAgent::QoderCLI => "icons/agents/qodercli.svg", + CLIAgent::Crush => "icons/agents/crush.svg", CLIAgent::Aider | CLIAgent::Auggie | CLIAgent::Hermes @@ -659,6 +714,12 @@ pub struct AgentSessionState { pub cwd: Option, #[serde(default)] pub activity: u64, + /// How many turns this session has finished: bumped each time it settles + /// into `Done`. The status alone cannot tell a client that attaches to a + /// `Done` pane whether that is the turn it already showed the reader or a + /// later one that finished while nobody was watching (#870). + #[serde(default)] + pub turns: u64, } impl AgentStatus { @@ -712,6 +773,9 @@ impl AgentSessionState { } } AgentEventKind::Stop => { + if self.status != AgentStatus::Done { + self.turns = self.turns.wrapping_add(1); + } self.status = AgentStatus::Done; self.message = ev.message.clone(); } @@ -848,6 +912,32 @@ mod tests { ); } + /// The npm package installs `qoder` and `qodercli`, and the documentation + /// tells people to run the first one. Both are `#!/usr/bin/env node` + /// scripts, so what the pty carries is node plus the path to the shim — + /// the dispatcher's own child, which is where the name `qodercli` appears + /// on that path, is not the process group leader and is never read. + #[test] + fn qoder_is_detected_through_either_of_its_binaries() { + for launcher in [ + "qoder", + "qodercli", + "/opt/homebrew/bin/qoder", + "/opt/homebrew/bin/qodercli", + ] { + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["node", launcher])), + Some(CLIAgent::QoderCLI), + "on {launcher}" + ); + assert_eq!( + CLIAgent::detect_from_argv(&argv(&[launcher])), + Some(CLIAgent::QoderCLI), + "on {launcher}" + ); + } + } + #[test] fn detects_npx_package_form() { assert_eq!( @@ -949,6 +1039,8 @@ mod tests { ("/opt/homebrew/bin/omp", CLIAgent::OhMyPi), ("kimi", CLIAgent::Kimi), ("/usr/local/bin/kimi", CLIAgent::Kimi), + ("crush", CLIAgent::Crush), + ("/opt/homebrew/bin/crush", CLIAgent::Crush), ] { assert_eq!(CLIAgent::detect_from_argv(&argv(&[cmd])), Some(agent)); } @@ -1190,6 +1282,36 @@ mod tests { assert_eq!(s.activity, 4); } + #[test] + fn each_finished_turn_is_counted_once() { + let ev = |kind| AgentEvent { + agent: Some(CLIAgent::Claude), + kind, + session_id: None, + message: None, + cwd: None, + prompt: None, + }; + + let mut s = AgentSessionState::default(); + s.apply_event(&ev(AgentEventKind::PromptSubmit)); + assert_eq!(s.turns, 0, "a turn starting has not finished anything"); + + s.apply_event(&ev(AgentEventKind::Stop)); + assert_eq!(s.turns, 1); + s.apply_event(&ev(AgentEventKind::Stop)); + assert_eq!(s.turns, 1, "a repeated stop is the same turn"); + s.apply_event(&ev(AgentEventKind::Notification)); + assert_eq!(s.turns, 1); + + s.apply_event(&ev(AgentEventKind::PromptSubmit)); + s.apply_event(&ev(AgentEventKind::Stop)); + assert_eq!(s.turns, 2, "a second turn is a second count"); + + s.apply_event(&ev(AgentEventKind::SessionEnd)); + assert_eq!(s.turns, 2); + } + #[test] fn session_state_tracks_and_releases_the_agent_cwd() { use std::path::PathBuf; @@ -1508,6 +1630,124 @@ mod tests { .as_deref(), Some("grok --yolo --resume g-3") ); + assert_eq!( + CLIAgent::QoderCLI + .resume_command("q-1", Some(&argv(&["qodercli", "--model", "qoder-1"]))) + .as_deref(), + Some("qodercli --model qoder-1 --resume q-1") + ); + assert_eq!( + CLIAgent::QoderCLI + .resume_command( + "q-2", + Some(&argv(&["qodercli", "--resume", "q-1", "--fork-session"])) + ) + .as_deref(), + Some("qodercli --resume q-2"), + "a stale --resume id and --fork-session come off before the new one goes on" + ); + assert_eq!( + CLIAgent::QoderCLI + .resume_command( + "q-3", + Some(&argv(&["qodercli", "--session-id", "old", "--yolo"])) + ) + .as_deref(), + Some("qodercli --yolo --resume q-3"), + "`--session-id` names a new session and is rejected next to `--resume`" + ); + } + + #[test] + fn qoder_resume_and_fork_do_not_recreate_worktrees() { + for worktree in [ + vec!["--worktree"], + vec!["--worktree", "old-tree"], + vec!["--worktree=old-tree"], + ] { + for cwd_flag in ["-w", "--cwd"] { + let mut launch = argv(&["qodercli", "--model", "qoder-1"]); + launch.extend(argv(&worktree)); + launch.extend(argv(&[cwd_flag, "/repo/current-tree"])); + assert_eq!( + CLIAgent::QoderCLI.resume_command("q-1", Some(&launch)), + Some(format!( + "qodercli --model qoder-1 {cwd_flag} /repo/current-tree --resume q-1" + )), + "launch argv: {launch:?}" + ); + assert_eq!( + CLIAgent::QoderCLI.fork_command("q-1", Some(&launch)), + Some(format!( + "qodercli --model qoder-1 {cwd_flag} /repo/current-tree --resume q-1 --fork-session" + )), + "launch argv: {launch:?}" + ); + } + } + } + + #[test] + fn qoder_session_commands_require_persistence() { + let ephemeral = argv(&["qodercli", "--print", "--no-session-persistence"]); + assert_eq!( + CLIAgent::QoderCLI.resume_command("q-1", Some(&ephemeral)), + None + ); + assert_eq!( + CLIAgent::QoderCLI.fork_command("q-1", Some(&ephemeral)), + None + ); + + let persistent = argv(&["qodercli", "--model", "qoder-1"]); + assert_eq!( + CLIAgent::QoderCLI + .resume_command("q-1", Some(&persistent)) + .as_deref(), + Some("qodercli --model qoder-1 --resume q-1") + ); + assert_eq!( + CLIAgent::QoderCLI + .fork_command("q-1", Some(&persistent)) + .as_deref(), + Some("qodercli --model qoder-1 --resume q-1 --fork-session") + ); + } + + #[test] + fn crush_resumes_by_session_and_keeps_its_cwd_flag() { + let argv = |parts: &[&str]| parts.iter().map(|s| s.to_string()).collect::>(); + + assert_eq!( + CLIAgent::Crush.resume_command("c-1", None).as_deref(), + Some("crush --session c-1") + ); + assert_eq!( + CLIAgent::Crush + .resume_command("c-2", Some(&argv(&["crush", "--session", "c-1", "--yolo"]))) + .as_deref(), + Some("crush --yolo --session c-2"), + "a stale --session and its id come off before the new one goes on" + ); + assert_eq!( + CLIAgent::Crush + .resume_command("c-3", Some(&argv(&["crush", "--continue", "-C", "--yolo"]))) + .as_deref(), + Some("crush --yolo --session c-3"), + "both spellings of continue are stale with it" + ); + // `-c` is `--cwd` in Crush, not `--continue`, and must survive. + assert_eq!( + CLIAgent::Crush + .resume_command("c-4", Some(&argv(&["crush", "-c", "/repo/tree", "--yolo"]))) + .as_deref(), + Some("crush -c /repo/tree --yolo --session c-4") + ); + assert_eq!( + CLIAgent::Crush.fork_command("c-1", None), + None, + "Crush has no fork command" + ); } #[test] diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index eb725298..9585ef52 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -110,6 +110,23 @@ impl serde::Serialize for FontFeatures { } } +/// One action's line in `keybindings`. +/// +/// The two shapes mean different things, so a save writes back whichever one +/// was read. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(untagged)] +pub enum KeybindingOverride { + /// `"NextTab": "cmd-shift-]"` — a chord *beside* the ones the action + /// already has, the way VS Code, Zed and kitty read a line like it (#868). + /// Empty unbinds the action, which is what `""` has always meant here. + Add(String), + /// `"NextTab": ["cmd-shift-]"]` — exactly these chords, replacing the + /// default and the preset's. `[]` unbinds. This is what the Settings page + /// writes, because recording a shortcut there sets it. + Exact(Vec), +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct Config { @@ -150,7 +167,16 @@ pub struct Config { pub window_backdrop: WindowBackdrop, #[serde(default = "default_true")] pub dim_inactive_panes: bool, - pub keybindings: HashMap, + /// Lenient one entry at a time, for the same reason the nested keys below + /// are: this is hand-edited, and it used to be all-or-nothing. A single + /// value serde could not read — `"ActivateTab1": null`, a number, an object + /// — failed the whole `Config`, which quarantines `config.json` and starts + /// the app on built-in defaults; every rebinding in the file then read as + /// its shipped default, and the next settings write persisted those + /// defaults over what the user wrote (#901). A line that cannot be read is + /// skipped with a warning, and the rest of the map still binds. + #[serde(default, deserialize_with = "de_keybindings")] + pub keybindings: HashMap, #[serde(default = "default_preset")] pub keybinding_preset: String, #[serde(default = "default_prefix")] @@ -1193,6 +1219,37 @@ fn default_sidebar_width() -> f32 { pub const MAX_SCROLLBACK: usize = 100_000; +/// `keybindings`, read one line at a time. +/// +/// [`de_lenient`] is all-or-nothing per field, which for a map means one bad +/// line throwing away every good one. Here each entry stands on its own: the +/// ones that name a shortcut or a list of shortcuts bind, the ones that do not +/// are logged and dropped. A `keybindings` that is not an object at all falls +/// back to an empty map rather than failing the file. +fn de_keybindings<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = serde_json::Value::deserialize(deserializer)?; + let serde_json::Value::Object(entries) = value else { + log::warn!("ignoring `keybindings` {value}: expected an object of action name to shortcut"); + return Ok(HashMap::new()); + }; + let mut bindings = HashMap::with_capacity(entries.len()); + for (action, raw) in entries { + match KeybindingOverride::deserialize(&raw) { + Ok(binding) => { + bindings.insert(action, binding); + } + Err(e) => log::warn!( + "ignoring keybinding for {action:?}: {raw} is not a shortcut, \ + a list of shortcuts, or \"\" to unbind ({e})" + ), + } + } + Ok(bindings) +} + pub(crate) fn de_lenient<'de, D, T>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -1929,6 +1986,53 @@ mod tests { assert!(cfg.keybindings.is_empty()); } + #[test] + fn keybindings_take_a_chord_or_a_list_and_write_back_what_they_read() { + // A string is the shape every config written before #868 has, from the + // Settings page and by hand; a list is the one that replaces an + // action's chords outright. Both have to load, and a save must not turn + // one into the other — the two mean different things. + let written = serde_json::json!({ + "NextTab": "cmd-shift-]", + "AlternatePaste": "", + "PrevTab": ["cmd-shift-[", "ctrl-shift-tab"], + "SplitRight": [], + }); + let cfg: Config = + serde_json::from_value(serde_json::json!({ "keybindings": written.clone() })) + .expect("both shapes load"); + assert_eq!(cfg.keybindings.len(), 4); + assert_eq!(serde_json::to_value(&cfg.keybindings).unwrap(), written); + } + + #[test] + fn a_keybinding_line_that_cannot_be_read_does_not_take_the_config_with_it() { + // #901: one unreadable line used to fail the whole `Config`. The loader + // then quarantines config.json and starts on built-in defaults, so + // every rebinding in the file — the point of the file — came back as + // the shipped default, and the next settings write made that permanent. + let cfg: Config = serde_json::from_str( + r#"{"font_size": 20.0, + "keybindings": {"ActivateTab1": null, "ActivateTab2": 2, + "ActivateTab3": {"key": "alt-shift-3"}, + "ActivateTab4": [], "NextTab": "ctrl-alt-]"}}"#, + ) + .expect("a bad keybinding line must not fail the file"); + assert_eq!(cfg.font_size, 20.0, "the rest of the file still loads"); + assert_eq!( + serde_json::to_value(&cfg.keybindings).unwrap(), + serde_json::json!({"ActivateTab4": [], "NextTab": "ctrl-alt-]"}), + "the readable lines survive and the unreadable ones are dropped" + ); + + // And a `keybindings` that is not a map at all is no reason to hand + // the user back default fonts, themes and everything else. + let odd: Config = serde_json::from_str(r#"{"font_size": 20.0, "keybindings": []}"#) + .expect("a keybindings of the wrong shape must not fail the file"); + assert_eq!(odd.font_size, 20.0); + assert!(odd.keybindings.is_empty()); + } + fn pin_config_dir() { let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); std::fs::create_dir_all(&dir).ok(); diff --git a/crates/tty7-core/src/core/osc.rs b/crates/tty7-core/src/core/osc.rs index 83504084..82a3177d 100644 --- a/crates/tty7-core/src/core/osc.rs +++ b/crates/tty7-core/src/core/osc.rs @@ -147,6 +147,104 @@ pub fn parse_notification(payload: &[u8]) -> Option<(Option, String)> { None } +/// What a sequence did to the title a pane is showing — see +/// [`TitleLifetime`]. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum TitleEffect { + /// Nothing to do with the title. + None, + /// An OSC 0/2: the pane just named itself. + Set, + /// The command that set the title showing has finished, so the title + /// describes something that is no longer running and has to go. + Retire, +} + +/// How long a title a program set outlives the program (#889). +/// +/// An OSC 0/2 has no owner and no end: whatever a pane last named itself +/// stands until something else names it. That is right while the program that +/// wrote it is still running, and wrong the instant it exits — a tab that goes +/// on reading "✳ refactoring the parser" after Claude Code has quit is naming +/// a session that no longer exists, and the pane's directory (the rung below +/// it in the label ladder) would describe it far better. +/// +/// The shell integration already says when a command starts and stops: OSC +/// 133;C and 133;D. So a title set *between* them belongs to that command and +/// is retired by its `D`; a title set at a prompt — the shell's own, or one +/// the reader pinned by hand with a bare `printf '\e]0;…'` — belongs to +/// nobody in particular and is left alone. A pane with no shell integration +/// sees neither mark and so keeps every title, exactly as before. +/// +/// Both the daemon (which keeps `PaneRecord::osc_title` for the switcher and +/// the CLI) and the window (which keeps its own terminal's title for its tab +/// strip) run this over the same bytes, so the two can never disagree about +/// whether a title is still current. Feeding it in stream order is what makes +/// the answer right: a shell that re-titles itself in `precmd` emits its OSC +/// 0/2 *after* the `D`, and that [`Set`](TitleEffect::Set) is simply the last +/// word. +#[derive(Debug, Default, Clone, Copy)] +pub struct TitleLifetime { + /// A command owns the pane: a `C` has arrived and its `D` has not. + running: bool, + /// The title standing right now was set while a command owned the pane. + from_command: bool, + /// Any `133` prompt mark (`A`–`D`) has been read at all. + marked: bool, +} + +impl TitleLifetime { + /// Reads one OSC payload — identifier included, as + /// [`OscTokenizer`] reports it — and says what it did to the title. + pub fn saw(&mut self, payload: &[u8]) -> TitleEffect { + if payload.starts_with(b"0;") || payload.starts_with(b"2;") { + self.from_command = self.running; + return TitleEffect::Set; + } + let Some(rest) = payload.strip_prefix(b"133;") else { + return TitleEffect::None; + }; + if matches!(rest.first(), Some(b'A'..=b'D')) { + self.marked = true; + } + match rest.first() { + Some(b'C') => self.running = true, + Some(b'D') => { + let retire = self.running && self.from_command; + // Whatever stands after this mark was not written by a + // command that is still running, whoever wrote it. + self.running = false; + self.from_command = false; + if retire { + return TitleEffect::Retire; + } + } + // `A` and `B` only draw a prompt. They must not retire anything: + // a shell's own `precmd` title lands between the `D` and the `A`. + _ => {} + } + TitleEffect::None + } + + /// The stream was picked up partway through a command whose `C` mark is + /// not in it — a window reattaching to a pane whose replay ring rolled + /// past the `C` while a long session (an agent, an editor) ran on. + /// + /// If what was read carried no prompt mark at all, every byte of it was + /// written under that command, titles included, so the command's `D` + /// must retire them just as it would have on a link that saw the `C`. + /// Without this a reattached window keeps the dead program's title + /// forever — exactly #889 — while the daemon, which saw the whole stream, + /// has already dropped it. Any mark read settles the question on its own, + /// so this does nothing then. + pub fn joined_mid_command(&mut self) { + if !self.marked { + self.running = true; + self.from_command = true; + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -271,6 +369,116 @@ mod tests { assert_eq!(got, vec![(7, b"777;notify;x".to_vec())]); } + /// Feeds a stream through the tokenizer the way both readers do and + /// reports what the title ended up being: `Some(t)` for a title that + /// stands, `None` for one that was retired or never set. + fn showing(stream: &[u8]) -> Option { + let mut tok = OscTokenizer::new(&[b"0", b"2", b"133"]); + let mut life = TitleLifetime::default(); + let mut title = None; + tok.feed(stream, |payload| match life.saw(payload) { + TitleEffect::Set => { + let body = payload.split(|&b| b == b';').nth(1).unwrap_or(b""); + title = (!body.is_empty()).then(|| String::from_utf8_lossy(body).into_owned()); + } + TitleEffect::Retire => title = None, + TitleEffect::None => {} + }); + title + } + + /// The bug in #889: Claude Code names the tab, quits, and the name stays + /// on a pane that is back at its own prompt in a real directory. + #[test] + fn a_title_a_command_set_is_retired_when_that_command_finishes() { + assert_eq!( + showing(b"\x1b]133;C;claude\x07\x1b]0;refactoring the parser\x07\x1b]133;D;0\x07"), + None, + "the program that named the tab has exited" + ); + } + + /// The case the title is *supposed* to stick for: a TUI that names itself + /// once and keeps running. + #[test] + fn a_title_of_a_command_still_running_stands() { + assert_eq!( + showing(b"\x1b]133;C;vim\x07\x1b]0;vim \xe2\x80\x94 main.rs\x07").as_deref(), + Some("vim — main.rs"), + ); + } + + /// A shell that re-titles itself in `precmd` writes its OSC 0/2 after the + /// `D` and before the `A` (tty7's own zsh helper prepends the `D` emitter + /// for exactly that reason, and the PowerShell one titles between them). + /// Reading the stream in order is what keeps that title. + #[test] + fn a_title_the_shell_writes_at_its_next_prompt_is_the_last_word() { + assert_eq!( + showing( + b"\x1b]133;C;claude\x07\x1b]0;claude\x07\ + \x1b]133;D;0\x07\x1b]0;me@box:~/dev\x07\x1b]133;A\x07\x1b]133;B\x07" + ) + .as_deref(), + Some("me@box:~/dev"), + ); + } + + /// A title nobody's command set — the shell's, or one pinned by hand at a + /// prompt — is not a command's to retire. + #[test] + fn a_title_set_at_a_prompt_survives_the_next_command() { + assert_eq!( + showing( + b"\x1b]133;A\x07\x1b]0;my tab\x07\x1b]133;B\x07\ + \x1b]133;C;ls\x07\x1b]133;D;0\x07" + ) + .as_deref(), + Some("my tab"), + ); + } + + /// Without shell integration there are no marks at all, and a title is + /// kept the way it always was. + #[test] + fn a_pane_with_no_marks_keeps_every_title() { + assert_eq!(showing(b"\x1b]2;anything\x07").as_deref(), Some("anything")); + let mut life = TitleLifetime::default(); + assert_eq!(life.saw(b"7;file://h/x"), TitleEffect::None); + assert_eq!(life.saw(b"133;V;1"), TitleEffect::None); + } + + /// A `D` with no command before it reports the shell's own startup, not a + /// command that ended; there is nothing of anyone's to retire. + #[test] + fn a_d_mark_with_no_command_before_it_retires_nothing() { + let mut life = TitleLifetime::default(); + assert_eq!(life.saw(b"0;pinned"), TitleEffect::Set); + assert_eq!(life.saw(b"133;D;0"), TitleEffect::None); + } + + /// A reattach whose replay ring no longer holds the running command's `C`: + /// the titles in it are that command's, and its `D` retires them. + #[test] + fn a_stream_joined_mid_command_retires_its_title_at_the_d() { + let mut life = TitleLifetime::default(); + assert_eq!( + life.saw(b"2;\xe2\x9c\xb3 fixing the switcher"), + TitleEffect::Set + ); + life.joined_mid_command(); + assert_eq!(life.saw(b"133;D;0"), TitleEffect::Retire); + + // A replay that carried marks already knows who owns the title: a + // title pinned at a prompt stays pinned through the next command. + let mut life = TitleLifetime::default(); + assert_eq!(life.saw(b"133;B"), TitleEffect::None); + assert_eq!(life.saw(b"0;my tab"), TitleEffect::Set); + life.joined_mid_command(); + assert_eq!(life.saw(b"133;C;ls"), TitleEffect::None); + assert_eq!(life.saw(b"133;D;0"), TitleEffect::None); + } + #[test] fn esc_runs_and_non_osc_escapes_do_not_confuse_the_scanner() { assert_eq!( diff --git a/crates/tty7-core/src/core/tab_view.rs b/crates/tty7-core/src/core/tab_view.rs index 149c585f..bbed34aa 100644 --- a/crates/tty7-core/src/core/tab_view.rs +++ b/crates/tty7-core/src/core/tab_view.rs @@ -91,6 +91,73 @@ pub fn strip_host_prefix(raw: &str) -> &str { } } +/// The marks a coding agent writes in front of the title it sets while it +/// works, and which of them to take back off. +/// +/// Agents animate in the terminal title and do not agree on an alphabet: +/// Claude Code cycles the quadrant circles and rests on an asterisk, others +/// step through the braille frames, some write nothing. Rendered as they +/// arrive, a column of tabs carries a mark in front of some rows and not +/// others, in three vocabularies, while the row already says what the agent is +/// doing — in one, with its status dot. +/// +/// **A known alphabet, not a shape.** The obvious rule — a leading character +/// that is non-ASCII and above some code point, followed by a space — matches +/// by shape, and a tab called `🔥 build`, or `📁 ~/repo` from somebody's shell +/// integration, fits it exactly and loses its first character with no way to +/// ask for it back and no clue as to what took it. Matching a list of marks we +/// have actually seen costs the same and cannot do that. When an agent invents +/// a mark that is not here yet the failure is today's behaviour — the mark +/// stays — which is the safe direction to fail in, and adding it is a line in +/// the table below. +const STATUS_MARKS: &[char] = &[ + // Claude Code: the quadrant circles while it works, the asterisk at rest. + '\u{25D0}', '\u{25D1}', '\u{25D2}', '\u{25D3}', '\u{2733}', +]; + +/// Whether `c` is one of the braille cells the common spinners are built from. +/// The whole block, because the frame sets differ between agents and every +/// cell in it is a spinner frame somewhere — none is a character a human puts +/// at the front of a tab's name. +fn is_braille_frame(c: char) -> bool { + ('\u{2800}'..='\u{28FF}').contains(&c) +} + +/// `title` with any leading status marks taken off. +/// +/// A mark only counts with whitespace behind it, which is how every agent +/// writes one and is one more thing a title would have to do by accident. +/// Variation selectors and zero-width joiners ride along with the mark. +pub fn strip_status_mark(title: &str) -> &str { + let mut rest = title; + loop { + let mut chars = rest.chars(); + let Some(first) = chars.next() else { + return rest; + }; + if !STATUS_MARKS.contains(&first) && !is_braille_frame(first) { + return rest; + } + let after = chars + .as_str() + .trim_start_matches(|c: char| matches!(c, '\u{FE00}'..='\u{FE0F}' | '\u{200D}')); + let trimmed = after.trim_start(); + // Nothing between the mark and the rest of the title: a title that + // happens to start with the character, not a mark in front of one. + if trimmed.len() == after.len() { + return rest; + } + // A mark with nothing behind it is the whole title. Taking it would + // leave an empty string, and an empty title is not a tab called + // nothing — it is a tab that falls back to its number, which is less + // than the mark was saying. + if trimmed.is_empty() { + return rest; + } + rest = trimmed; + } +} + impl TabView { pub fn label(&self) -> TabLabel<'_> { if let Some(name) = self @@ -105,6 +172,7 @@ impl TabView { .osc_title .as_deref() .map(str::trim) + .map(strip_status_mark) .filter(|t| !t.is_empty()) { return TabLabel::Osc(title); @@ -159,6 +227,72 @@ pub fn tab_views_of(ws: &Workspace, panes: &[PaneRecord]) -> Vec { #[cfg(test)] mod tests { + + /// The marks come off, whichever alphabet the agent picked. + #[test] + fn a_status_mark_comes_off_the_front_of_a_title() { + for raw in [ + "\u{2733} fixing the switcher", // Claude Code at rest + "\u{25D0} fixing the switcher", // and while it works + "\u{25D3} fixing the switcher", + "\u{280B} fixing the switcher", // a braille frame + "\u{28FF} fixing the switcher", // the far end of the block + "\u{2733}\u{FE0F} fixing the switcher", // with an emoji selector + "\u{2733} \u{280B} fixing the switcher", // two of them, both go + ] { + assert_eq!(strip_status_mark(raw), "fixing the switcher", "on {raw:?}"); + } + } + + /// The reason this matches an alphabet rather than a shape. Every one of + /// these fits "leading non-ASCII character above U+2000, then a space", + /// and every one of them is somebody's title rather than an agent's mark — + /// a shape rule eats the first character of each, silently. + #[test] + fn a_title_that_merely_looks_like_one_is_left_alone() { + for raw in [ + "\u{1F525} build", // fire, a name somebody chose + "\u{1F4C1} ~/repo", // folder, from a shell integration + "\u{2192} deploy", // an arrow + "\u{2714} done", // a tick + "\u{2022} notes", // a bullet + "\u{4E2D}\u{6587} title", // a title in a script with no case + "\u{2733}fixing", // no space: part of the word + "fixing the switcher", // nothing to take + "", + ] { + assert_eq!(strip_status_mark(raw), raw, "on {raw:?}"); + } + } + + /// A name the user typed is theirs, mark or no mark. The strip is for the + /// title an agent writes, and `label` reaches the name first — but that + /// ordering is the only thing keeping a tab someone deliberately called + /// `\u{2733} release` from being renamed behind their back, so it is worth + /// saying out loud. + #[test] + fn a_name_the_user_gave_is_never_stripped() { + let view = TabView { + id: TabId::new(), + name: Some("\u{2733} release".to_string()), + title: "zsh".to_string(), + osc_title: Some("\u{2733} fixing the switcher".to_string()), + cwd: None, + agent: None, + status: None, + live: true, + panes: 1, + }; + assert_eq!(view.label(), TabLabel::Named("\u{2733} release")); + } + + /// A title that is only a mark keeps it, rather than becoming empty and + /// falling through to the tab's number. + #[test] + fn a_mark_on_its_own_is_still_a_title() { + assert_eq!(strip_status_mark("\u{2733}"), "\u{2733}"); + assert_eq!(strip_status_mark("\u{2733} "), "\u{2733} "); + } use super::*; use crate::core::machine::{AgentFacts, Tab}; @@ -196,7 +330,7 @@ mod tests { cwd: Some("/work".into()), ..view() }; - assert_eq!(titled.label(), TabLabel::Osc("✳ fixing the switcher")); + assert_eq!(titled.label(), TabLabel::Osc("fixing the switcher")); let blank_title = TabView { osc_title: Some(" ".into()), diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index 72bb557f..dc47d25c 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -938,6 +938,11 @@ struct ClientInner { blobs: Mutex>>, connected: AtomicBool, last_inbound: Mutex, + /// How long the last `Ping` took to come back, in microseconds; zero until + /// one has. Only `Ping` is timed: every other request does work on the far + /// side, so its round trip measures that work and not the link, and a + /// `ReadFile` of a large file would report the network as seconds slow. + last_rtt_us: AtomicU64, hello: ControlHelloOk, shutdown: Option>, reader_done: Mutex, @@ -1016,6 +1021,7 @@ impl ControlClient { blobs: Mutex::new(HashMap::new()), connected: AtomicBool::new(true), last_inbound: Mutex::new(Instant::now()), + last_rtt_us: AtomicU64::new(0), hello: ok, shutdown, reader_done: Mutex::new(false), @@ -1059,6 +1065,19 @@ impl ControlClient { .unwrap_or_default() } + /// The last measured round trip to the peer, or `None` on a link nothing + /// has pinged yet. + /// + /// Fed by every [`ControlRequest::Ping`] that comes back — the keepalive's + /// as well as any a caller sends itself — so a link that is being kept + /// alive already carries a number without anyone asking for one. + pub fn last_rtt(&self) -> Option { + match self.inner.last_rtt_us.load(Ordering::Relaxed) { + 0 => None, + us => Some(Duration::from_micros(us)), + } + } + pub fn call(&self, req: ControlRequest) -> io::Result { self.call_full(req, &[]).map(|r| r.reply) } @@ -1086,6 +1105,9 @@ impl ControlClient { } let req_id = self.inner.next_req_id.fetch_add(1, Ordering::Relaxed); + // Read off before `req` is moved into the message below. + let timed = matches!(req, ControlRequest::Ping); + let sent_at = Instant::now(); log::debug!(target: "tty7::control", "#{req_id} {req:?}"); let (tx, rx) = sync_channel(1); self.inner.pending()?.insert(req_id, tx); @@ -1108,9 +1130,21 @@ impl ControlClient { match rx.recv_timeout(deadline) { Ok(reply) => { let blob = self.inner.take_blob(req_id); - reply + let out = reply .into_result() - .map(|reply| ControlResponse { reply, blob }) + .map(|reply| ControlResponse { reply, blob }); + // Only a ping that actually came back. A timeout leaves the + // last good number in place rather than recording the deadline + // as the link's latency, and a refusal measures the peer's + // opinion of the request rather than the distance to it. + if timed && out.is_ok() { + let us = sent_at.elapsed().as_micros().min(u128::from(u64::MAX)) as u64; + // Zero means "never measured", so a sub-microsecond round + // trip on a loopback link rounds up rather than reading as + // no measurement at all. + self.inner.last_rtt_us.store(us.max(1), Ordering::Relaxed); + } + out } Err(RecvTimeoutError::Timeout) => { self.inner.forget(req_id); @@ -1343,6 +1377,7 @@ mod tests { blobs: Mutex::new(HashMap::new()), connected: AtomicBool::new(true), last_inbound: Mutex::new(Instant::now()), + last_rtt_us: AtomicU64::new(0), hello: ControlHelloOk { control_version: CONTROL_VERSION, protocol_version: 0, @@ -1572,6 +1607,7 @@ mod tests { rich: true, cwd: Some("/work/api".into()), activity: 3, + turns: 1, }, }])), ControlReply::Ok(ReplyOk::AgentStates(Vec::new())), diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 29a0e542..7ece7ed2 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -3301,6 +3301,9 @@ struct SniffSignals { struct OscSniffer { tok: OscTokenizer, shell: ShellState, + /// Whether the title the pane is showing still belongs to something that + /// is running — see [`crate::core::osc::TitleLifetime`] (#889). + title_life: crate::core::osc::TitleLifetime, } impl OscSniffer { @@ -3308,13 +3311,20 @@ impl OscSniffer { Self { tok: OscTokenizer::new(&[b"0", b"2", b"7", b"133", b"9", b"777"]), shell: ShellState::default(), + title_life: crate::core::osc::TitleLifetime::default(), } } fn feed(&mut self, bytes: &[u8]) -> SniffSignals { let mut signals = SniffSignals::default(); let shell = &mut self.shell; + let title_life = &mut self.title_life; self.tok.feed(bytes, |payload| { + // In stream order, so that a shell which re-titles itself right + // after the `D` mark gets the last word over the retirement. + if title_life.saw(payload) == crate::core::osc::TitleEffect::Retire { + signals.title = Some(String::new()); + } if let Some(path) = parse_osc7(payload) { signals.cwd = Some(path); } else if let Some(title) = parse_osc_title(payload) { @@ -4377,6 +4387,87 @@ mod tests { assert_eq!(st.osc_title, None, "an empty title clears, not blanks"); } + /// #889: the tab went on reading "✳ fixing the switcher" long after Claude + /// Code had exited and the pane was back at its own prompt, because + /// nothing in the pane path ever retired an OSC 0/2 — only another one + /// replaced it. The `D` mark says the command that wrote it is over. + #[test] + fn a_title_a_command_set_is_retired_when_that_command_finishes() { + let mut st = test_state(true); + let mut s = OscSniffer::new(); + + let mut read = |st: &mut PaneState, bytes: &[u8]| apply_signals(st, s.feed(bytes)); + + read( + &mut st, + b"\x1b]7;file://h/work/tty7\x07\x1b]133;A\x07\x1b]133;B\x07", + ); + read(&mut st, b"\x1b]133;C;claude\x07"); + read(&mut st, b"\x1b]2;\xe2\x9c\xb3 fixing the switcher\x1b\\"); + assert_eq!( + st.osc_title.as_deref(), + Some("✳ fixing the switcher"), + "a running command names the tab" + ); + + // The user's precmd chain can take hundreds of ms, which is why the + // `D` emitter is prepended to it — so the mark routinely lands in a + // read of its own, ahead of anything the next prompt writes. + read(&mut st, b"\x1b]133;D;0\x07"); + assert_eq!( + st.osc_title, None, + "the program that wrote the title has exited" + ); + assert_eq!( + st.cwd.as_deref(), + Some(std::path::Path::new("/work/tty7")), + "and the rung below it in the label ladder still knows the place" + ); + } + + /// The two cases the retirement must not touch: a program that is still + /// running, and a shell that titles its own prompt. + #[test] + fn a_running_program_and_a_shells_own_prompt_title_both_keep_theirs() { + let mut st = test_state(true); + let mut s = OscSniffer::new(); + + apply_signals(&mut st, s.feed(b"\x1b]133;C;vim\x07\x1b]2;vim\x1b\\")); + assert_eq!( + st.osc_title.as_deref(), + Some("vim"), + "nothing said the command ended" + ); + + // A shell that re-titles itself does it between the `D` and the `A` + // (tty7's zsh helper prepends the `D`; the PowerShell one titles in + // its prompt function), so the title is the last word in the read. + apply_signals( + &mut st, + s.feed(b"\x1b]133;D;0\x07\x1b]0;me@box:~/dev\x07\x1b]133;A\x07"), + ); + assert_eq!( + st.osc_title.as_deref(), + Some("me@box:~/dev"), + "the prompt's own title outranks the retirement it follows" + ); + + // And that prompt title is nobody's command to retire. + apply_signals(&mut st, s.feed(b"\x1b]133;C;ls\x07\x1b]133;D;0\x07")); + assert_eq!(st.osc_title.as_deref(), Some("me@box:~/dev")); + } + + /// A pane with no shell integration never sees a mark, so nothing changes + /// for it: whatever it last called itself is all tty7 has. + #[test] + fn a_pane_without_shell_integration_keeps_its_title() { + let mut st = test_state(true); + let mut s = OscSniffer::new(); + apply_signals(&mut st, s.feed(b"\x1b]0;user@host:~/dev\x07")); + apply_signals(&mut st, s.feed(b"lots of output\r\n")); + assert_eq!(st.osc_title.as_deref(), Some("user@host:~/dev")); + } + #[test] fn sniff_osc133_prompt() { let mut s = OscSniffer::new(); @@ -5125,6 +5216,7 @@ mod tests { rich: true, cwd: None, activity: 0, + turns: 0, }); apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07")); assert_eq!( diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 8bdad16f..0538c114 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -1840,6 +1840,7 @@ mod tests { rich: true, cwd: Some("/repo/.claude/worktrees/fix-x".into()), activity: 12, + turns: 4, })), DaemonMsg::AgentStatus(None), DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }), diff --git a/crates/tty7-core/src/host/mod.rs b/crates/tty7-core/src/host/mod.rs index 6bd0a7de..28291a03 100644 --- a/crates/tty7-core/src/host/mod.rs +++ b/crates/tty7-core/src/host/mod.rs @@ -173,6 +173,15 @@ pub trait WatchHandle: Send + Sync { pub trait Host: Send + Sync + 'static { fn id(&self) -> HostId; + /// How far away this host is: the round trip to it, measured now. + /// + /// `None` from a host with no link to measure — the local one, whose + /// "peer" is this process's own daemon over a Unix socket — and from a + /// remote one whose link is down or has not answered a ping yet. + fn link_rtt(&self) -> Option { + None + } + fn separator(&self) -> char; fn join(&self, dir: &Path, name: &str) -> PathBuf { diff --git a/crates/tty7-core/src/host/remote.rs b/crates/tty7-core/src/host/remote.rs index 7183abe8..c90896f4 100644 --- a/crates/tty7-core/src/host/remote.rs +++ b/crates/tty7-core/src/host/remote.rs @@ -253,6 +253,22 @@ impl Host for RemoteHost { }) } + fn link_rtt(&self) -> Option { + // A ping of our own rather than whatever the keepalive last left + // behind: that one only fires on an idle link, and a link being polled + // for this is by definition not idle, so its number would age out of + // date exactly while someone is watching it. + if self.client.is_connected() + && let Err(e) = self.client.ping() + { + // The last good measurement below still stands. A link that has + // just gone down reports the distance it had while it was up, + // which is better than a blank until something notices it is gone. + log::debug!("could not ping {:?}: {e}", self.id); + } + self.client.last_rtt() + } + /// The peer owns these panes' PTYs, so it is the one that can walk their /// process trees. A peer that does not announce the feature is not asked: /// it would answer `Err` and the caller cannot tell that apart from a pane @@ -1051,6 +1067,75 @@ mod tests { .unwrap() } + #[test] + fn link_rtt_pings_and_reports_what_came_back() { + let (host, seen) = host_with_peer('/', |req| match req { + ControlRequest::Ping => Some((ControlReply::Ok(ReplyOk::Pong), vec![])), + other => panic!("unexpected request {other:?}"), + }); + + assert!( + host.link_rtt().is_some(), + "a ping that came back is a measurement" + ); + assert_eq!(seen.recv().unwrap(), ControlRequest::Ping); + assert_eq!( + seen.try_recv().ok(), + None, + "one row is worth one round trip and no more" + ); + } + + /// The latency row must mean the distance to the peer, not how long the + /// peer spent on whatever was asked of it. Timing every call would put a + /// `ReadFile` of a large file, or a `Git` that shells out, on that row and + /// read as a network gone seconds slow. + #[test] + fn only_a_ping_is_timed() { + let (host, _seen) = host_with_peer('/', |req| match req { + ControlRequest::Ping => Some((ControlReply::Ok(ReplyOk::Pong), vec![])), + ControlRequest::Exists { .. } => Some((ControlReply::Ok(ReplyOk::Bool(true)), vec![])), + other => panic!("unexpected request {other:?}"), + }); + + assert_eq!( + host.client().last_rtt(), + None, + "a link nothing has pinged has no measurement to report" + ); + assert!(host.exists(Path::new("/etc/hosts"))); + assert_eq!( + host.client().last_rtt(), + None, + "an ordinary call measures the peer's work, so it leaves the link's latency alone" + ); + host.client().ping().unwrap(); + assert!(host.client().last_rtt().is_some()); + } + + /// A link that has just gone down keeps the distance it had while it was + /// up. Blanking the row on the first failed ping would take the number + /// away at exactly the moment someone is looking at it to work out why the + /// pane has stopped responding. + #[test] + fn a_link_that_goes_down_keeps_its_last_measurement() { + let served = std::sync::atomic::AtomicBool::new(true); + let (host, _seen) = host_with_peer('/', move |req| match req { + ControlRequest::Ping => match served.swap(false, Ordering::Relaxed) { + true => Some((ControlReply::Ok(ReplyOk::Pong), vec![])), + // Hanging up rather than answering, which is what a peer whose + // machine went away looks like from here. + false => None, + }, + other => panic!("unexpected request {other:?}"), + }); + + let measured = host.link_rtt().expect("the first ping came back"); + assert_eq!(host.link_rtt(), Some(measured)); + assert!(!host.is_connected(), "the second ping took the link down"); + assert_eq!(host.link_rtt(), Some(measured)); + } + #[test] fn shells_come_from_the_peer() { let (host, seen) = host_with_peer('/', |req| match req { diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx index dd543888..94fd0c10 100644 --- a/docs/agents/overview.mdx +++ b/docs/agents/overview.mdx @@ -1,6 +1,6 @@ --- title: "Coding agents" -description: "What tty7 does around Claude Code, Codex, TraeCode, and 17 others — without ever wrapping them." +description: "What tty7 does around Claude Code, Codex, TraeCode, and 19 others — without ever wrapping them." --- tty7 recognises coding agents running in a pane and builds around them. It does @@ -15,7 +15,7 @@ need, and what changed. ## Which agents -Twenty CLIs are recognised on sight, by the command running in the pane: +Twenty-two CLIs are recognised on sight, by the command running in the pane: | Agent | Command | |---|---| @@ -33,6 +33,8 @@ Twenty CLIs are recognised on sight, by the command running in the pane: | Grok | `grok` | | Qwen Code | `qwen`, `qwen-code` | | Kimi Code | `kimi`, `kimi-code` | +| Qoder CLI | `qoder`, `qodercli` | +| Crush | `crush` | | Auggie | `auggie` | | Hermes | `hermes` | | Vibe | `vibe`, `vibe-acp` | @@ -61,7 +63,7 @@ If you launch agents through a wrapper script, map its name to an agent in The key is your command's name; the value is one of the slugs above (`claude`, `codex`, `traecli`, `gemini`, `aider`, `amp`, `opencode`, `copilot`, `cursor`, `goose`, `droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`, -`omp`, `kimi`). +`omp`, `kimi`, `qodercli`, `crush`). ## What you get for free diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx index 095a9899..57ae6438 100644 --- a/docs/agents/status.mdx +++ b/docs/agents/status.mdx @@ -14,7 +14,7 @@ the agent say which one it is. | Agent | | |---|---| -| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code | Hooks available | +| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code · Qoder CLI · Crush | Hooks available | | Aider · Amp · Cursor · Auggie · Hermes · Vibe · Antigravity | Detected and labelled, but no status channel yet | Installing writes into that agent's own configuration directory. Once installed @@ -22,6 +22,12 @@ the row grows a second **Uninstall** button beside the first, which itself becomes **Reinstall** — or **Update**, against an **Outdated** state, when tty7 ships a newer hook. + + Crush ships only a `PreToolUse` hook today. It is enough to remember the + session for resume, but there is no turn boundary behind it, so a Crush pane + carries no status dot and `tty7 wait` times out rather than returning. + + The hooks only do anything inside tty7. Running the same agent in another terminal is unaffected. diff --git a/docs/customization/keybindings.mdx b/docs/customization/keybindings.mdx index b30d42ee..96f45502 100644 --- a/docs/customization/keybindings.mdx +++ b/docs/customization/keybindings.mdx @@ -19,7 +19,13 @@ Click a shortcut and press the new keys. It saves after a brief pause. | Press keys | Set the binding | | Press more keys | Chain a sequence — ⌃ B then X | | Esc | Cancel | -| ⌫ | Remove the last key — or, pressed first, reset the shortcut to its default | +| ⌫ | Remove the last key — or, pressed first, leave the action with **no shortcut** | + +Leaving an action unbound is how you hand a key back to whatever is running in +the terminal: ⌥ 1…⌥ 9 jump between tabs by default, and +vim wants them for its own tabs. Clear the nine **Go to Tab** rows and the +digits go straight through. A cleared row shows `—` and grows a **Reset** +button, which puts the default back. **Restore all defaults** at the bottom undoes every rebinding at once. There is no undo for that one. @@ -39,13 +45,32 @@ the panel tabs. They are all in the command palette, and all bindable here. ```json { "keybindings": { - "SplitRight": "cmd-d", + "NextTab": "cmd-shift-]", + "SplitRight": ["cmd-d"], "ResizePaneLeft": "ctrl-alt-left", - "ToggleSftp": "cmd-shift-u" + "ToggleFullscreen": "" } } ``` +An action's value takes one of two shapes: + +| Value | Means | +|---|---| +| `"cmd-shift-]"` | **Add** this shortcut. The default keeps working — ⌃ ⇥ still switches tabs | +| `["cmd-d"]` | **Replace**: exactly these shortcuts, nothing else. List several to have several | +| `""` or `[]` | **Unbind** the action | + +Recording a shortcut on the Settings page replaces, so it writes the list +shape. A shortcut you add that another action already uses is the one that +runs. The exception is the terminal's own shortcuts: copy, paste, find, clear +scrollback and insert newline keep theirs while a terminal has focus. + +Up to 26.9.2 a string replaced the default, and the Settings page wrote +strings. So a shortcut you recorded in those versions now works *beside* the +default it was meant to replace. To drop the default again, record the shortcut again +or wrap it in a list: `"NewTab": ["cmd-shift-n"]`. + The syntax is modifiers joined by `-`, then the key. Chords are separated by a space. @@ -55,8 +80,22 @@ space. | `cmd` · `ctrl` · `alt` · `shift` | Literal modifiers | | `ctrl-b n` | A two-key sequence | -An unknown action name or an invalid keystroke is skipped with a warning in the -log rather than breaking the rest of your bindings. +To keep a key for the program running in the terminal, unbind the action that +holds it. vim's tab keys, for instance: + +```json +{ + "keybindings": { + "ActivateTab1": [], "ActivateTab2": [], "ActivateTab3": [], + "ActivateTab4": [], "ActivateTab5": [], "ActivateTab6": [], + "ActivateTab7": [], "ActivateTab8": [], "ActivateTab9": [] + } +} +``` + +An unknown action name, an invalid keystroke, or a line that is neither a +shortcut nor a list of them is skipped with a warning in the log rather than +breaking the rest of your bindings. The full action list is on the [keyboard shortcuts](/reference/keyboard-shortcuts) page. diff --git a/docs/getting-started/first-launch.mdx b/docs/getting-started/first-launch.mdx index 59ee13f5..20e2e571 100644 --- a/docs/getting-started/first-launch.mdx +++ b/docs/getting-started/first-launch.mdx @@ -54,7 +54,7 @@ leave it off if you type accented characters. ## 4. If you use coding agents, install the hooks -**Settings → Integrations.** tty7 detects 20 coding CLIs by process name on its own — +**Settings → Integrations.** tty7 detects 22 coding CLIs by process name on its own — you get brand avatars and tab labels for free. The *status dots*, the "needs your permission" notifications, and `tty7 wait` all need one more thing: a small hook the agent calls to report what it is doing. diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 48b35c37..6f3d4090 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -46,6 +46,11 @@ AppImage bundles its own X11/Wayland/font libraries. tty7-app.exe --unregister-explorer-menu ``` + Nothing has to be installed first. The executables link the Visual C++ + runtime statically, so the "Visual C++ 2015–2022 Redistributable" is not a + prerequisite — releases up to and including 26.9.2 did need it, and failed + to start at all on a machine that had never installed it. + The Windows package also carries a Linux `tty7-server` binary so a WSL distro can be served locally instead of downloading one. See diff --git a/docs/index.mdx b/docs/index.mdx index a298a154..07e72400 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -31,7 +31,7 @@ something floods the screen. syntax highlighting, click-to-place-caret, real multi-line editing. - 20 coding CLIs are recognised on sight. Per-pane status dots, notifications + 22 coding CLIs are recognised on sight. Per-pane status dots, notifications when one needs you, git context, and session resume after a reboot. diff --git a/src/core/update.rs b/src/core/update.rs index 1d4ed849..31a865d1 100644 --- a/src/core/update.rs +++ b/src/core/update.rs @@ -1766,6 +1766,11 @@ struct GitHubAsset { browser_download_url: String, } +#[derive(serde::Deserialize)] +struct GitHubError { + message: String, +} + /// `nightly.json`, written by the nightly workflow. Only `version` is read /// today; the rest is there so a build can be traced back to its commit /// without cross-referencing the release notes. @@ -1853,7 +1858,30 @@ async fn fetch_json( let mut response = client.send(request).await.context("sending the request")?; if !response.status().is_success() { - anyhow::bail!("GitHub returned HTTP {}", response.status().as_u16()); + let status = response.status().as_u16(); + let rate_remaining = response + .headers() + .get("x-ratelimit-remaining") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let rate_reset = response + .headers() + .get("x-ratelimit-reset") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let mut body = Vec::new(); + let _ = response + .body_mut() + .take(8 * 1024) + .read_to_end(&mut body) + .await; + anyhow::bail!(github_http_error( + status, + rate_remaining.as_deref(), + rate_reset.as_deref(), + &body, + now_secs(), + )); } let mut body = Vec::new(); @@ -1866,6 +1894,54 @@ async fn fetch_json( serde_json::from_slice(&body).context("parsing JSON") } +fn github_http_error( + status: u16, + rate_remaining: Option<&str>, + rate_reset: Option<&str>, + body: &[u8], + now: u64, +) -> String { + let message = serde_json::from_slice::(body) + .ok() + .map(|error| sanitize_github_message(&error.message)); + // 403 is what the REST API has always answered a spent quota with; 429 is + // what it increasingly answers instead, and both carry the same headers. + let rate_limited = matches!(status, 403 | 429) + && (rate_remaining == Some("0") + || message.as_deref().is_some_and(|message| { + message.to_ascii_lowercase().contains("rate limit exceeded") + })); + + if rate_limited { + let retry = rate_reset + .and_then(|reset| reset.parse::().ok()) + .filter(|reset| *reset > now) + .map(|reset| { + let minutes = (reset - now).div_ceil(60); + let suffix = if minutes == 1 { "" } else { "s" }; + format!("try again in about {minutes} minute{suffix}") + }) + .unwrap_or_else(|| "try again shortly".to_string()); + return format!("GitHub API rate limit exceeded; {retry} (HTTP {status})"); + } + + match message.filter(|message| !message.is_empty()) { + Some(message) => format!("GitHub returned HTTP {status}: {message}"), + None => format!("GitHub returned HTTP {status}"), + } +} + +fn sanitize_github_message(message: &str) -> String { + let single_line = message.split_whitespace().collect::>().join(" "); + let mut chars = single_line.chars(); + let shortened: String = chars.by_ref().take(200).collect(); + if chars.next().is_some() { + format!("{shortened}…") + } else { + shortened + } +} + /// Returns the release together with the version it advertises, which is not /// always something the release object states outright — see `resolve_version`. async fn fetch_latest_release( @@ -2895,6 +2971,73 @@ fn is_update_available(latest: &str, current: &str) -> bool { mod tests { use super::*; + #[test] + fn github_rate_limit_error_says_when_to_retry() { + let error = github_http_error( + 403, + Some("0"), + Some("4600"), + br#"{"message":"API rate limit exceeded for 203.0.113.1."}"#, + 1000, + ); + + assert_eq!( + error, + "GitHub API rate limit exceeded; try again in about 60 minutes (HTTP 403)" + ); + } + + /// GitHub answers a spent quota with 403 or 429 depending on the endpoint + /// and the era. Only the 403 spelling used to reach the retry advice, so a + /// 429 told the reader the quota was gone without saying when it returns. + #[test] + fn a_429_is_a_rate_limit_too() { + let error = github_http_error( + 429, + Some("0"), + Some("1600"), + br#"{"message":"API rate limit exceeded for 203.0.113.1."}"#, + 1000, + ); + + assert_eq!( + error, + "GitHub API rate limit exceeded; try again in about 10 minutes (HTTP 429)" + ); + } + + #[test] + fn expired_github_rate_limit_says_to_retry_shortly() { + let error = github_http_error( + 403, + Some("0"), + Some("999"), + br#"{"message":"API rate limit exceeded."}"#, + 1000, + ); + + assert_eq!( + error, + "GitHub API rate limit exceeded; try again shortly (HTTP 403)" + ); + } + + #[test] + fn github_json_error_keeps_a_short_actionable_message() { + let error = github_http_error( + 404, + None, + None, + br#"{"message":"Not Found\nPlease check the repository"}"#, + 1000, + ); + + assert_eq!( + error, + "GitHub returned HTTP 404: Not Found Please check the repository" + ); + } + fn github_asset(name: &str) -> GitHubAsset { GitHubAsset { name: name.to_string(), diff --git a/src/main.rs b/src/main.rs index 67d36b02..a5793199 100644 --- a/src/main.rs +++ b/src/main.rs @@ -638,6 +638,24 @@ fn main() { application.on_open_urls(move |urls| { let _ = external_open_tx.try_send(urls); }); + // macOS relaunching an app that is already running — the Dock icon, a + // double-click on the bundle, `open -a tty7` — only reaches this process + // as `applicationShouldHandleReopen:`, and gpui's delegate does nothing + // with it unless a handler is registered. That is the one entrance a + // tray-resident tty7 has: with `show_tray_icon` on, closing the last + // window keeps the process and its Dock icon alive with nothing on + // screen, and without this the icon's click was a no-op (the only ways + // back in were ⌘N, the tray's "Show tty7", or quitting and relaunching). + // Like `on_open_urls`, the hook lives on `Application`, so it cannot go + // beside the other app-level handlers in `keymap::init`; and like that + // path it defers to the loop rather than opening windows on AppKit's + // delegate stack, which is also how Zed's own reopen handler runs. + application.on_reopen(|cx| { + cx.spawn(async move |cx| { + let _ = cx.update(crate::ui::windows::reopen); + }) + .detach(); + }); application.run(move |cx| { // gpui invokes this callback without an `App` context. Bridge it // back onto the application loop instead of touching UI state on @@ -724,9 +742,10 @@ mod config_reload_tests { } fn bound_to_split_right(config: &mut Config, key: &str) { - config - .keybindings - .insert("SplitRight".to_string(), key.to_string()); + config.keybindings.insert( + "SplitRight".to_string(), + crate::core::config::KeybindingOverride::Add(key.to_string()), + ); } #[gpui::test] @@ -803,11 +822,10 @@ mod config_reload_tests { ); assert!(announced, "the breakage is announced"); assert_eq!( - cx.global::() - .keybindings - .get("SplitRight") - .map(String::as_str), - Some("ctrl-alt-9"), + cx.global::().keybindings.get("SplitRight"), + Some(&crate::core::config::KeybindingOverride::Add( + "ctrl-alt-9".to_string() + )), "the running config survives the broken file" ); assert_eq!( diff --git a/src/terminal/element.rs b/src/terminal/element.rs index c1a5b372..3710ab90 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -249,11 +249,46 @@ fn snapshot_cell( rc.selected = true; } if flags.contains(Flags::DIM) { - rc.fg.a *= DIM_OPACITY; + rc.fg = dim_fg(rc.fg, bgc, colors.legible_dim); } rc } +/// SGR 2's colour: the ink at `DIM_OPACITY` over its cell, or — on a light +/// cell where that fade would be illegible — the opaque ink +/// `presets::legible_dim` solved for. `legible` is Settings → Appearance's +/// palette switch; off keeps the plain fade everywhere. +fn dim_fg(fg: Hsla, under: Rgb, legible: bool) -> Hsla { + let mut faded = fg; + faded.a *= DIM_OPACITY; + if !legible { + return faded; + } + // A faint run is one (ink, background) pair repeated across the row, and + // the rescue is a contrast bisection — remember the last answer so a + // screen of dim text costs one solve per colour change, not one per cell. + thread_local! { + static LAST: std::cell::Cell)>> = + const { std::cell::Cell::new(None) }; + } + let (ink, bg) = (pack_rgb(super::palette::hsla_to_rgb(fg)), pack_rgb(under)); + let solved = LAST.with(|last| match last.get() { + Some((i, b, s)) if i == ink && b == bg => s, + _ => { + let s = crate::ui::presets::legible_dim(ink, bg, DIM_OPACITY); + last.set(Some((ink, bg, s))); + s + } + }); + match solved { + Some(c) => Hsla { + a: fg.a, + ..to_hsla(unpack_rgb(c)) + }, + None => faded, + } +} + fn active_selection_bg(cx: &gpui::App) -> Rgb { match cx.try_global::() { Some(a) => a.sel_bg, @@ -305,6 +340,9 @@ pub(super) struct PaintColors { current_match_bg: Hsla, fg_rgb: Rgb, bg_rgb: Rgb, + /// Whether faint text on a light cell is held at the text floor (see + /// `dim_fg`). Mirrors `theme_legible_palette`. + legible_dim: bool, } /// The under-colour a dimmed pane blends its content toward: the window @@ -444,6 +482,9 @@ impl PaintColors { current_match_bg, fg_rgb, bg_rgb, + legible_dim: cx + .try_global::() + .is_none_or(|c| c.theme_legible_palette), } } @@ -466,6 +507,7 @@ impl PaintColors { current_match_bg: blend_toward(self.current_match_bg, dim, under), fg_rgb: self.fg_rgb, bg_rgb: self.bg_rgb, + legible_dim: self.legible_dim, } } } @@ -1583,6 +1625,20 @@ pub(super) struct GridSnapshot { history_size: usize, } +impl GridSnapshot { + /// The terminal caret this frame paints, if any. None while the program + /// has the cursor hidden: alacritty reports a DECTCEM-reset cursor + /// (`?25l`) as `CursorShape::Hidden`, and a TUI that hides it is usually + /// drawing a caret of its own that ours must not cover (#844). + pub(super) fn painted_cursor( + &self, + ) -> Option<(usize, usize, crate::core::config::CursorStyle)> { + self.cursor + .filter(|c| !c.hidden) + .map(|c| (c.row, c.col, c.style)) + } +} + impl TerminalElement { pub(super) fn build_grid( &self, @@ -2123,9 +2179,7 @@ impl Element for TerminalElement { let sliver = snap.sliver.as_ref(); let cursor_cell = cursor.map(|c| (c.row, c.ime_col)); - let render_cursor = cursor - .filter(|c| !c.hidden) - .map(|c| (c.row, c.col, c.style)); + let render_cursor = snap.painted_cursor(); // Reverse-video the block cursor's cell up front, so it rides the // normal background-then-glyph path instead of being tinted on top of @@ -2412,6 +2466,7 @@ mod tests { selection_bg: Hsla::default(), match_bg: Hsla::default(), current_match_bg: Hsla::default(), + legible_dim: true, fg_rgb: Rgb { r: 17, g: 17, @@ -3600,6 +3655,7 @@ mod tests { current_match_bg: wash(0.85), fg_rgb: fg, bg_rgb: bg, + legible_dim: true, } } @@ -3722,6 +3778,167 @@ mod tests { ); } + /// What a cell's foreground lands on screen as: its rgb composited over + /// the cell background by its alpha, the way the glyph is actually drawn. + fn on_screen(fg: Hsla, under: Rgb) -> u32 { + let c = Rgba::from(fg); + let ch = |v: f32, u: u8| ((v * c.a + (u as f32 / 255.) * (1. - c.a)) * 255.).round() as u32; + ch(c.r, under.r) << 16 | ch(c.g, under.g) << 8 | ch(c.b, under.b) + } + + /// The colours a pane on builtin `t` resolves cells with. + fn builtin_colors(t: &crate::ui::presets::Theme) -> (PaintColors, [Rgb; 256]) { + let (fg, bg) = (unpack_rgb(t.foreground), unpack_rgb(t.background_color())); + let mut colors = test_colors(); + colors.default_fg = to_hsla(fg); + colors.default_bg = to_hsla(bg); + colors.fg_rgb = fg; + colors.bg_rgb = bg; + let mut palette = super::super::palette::build(); + palette[..16].copy_from_slice(&t.active_palette(true).ansi16); + (colors, palette) + } + + /// Every colour faint text is commonly written in: the default foreground, + /// the sixteen palette slots, the 256-colour greys apps reach for as + /// "muted", and a truecolour grey. + fn faint_samples() -> Vec<(String, AnsiColor)> { + let mut v = vec![("fg".to_string(), AnsiColor::Named(NamedColor::Foreground))]; + for i in 0..16u8 { + v.push((format!("ansi{i}"), AnsiColor::Indexed(i))); + } + for i in [240u8, 244, 248, 250] { + v.push((format!("256:{i}"), AnsiColor::Indexed(i))); + } + v.push(( + "#999999".to_string(), + AnsiColor::Spec(Rgb { + r: 153, + g: 153, + b: 153, + }), + )); + v + } + + /// (plain, faint) on-screen colours of `color` as a pane on `t` paints them. + fn plain_and_faint( + colors: &PaintColors, + palette: &[Rgb; 256], + color: AnsiColor, + ) -> (RenderCell, RenderCell) { + let point = AlacPoint::new(AlacLine(0), AlacColumn(0)); + let mut cell = Cell { + c: 'x', + fg: color, + ..Cell::default() + }; + let plain = snapshot_cell(&cell, point, palette, colors, None); + cell.flags = Flags::DIM; + let faint = snapshot_cell(&cell, point, palette, colors, None); + (plain, faint) + } + + #[test] + fn faint_text_keeps_the_text_floor_on_every_light_builtin() { + // #858: SGR 2 painted the ink at 66% over the cell, which on a light + // background took Catppuccin Latte's foreground to 3.2:1 and every + // bright-black the palette rescue had lifted to 4.5:1 back to ~2.5:1. + // Faint text on a light cell must now clear 4.5:1 — or, for an ink + // that never cleared it undimmed, stay at the ink's own ratio. + use crate::ui::presets::{builtins, contrast}; + let mut failures = Vec::new(); + eprintln!("| theme | colour | plain | faint |"); + for t in builtins().into_iter().filter(|t| !t.dark) { + let (colors, palette) = builtin_colors(&t); + let bg = t.background_color(); + for (name, color) in faint_samples() { + let (plain, faint) = plain_and_faint(&colors, &palette, color); + let plain = contrast(on_screen(plain.fg, colors.bg_rgb), bg); + let faint = contrast(on_screen(faint.fg, colors.bg_rgb), bg); + eprintln!("| {} | {name} | {plain:.2} | {faint:.2} |", t.id); + if faint < 4.5_f32.min(plain) - 0.05 { + failures.push(format!( + "{}/{name}: faint {faint:.2}:1 (plain {plain:.2}:1)", + t.id + )); + } + } + } + assert!( + failures.is_empty(), + "faint text under the floor:\n{}", + failures.join("\n") + ); + } + + #[test] + fn faint_text_on_dark_builtins_is_the_plain_fade() { + // The floor is a light-background rescue: every dark builtin must + // keep painting SGR 2 exactly as it did, as the ink at DIM_OPACITY. + for t in crate::ui::presets::builtins() + .into_iter() + .filter(|t| t.dark) + { + let (colors, palette) = builtin_colors(&t); + for (name, color) in faint_samples() { + let (plain, faint) = plain_and_faint(&colors, &palette, color); + let mut fade = plain.fg; + fade.a *= DIM_OPACITY; + assert_eq!( + faint.fg, fade, + "{}/{name}: dark theme faint text changed", + t.id + ); + } + } + } + + #[test] + fn faint_text_stays_fainter_than_plain_text() { + // The rescue buys legibility, not a restyle: faint text never gains + // contrast over its own ink, and an ink with room to spare above the + // floor still reads visibly fainter. + use crate::ui::presets::{builtins, contrast}; + for t in builtins() { + let (colors, palette) = builtin_colors(&t); + let bg = t.background_color(); + for (name, color) in faint_samples() { + let (plain, faint) = plain_and_faint(&colors, &palette, color); + let plain = contrast(on_screen(plain.fg, colors.bg_rgb), bg); + let faint = contrast(on_screen(faint.fg, colors.bg_rgb), bg); + assert!( + faint <= plain + 0.01, + "{}/{name}: faint {faint:.2} > plain {plain:.2}", + t.id + ); + if plain >= 6.0 { + assert!( + faint <= plain * 0.85, + "{}/{name}: faint {faint:.2} no longer reads fainter than {plain:.2}", + t.id + ); + } + } + } + } + + #[test] + fn faint_text_is_the_plain_fade_with_the_legibility_switch_off() { + // Settings → Appearance's palette switch off renders colours as + // authored; that includes faint text. + for t in crate::ui::presets::builtins() { + let (mut colors, palette) = builtin_colors(&t); + colors.legible_dim = false; + for (name, color) in faint_samples() { + let (plain, faint) = plain_and_faint(&colors, &palette, color); + let mut fade = plain.fg; + fade.a *= DIM_OPACITY; + assert_eq!(faint.fg, fade, "{}/{name}: switch off still rescued", t.id); + } + } + } + #[test] fn blend_toward_mixes_in_rgb_space_and_keeps_alpha() { let under = Rgba { diff --git a/src/terminal/input.rs b/src/terminal/input.rs index 03ec4344..86ac5aeb 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -6,7 +6,7 @@ use super::view::TerminalView; use crate::core::config::Config; /// Everything about the terminal's current state that changes how a keystroke -/// is encoded: the kitty protocol flags, plus DECCKM (application cursor keys). +/// is encoded: keyboard modes and the PTY that receives the bytes. #[derive(Clone, Copy, Default)] pub(super) struct KeyFlags { disambiguate: bool, @@ -15,6 +15,7 @@ pub(super) struct KeyFlags { /// DECCKM. ncurses apps turn this on via `smkx` and then only recognise the /// SS3 form of the arrow keys, because that is what `kcuu1` & co. spell. app_cursor: bool, + local_conpty: bool, } impl KeyFlags { @@ -24,6 +25,14 @@ impl KeyFlags { report_all_keys: mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC), report_text: mode.contains(TermMode::REPORT_ASSOCIATED_TEXT), app_cursor: mode.contains(TermMode::APP_CURSOR), + local_conpty: false, + } + } + + pub(super) fn from_mode_with_local_conpty(mode: &TermMode, local_conpty: bool) -> Self { + Self { + local_conpty, + ..Self::from_mode(mode) } } @@ -31,6 +40,17 @@ impl KeyFlags { self.disambiguate || self.report_all_keys } + pub(super) fn legacy_newline_bytes(self) -> &'static [u8] { + if self.local_conpty { + // ConPTY decodes bare LF as Ctrl+Enter. Explicit Ctrl+J events + // preserve the key for native readers and still produce LF for + // VT readers such as ssh and WSL. + b"\x1b[74;36;10;1;8;1_\x1b[74;36;10;0;8;1_" + } else { + b"\n" + } + } + pub(super) fn app_cursor(self) -> bool { self.app_cursor } @@ -374,6 +394,9 @@ fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke, flags: KeyFlags) -> Option KeyFlags { @@ -558,6 +582,7 @@ mod tests { report_all_keys: true, report_text: true, app_cursor: false, + local_conpty: false, } } @@ -567,6 +592,7 @@ mod tests { report_all_keys: false, report_text: false, app_cursor: false, + local_conpty: false, } } @@ -582,6 +608,54 @@ mod tests { } } + #[test] + fn legacy_newline_preserves_ctrl_j_for_native_console_readers() { + let ctrl_j = Keystroke::parse("ctrl-j").unwrap(); + for (local_conpty, expected) in [ + (false, b"\n".as_slice()), + (true, b"\x1b[74;36;10;1;8;1_\x1b[74;36;10;0;8;1_".as_slice()), + ] { + let flags = KeyFlags::from_mode_with_local_conpty(&TermMode::empty(), local_conpty); + assert_eq!(flags.legacy_newline_bytes(), expected); + assert_eq!( + keystroke_to_bytes(&ctrl_j, flags).as_deref(), + Some(expected) + ); + for (chord, expected) in [ + ("enter", b"\r".as_slice()), + ("ctrl-c", b"\x03".as_slice()), + ("alt-enter", b"\x1b\r".as_slice()), + ("ctrl-alt-j", b"\x1b\n".as_slice()), + ] { + assert_eq!( + keystroke_to_bytes(&Keystroke::parse(chord).unwrap(), flags).as_deref(), + Some(expected), + "{chord}, local_conpty={local_conpty}" + ); + } + } + } + + #[test] + fn kitty_newline_chords_take_precedence_over_conpty_encoding() { + for mode in [ + TermMode::DISAMBIGUATE_ESC_CODES, + TermMode::REPORT_ALL_KEYS_AS_ESC, + ] { + let flags = KeyFlags::from_mode_with_local_conpty(&mode, true); + for (chord, expected) in [ + ("ctrl-j", b"\x1b[106;5u".as_slice()), + ("shift-enter", b"\x1b[13;2u".as_slice()), + ] { + assert_eq!( + keystroke_to_bytes(&Keystroke::parse(chord).unwrap(), flags).as_deref(), + Some(expected), + "{chord}" + ); + } + } + } + #[test] fn plain_text_defers_to_the_ime_unless_kitty_wants_every_key() { let plain = Modifiers::default(); @@ -934,6 +1008,7 @@ mod tests { report_all_keys: true, report_text: true, app_cursor: false, + local_conpty: false, }; for flags in [kitty(), full] { assert_eq!( @@ -1128,6 +1203,7 @@ mod tests { report_all_keys: false, report_text: false, app_cursor: false, + local_conpty: false, } } @@ -1209,6 +1285,7 @@ mod tests { report_all_keys: true, report_text: false, app_cursor: false, + local_conpty: false, }; let none = Modifiers::default(); assert_eq!( @@ -1237,6 +1314,7 @@ mod tests { report_all_keys: true, report_text: false, app_cursor: false, + local_conpty: false, }; assert_eq!(tab_bytes(false, full), b"\x1b[9u".to_vec()); } @@ -1316,6 +1394,7 @@ mod tests { report_all_keys: true, report_text: true, app_cursor: false, + local_conpty: false, }; for key in ["f1", "f2", "f4", "f5", "f7", "f10", "f11", "f12"] { for mods in [none, shift, ctrl] { @@ -1352,6 +1431,7 @@ mod tests { report_all_keys: true, report_text: true, app_cursor: false, + local_conpty: false, }; let none = Modifiers::default(); assert_eq!( @@ -1367,6 +1447,7 @@ mod tests { report_all_keys: true, report_text: true, app_cursor: false, + local_conpty: false, }; let none = Modifiers::default(); assert_eq!( diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index be4cee3e..d132c28c 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -16,9 +16,9 @@ use crate::terminal::parked_cursor::{CursorCut, ParkedCursorRepair, ParkedCursor use std::collections::VecDeque; -use crate::core::cli_agent::{AgentSessionState, CLIAgent}; +use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent}; use crate::core::config::CursorStyle as ConfigCursorStyle; -use crate::core::osc::OscTokenizer; +use crate::core::osc::{OscTokenizer, TitleEffect, TitleLifetime}; use crate::daemon::protocol::{ AuthPromptKind, AuthResponse, ClientMsg, DaemonMsg, KnownHostEntry, KnownHostId, LoopbackForward, LoopbackForwardRequest, ManagedForward, NativeSshSpec, PaneProcs, @@ -64,12 +64,35 @@ struct ShellState { cycle: u64, } +/// The pane's agent status as the client last heard it, plus how it heard it. +/// +/// A reattach — the app restarting onto panes the daemon kept alive, or a +/// dropped link coming back — has the daemon replay the pane's *stored* status +/// as an ordinary `AgentStatus` frame ([`crate::daemon`]'s `replay_state`). +/// Nothing on the wire distinguishes it from a live transition, and a client +/// that reads it as one concludes that every restored agent finished its turn +/// in the instant the window opened. `replayed` is that distinction, kept in +/// the same lock as the value it describes so a reader can never observe the +/// status without also learning where it came from. +#[derive(Default)] +struct AgentSlot { + state: Option, + /// `state` arrived as an attach replay and no one has adopted it yet. + /// Cleared by the first taker — the view adopts it as a baseline rather + /// than as an edge. + replayed: bool, +} + struct ReaderSignals { cwd: Arc>>, shell: Arc>, remote: Arc>>, agent: Arc>>, - agent_session: Arc>>, + agent_session: Arc>, + /// Whether this link still owes us the attach replay. The reader keeps it + /// as a plain local: a link's replay is a property of that link's stream + /// position, and nothing outside the reader thread ever needs to read it. + awaiting_replay: bool, exited: Arc, child_exited: Arc, zle_reading: Arc, @@ -87,7 +110,7 @@ struct ReaderSignals { /// the reader puts back the cursor a repaint parked. Decided per pane from /// its [`PtySource`], and shared rather than copied because the reader can /// learn better mid-stream — see the `RemoteContext` arm. - repair_cursor: Arc, + local_conpty: Arc, } /// What kind of pty is at the far end of a pane's link, which is what decides @@ -123,20 +146,6 @@ impl PtySource { _ => PtySource::Raw, } } - - /// Whether the cursor a repaint parked has to be put back — see - /// [`crate::terminal::parked_cursor`]. - /// - /// Only conhost parks one. On a raw pty the application owns the cursor and - /// is free to end a repaint on the text it just wrote and then echo the - /// next keystroke straight after it, with no positioning of its own: vim - /// opens its command line that way, and putting the cursor back on the cell - /// the repaint hid it on drops the `wq!` typed next onto the row being - /// edited (#430, and #774 for the Windows client that reached a Linux host - /// and was repaired anyway). - fn repairs_parked_cursor(self) -> bool { - self == PtySource::LocalConpty - } } #[derive(Clone, Debug, PartialEq)] @@ -571,7 +580,7 @@ pub struct RemoteTerminal { ssh_user: Option, auto_supplied_password: bool, agent: Arc>>, - agent_session: Arc>>, + agent_session: Arc>, /// Kitty-graphics images placed on this pane's grid (issue #213). /// Written by the reader thread from out-of-band `Image`/`DeleteImage` /// frames, read by the paint path — only the client holds the grid the @@ -590,12 +599,12 @@ pub struct RemoteTerminal { /// flag under the term lock before every grid mutation, so once it is set /// the abandoned thread can only exit, never write. reader_quit: Arc, - /// Whether this pane's pty is a ConPTY, and so whether the reader repairs + /// Whether this pane's pty is a ConPTY, for input encoding and repairing /// the cursor a repaint parks. Held here so a relink hands the same answer /// to the reader it starts: a pane's pty does not change kind when the link /// to it is rebuilt, and the route a relink carries cannot tell a /// native-SSH pane from a local shell. - repair_cursor: Arc, + local_conpty: Arc, } /// The workspace id a spawn carries, so the pane's shell gets `$TTY7_WS` and a @@ -819,7 +828,8 @@ impl RemoteTerminal { } Err(e) => return Err(e), }; - let mut term = Self::from_stream_with(stream, size, buffered, PtySource::for_route(route))?; + let mut term = + Self::from_stream_parts(stream, size, buffered, PtySource::for_route(route), true)?; term.route = route.clone(); Ok(term) } @@ -866,6 +876,17 @@ impl RemoteTerminal { let read_half = stream.try_clone()?; + // A relink keeps the view, and the view keeps the status it last saw + // before the link dropped. That is already a baseline, and the better + // one: if the agent finished its turn while the link was down, the + // daemon's replayed `Done` against the view's `Working` is exactly the + // edge the reader must be told about. So the relink's replay is read + // as a live report, and a cold-attach mark nobody took yet is dropped + // rather than left to swallow that edge. + if let Ok(mut guard) = self.agent_session.lock() { + guard.replayed = false; + } + self.exited_flag.store(false, Ordering::SeqCst); self.exited = false; { @@ -891,6 +912,10 @@ impl RemoteTerminal { remote: self.remote_context.clone(), agent: self.agent.clone(), agent_session: self.agent_session.clone(), + // The daemon does replay the stored status down this link, but + // the view already has a baseline from before the drop — see + // above. + awaiting_replay: false, exited: self.exited_flag.clone(), child_exited: self.child_exited.clone(), zle_reading: self.zle_reading.clone(), @@ -905,7 +930,7 @@ impl RemoteTerminal { // rebuilt from `route`: the pty on the far side is the same pty // it was before the link dropped, and only this value still // remembers what a `RemoteContext` taught the old reader. - repair_cursor: self.repair_cursor.clone(), + local_conpty: self.local_conpty.clone(), }, ); self.reader_thread = Some(reader); @@ -920,6 +945,20 @@ impl RemoteTerminal { Ok(()) } + /// A pane the client *reattached* to rather than spawned — the shape the + /// app restores last session's tabs in, where the head of the stream is the + /// daemon replaying state the pane already had. + #[cfg(test)] + pub(super) fn from_stream_reattached(stream: Stream, size: TermSize) -> anyhow::Result { + Self::from_stream_parts( + stream, + size, + Vec::new(), + PtySource::for_route(&PaneRoute::Local), + true, + ) + } + /// A pane on a pty of this machine's own — what the tests build, and what /// `spawn_on` narrows with the route it dialled. pub(super) fn from_stream(stream: Stream, size: TermSize) -> anyhow::Result { @@ -936,6 +975,22 @@ impl RemoteTerminal { size: TermSize, buffered: Vec, pty: PtySource, + ) -> anyhow::Result { + Self::from_stream_parts(stream, size, buffered, pty, false) + } + + /// `awaiting_replay` says this link is an attach rather than a spawn, and + /// so that the frames at the head of its stream describe a pane that was + /// already running — see [`AgentSlot`]. It has to be decided here rather + /// than set on the returned terminal: the reader starts inside this + /// function, and against a daemon that answers promptly the replay can be + /// parsed before the caller gets its value back. + fn from_stream_parts( + stream: Stream, + size: TermSize, + buffered: Vec, + pty: PtySource, + awaiting_replay: bool, ) -> anyhow::Result { let read_half = stream.try_clone()?; let write_half = stream; @@ -955,7 +1010,7 @@ impl RemoteTerminal { let shell_state: Arc> = Arc::new(Mutex::new(ShellState::default())); let remote_context: Arc>> = Arc::new(Mutex::new(None)); let agent: Arc>> = Arc::new(Mutex::new(None)); - let agent_session: Arc>> = Arc::new(Mutex::new(None)); + let agent_session: Arc> = Arc::new(Mutex::new(AgentSlot::default())); let exited_flag = Arc::new(AtomicBool::new(false)); let child_exited = Arc::new(AtomicBool::new(false)); let zle_reading = Arc::new(AtomicBool::new(false)); @@ -969,7 +1024,7 @@ impl RemoteTerminal { let clipboard_write_busy = Arc::new(AtomicBool::new(false)); let reader_quit = Arc::new(AtomicBool::new(false)); - let repair_cursor = Arc::new(AtomicBool::new(pty.repairs_parked_cursor())); + let local_conpty = Arc::new(AtomicBool::new(pty == PtySource::LocalConpty)); let reader_thread = Self::spawn_reader( term.clone(), proxy.clone(), @@ -982,6 +1037,7 @@ impl RemoteTerminal { remote: remote_context.clone(), agent: agent.clone(), agent_session: agent_session.clone(), + awaiting_replay, exited: exited_flag.clone(), child_exited: child_exited.clone(), zle_reading: zle_reading.clone(), @@ -992,7 +1048,7 @@ impl RemoteTerminal { images: images.clone(), clipboard_writes: clipboard_writes.clone(), clipboard_write_busy: clipboard_write_busy.clone(), - repair_cursor: repair_cursor.clone(), + local_conpty: local_conpty.clone(), }, ); @@ -1032,7 +1088,7 @@ impl RemoteTerminal { proxy, reader_thread: Some(reader_thread), reader_quit, - repair_cursor, + local_conpty, }) } @@ -1092,6 +1148,7 @@ impl RemoteTerminal { remote, agent, agent_session, + awaiting_replay, exited: exited_flag, child_exited, zle_reading, @@ -1102,14 +1159,26 @@ impl RemoteTerminal { images, clipboard_writes, clipboard_write_busy, - repair_cursor, + local_conpty, } = signals; + let mut awaiting_replay = awaiting_replay; crate::core::threads::promote_to_user_interactive(); let mut stream = read_half; let mut processor: ansi::Processor = ansi::Processor::new(); let mut osc = OscNotifyScanner::default(); let mut mode_tok = OscTokenizer::new(&[b"133"]); let mut zle_tok = OscTokenizer::new(&[b"133"]); + // #889: an OSC 0/2 the emulator above has already adopted, read + // a second time only to learn whether the program that wrote it + // has since exited. `TitleLifetime` is the daemon's rule too, + // so a window's tab strip and the switcher reading the tree can + // never disagree about whether a title is still current. + let mut title_tok = OscTokenizer::new(&[b"0", b"2", b"133"]); + let mut title_life = TitleLifetime::default(); + // No live `Output` frame yet: whatever arrives now is the + // daemon's replay (an attach or a relink), which it sends + // entirely as `Snapshot`s followed by the stored state. + let mut replaying_state = true; let mut cursor_scan = ParkedCursorScanner::new(); let mut parked_cursor = ParkedCursorRepair::default(); let mut pending: Vec = buffered; @@ -1158,6 +1227,25 @@ impl RemoteTerminal { let mut out_batch: Vec = Vec::new(); + // Whether this chunk ends with the title the pane is showing + // belonging to a command that has finished. The emulator has + // already parsed the same bytes, so a `true` here is sent on + // as a `ResetTitle` *after* every `Title` the chunk produced — + // which is the whole ordering question: a shell that re-titles + // itself in `precmd` writes its OSC 0/2 after the `D`, and + // that title is the last word rather than a thing to undo. + macro_rules! chunk_retires_the_title { + ($bytes:expr) => {{ + let mut retire = false; + title_tok.feed($bytes, |payload| match title_life.saw(payload) { + TitleEffect::Retire => retire = true, + TitleEffect::Set => retire = false, + TitleEffect::None => {} + }); + retire + }}; + } + 'main: loop { macro_rules! flush_batch { () => { @@ -1168,7 +1256,7 @@ impl RemoteTerminal { // emulator to the cut, act on the state that // sequence left behind, carry on. let mut cuts: Vec<(usize, CursorCut)> = Vec::new(); - if repair_cursor.load(Ordering::Relaxed) { + if local_conpty.load(Ordering::Relaxed) { cursor_scan.feed(&out_batch, |off, c| cuts.push((off, c))); } { @@ -1247,6 +1335,9 @@ impl RemoteTerminal { } } }); + if chunk_retires_the_title!(&out_batch) { + proxy.send_event(AlacEvent::ResetTitle); + } proxy.send_event(AlacEvent::Wakeup); out_batch.clear(); } @@ -1324,9 +1415,27 @@ impl RemoteTerminal { } }); proxy.replaying.store(false, Ordering::Relaxed); + // The replay carries the pane's recent marks, + // so reading it is what lets a reattached + // window know whether the title it just + // adopted belongs to anything still running. + if chunk_retires_the_title!(&bytes) { + proxy.send_event(AlacEvent::ResetTitle); + } proxy.send_event(AlacEvent::Wakeup); } DaemonMsg::Output(bytes) => { + // Live output only ever follows the whole + // replay (the daemon sends the stored status + // last, and the stream keeps that order), so + // the first frame here ends the window in which + // a status can still be a replayed one. Without + // this, a pane that had no agent session to + // replay would keep the window open until some + // agent it ran *later* reported for the first + // time, and that report would be discounted. + awaiting_replay = false; + replaying_state = false; out_batch.extend_from_slice(&bytes); tr_frames += 1; } @@ -1435,6 +1544,14 @@ impl RemoteTerminal { last_exit, } => { flush_batch!(); + // The replay says a command owns the pane + // right now. If the ring it replayed had + // already rolled past that command's `C`, the + // title just adopted from it is the command's + // and its `D` has to retire it (#889). + if replaying_state && active && !at_prompt { + title_life.joined_mid_command(); + } if let Ok(mut guard) = shell.lock() { *guard = ShellState { active, @@ -1479,7 +1596,7 @@ impl RemoteTerminal { .as_ref() .is_some_and(|c| c.kind == RemoteKind::NativeSsh) { - repair_cursor.store(false, Ordering::Relaxed); + local_conpty.store(false, Ordering::Relaxed); } if let Ok(mut guard) = remote.lock() { *guard = ctx; @@ -1508,7 +1625,11 @@ impl RemoteTerminal { DaemonMsg::AgentStatus(state) => { flush_batch!(); if let Ok(mut guard) = agent_session.lock() { - *guard = state; + guard.state = state; + // The first such frame on an attached link + // is the pane's stored status being + // replayed, not a turn changing state now. + guard.replayed = std::mem::take(&mut awaiting_replay); } proxy.send_event(AlacEvent::Wakeup); } @@ -1606,6 +1727,10 @@ impl RemoteTerminal { self.child_exited.load(Ordering::SeqCst) } + pub(super) fn is_local_conpty(&self) -> bool { + self.local_conpty.load(Ordering::Relaxed) + } + /// Queues a keystroke — or a paste, or a mouse report — for the link. /// /// Callers are gpui event handlers on the UI thread, so this returns @@ -1729,7 +1854,22 @@ impl RemoteTerminal { } pub fn agent_session(&self) -> Option { - self.agent_session.lock().ok().and_then(|g| g.clone()) + self.agent_session.lock().ok().and_then(|g| g.state.clone()) + } + + /// The status the daemon replayed when this link attached, handed out once. + /// + /// `Some(status)` means what [`Self::agent_session`] reports right now is + /// stored state from before this client existed — the caller should take it + /// as its starting point, not as something that just happened. Answering + /// only once is what keeps the very next live transition an edge again. + pub fn take_replayed_agent_status(&self) -> Option> { + let mut guard = self.agent_session.lock().ok()?; + if !guard.replayed { + return None; + } + guard.replayed = false; + Some(guard.state.as_ref().map(|s| s.status)) } pub fn zle_reading(&self) -> bool { @@ -3983,6 +4123,7 @@ mod parked_cursor_tests { let (client_side, daemon_side) = socket_pair(); let term = RemoteTerminal::from_stream_with(client_side, size, Vec::new(), pty) .expect("a terminal over a socket pair"); + assert_eq!(term.is_local_conpty(), pty == PtySource::LocalConpty); (term, daemon_side) } @@ -4026,8 +4167,6 @@ mod parked_cursor_tests { PtySource::Raw }, ); - assert!(PtySource::LocalConpty.repairs_parked_cursor()); - assert!(!PtySource::Raw.repairs_parked_cursor()); } #[test] @@ -4179,6 +4318,7 @@ mod parked_cursor_tests { term.remote_context().is_some(), "the reader never applied the context" ); + assert!(!term.is_local_conpty()); DaemonMsg::Output(b"\x1b[6;4H".to_vec()) .encode(&mut daemon_side) @@ -5604,6 +5744,118 @@ mod tests { assert!(poll(None), "agent exit should clear it"); } + /// The daemon replays a reattached pane's stored agent status as an + /// ordinary report. Nothing on the wire says so, so the link has to + /// remember that the first report it hears is that replay — and that + /// everything after it is live. + #[test] + fn a_reattached_link_marks_only_its_first_status_report_as_replayed() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus}; + + crate::core::config::pin_test_config_dir(); + let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); + let term = + RemoteTerminal::from_stream_reattached(client_side, TermSize::new(80, 24)).unwrap(); + assert_eq!( + term.take_replayed_agent_status(), + None, + "nothing replayed until the frame actually arrives" + ); + + let report = |status, daemon: &mut UnixStream| { + DaemonMsg::AgentStatus(Some(AgentSessionState { + status, + message: None, + session_id: Some("sid-1".into()), + launch_argv: None, + rich: true, + cwd: None, + activity: 0, + turns: 0, + })) + .encode(daemon) + .unwrap(); + daemon.flush().unwrap(); + }; + let poll = |want: AgentStatus| { + for _ in 0..200 { + if term.agent_session().map(|s| s.status) == Some(want) { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + false + }; + + report(AgentStatus::Done, &mut daemon_side); + assert!(poll(AgentStatus::Done), "the replayed status should land"); + assert_eq!( + term.take_replayed_agent_status(), + Some(Some(AgentStatus::Done)), + "the first report on a reattached link is stored state" + ); + assert_eq!( + term.take_replayed_agent_status(), + None, + "only one taker gets it" + ); + + report(AgentStatus::Working, &mut daemon_side); + assert!(poll(AgentStatus::Working), "the live status should land"); + assert_eq!( + term.take_replayed_agent_status(), + None, + "everything after the replay is something the client watched happen" + ); + } + + /// A pane with no agent session to replay sends no status frame at all, so + /// the replay window has to close on its own — otherwise the first report + /// from an agent launched *later* would be discounted as stored state. + #[test] + fn live_output_closes_the_replay_window() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus}; + + crate::core::config::pin_test_config_dir(); + let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); + let term = + RemoteTerminal::from_stream_reattached(client_side, TermSize::new(80, 24)).unwrap(); + + DaemonMsg::Output(b"$ claude\r\n".to_vec()) + .encode(&mut daemon_side) + .unwrap(); + DaemonMsg::AgentStatus(Some(AgentSessionState { + status: AgentStatus::Done, + message: None, + session_id: Some("sid-1".into()), + launch_argv: None, + rich: true, + cwd: None, + activity: 0, + turns: 0, + })) + .encode(&mut daemon_side) + .unwrap(); + daemon_side.flush().unwrap(); + + for _ in 0..200 { + if term.agent_session().is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!( + term.agent_session().map(|s| s.status), + Some(AgentStatus::Done), + "the status still lands" + ); + assert_eq!( + term.take_replayed_agent_status(), + None, + "a report that follows live output is live" + ); + } + #[test] fn agent_session_follows_daemon_status_reports() { use crate::core::cli_agent::{AgentSessionState, AgentStatus}; @@ -5630,6 +5882,7 @@ mod tests { rich: true, cwd: None, activity: 0, + turns: 0, })) .encode(&mut daemon_side) .unwrap(); diff --git a/src/terminal/typeahead.rs b/src/terminal/typeahead.rs index 21f097c2..3bd99d59 100644 --- a/src/terminal/typeahead.rs +++ b/src/terminal/typeahead.rs @@ -19,6 +19,11 @@ pub enum RawInput<'a> { plain: bool, }, Interrupt, + /// Ctrl-D. Readers take it as end of input only on an empty line — on a + /// line with text it deletes a character, and a shell that reads the gap + /// later is still left holding that text — so it closes the record only + /// when nothing unsubmitted was typed. + EndOfInput, } impl Typeahead { @@ -29,7 +34,9 @@ impl Typeahead { pub fn observe(&mut self, input: RawInput, externally_owned: bool) { match input { RawInput::Interrupt => self.discard(), + RawInput::EndOfInput if self.text.is_empty() => self.discard(), _ if externally_owned => {} + RawInput::EndOfInput => self.taint(), RawInput::Text(s) => self.record_text(s), RawInput::Pasted(s) => { self.record_text(s); @@ -90,17 +97,15 @@ impl Typeahead { } fn record_enter(&mut self) { - if self.text.len() + 1 > RECORD_CAP { - self.tainted = true; - return; - } - self.text.push('\r'); + // Enter has already gone to the foreground reader. That line is no + // longer pending shell input: keeping even an empty seed would send + // Ctrl-U into the next prompt after `exit` returns from SSH or a TUI. + // Only text typed after this boundary can belong to the next prompt. + self.discard(); } fn record_backspace(&mut self) { - if !self.text.ends_with('\r') { - self.text.pop(); - } + self.text.pop(); } fn taint(&mut self) { @@ -117,8 +122,7 @@ impl Typeahead { if self.tainted { return Some(String::new()); } - let seed = self.text.rsplit('\r').next().unwrap_or(""); - Some(seed.to_string()) + Some(self.text) } } @@ -322,11 +326,11 @@ mod tests { } #[test] - fn fully_submitted_input_wipes_but_seeds_nothing() { + fn fully_submitted_input_owes_no_wipe() { let mut p = Typeahead::new(); p.record_text("ls"); p.record_enter(); - assert_eq!(p.drain(), Some(String::new())); + assert_eq!(p.drain(), None); } #[test] @@ -335,7 +339,7 @@ mod tests { p.record_text("ls"); p.record_enter(); p.record_backspace(); - assert_eq!(p.drain(), Some(String::new())); + assert_eq!(p.drain(), None); } #[test] @@ -381,7 +385,93 @@ mod tests { let mut p = Typeahead::new(); p.taint(); p.record_text("ls"); - p.record_enter(); assert_eq!(p.drain(), Some(String::new())); } + + #[test] + fn end_of_input_on_an_empty_line_closes_the_record() { + let mut t = Typeahead::new(); + t.observe(RawInput::Text("exit"), false); + t.observe( + RawInput::Key { + key: "enter", + plain: true, + }, + false, + ); + t.observe( + RawInput::Key { + key: "up", + plain: true, + }, + false, + ); + t.observe(RawInput::EndOfInput, false); + assert_eq!(t.drain(), None); + } + + #[test] + fn end_of_input_after_unsubmitted_text_still_owes_the_wipe() { + let mut t = Typeahead::new(); + t.observe(RawInput::Text("ab"), false); + t.observe(RawInput::EndOfInput, false); + assert_eq!(t.drain(), Some(String::new())); + } + + #[test] + fn submitting_a_recalled_exit_discards_taint_and_paste_provenance() { + let mut t = Typeahead::new(); + t.observe(RawInput::Pasted("old command"), false); + t.observe( + RawInput::Key { + key: "up", + plain: true, + }, + false, + ); + t.observe( + RawInput::Key { + key: "enter", + plain: true, + }, + false, + ); + assert!(!t.pasted(), "the submitted line's paste mark is spent"); + assert_eq!( + t.adopt(), + None, + "returning to the prompt must not owe Ctrl-U" + ); + assert_eq!(t.drain(), None); + t.observe(RawInput::Text("git status"), false); + assert_eq!(t.drain(), Some("git status".to_string())); + } + + #[test] + fn a_submitted_exit_does_not_taint_the_next_prompts_typeahead() { + let mut t = Typeahead::new(); + t.observe(RawInput::Text("x".repeat(RECORD_CAP).as_str()), false); + t.observe( + RawInput::Key { + key: "enter", + plain: true, + }, + false, + ); + t.observe(RawInput::Text("exit"), false); + t.observe( + RawInput::Key { + key: "enter", + plain: true, + }, + false, + ); + t.observe(RawInput::Text("git status"), false); + assert_eq!(t.adopt(), Some("git status".to_string())); + assert_eq!( + t.drain(), + Some(String::new()), + "only the unsubmitted text needs a wipe" + ); + } } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index e5c2f206..ccc7cd50 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -191,6 +191,31 @@ pub struct ShellParts { pub(crate) owner: Option, } +/// What the reader was last shown of each pane's finished agent turn, kept for +/// the life of the app rather than of a view. +/// +/// A view is thrown away and built again over the same daemon pane whenever a +/// workspace is switched out and back or a window is reopened from the tray, +/// and a fresh view sees a `Done` agent arrive from nothing — exactly what a +/// turn finishing live looks like. This is how the new view tells the two apart +/// (#870). +#[derive(Default)] +struct AgentReadMarks(std::collections::HashMap<(crate::ui::host_ops::HostId, u64), AgentReadMark>); + +impl gpui::Global for AgentReadMarks {} + +#[derive(Clone)] +struct AgentReadMark { + session: (Option, Option>), + /// The status the pane's last view saw. Kept for every status, not only + /// `Done`, so that a pane with no mark at all is one this app never watched + /// an agent in — the only case where a reattach's replayed status may be + /// taken as a baseline (see `poll_agent_status`). + status: Option, + turns: u64, + unread: bool, +} + #[derive(Clone, Copy)] struct DragScroll { overshoot: f32, @@ -311,6 +336,10 @@ pub struct TerminalView { scroll_anim: Option, scroll_anim_epoch: u64, gesture_until: Option, + /// Whether the trackpad gesture in flight is zooming, latched at its first + /// event. `None` between gestures, and never set for a wheel, which has no + /// gesture to belong to and decides notch by notch. + gesture_zoom: Option, pub title: String, /// A title the pane has been told about but has not adopted yet — see /// `set_title_when_settled`. `None` means the tab is showing the newest @@ -381,6 +410,9 @@ pub struct TerminalView { /// time, however fast the pane is printing. pub(super) search_scan_armed: bool, pub bell_flash: bool, + /// Bumped by every bell, so only the timer armed by the latest one clears + /// the flash: a burst of bells holds one steady flash instead of strobing. + bell_epoch: u64, pub report_mouse: bool, last_at_prompt: bool, last_typeahead_blocked: bool, @@ -393,6 +425,9 @@ pub struct TerminalView { agent_was_rich: bool, agent_result_unread: bool, keep_unread_on_focus: bool, + /// Whether this view has seen its pane's agent status move at all. The + /// first move is where a rebuilt view consults [`AgentReadMarks`]. + agent_status_seen: bool, git_status_cwd: Option, last_agent_activity: u64, cmd: CmdEditor, @@ -1208,6 +1243,27 @@ fn fallback_chain(family: &str, configured: &[String]) -> Vec { chain } +/// Put `chain` on the regular face and on the bold and italic ones. +/// +/// Bold and italic never carry a chain of their own — `alt_font` copies theirs +/// off the regular face when they are built — so a rebuild that skipped them +/// would leave two of the three faces resolving against the old chain. +fn apply_fallback_chain( + chain: Vec, + font: &mut Font, + bold: &mut Option, + italic: &mut Option, +) { + let fallbacks = Some(gpui::FontFallbacks::from_fonts(chain)); + font.fallbacks = fallbacks.clone(); + if let Some(font) = bold { + font.fallbacks = fallbacks.clone(); + } + if let Some(font) = italic { + font.fallbacks = fallbacks; + } +} + impl TerminalView { pub fn spawn_shell_terminal_in( workspace: Option, @@ -1407,6 +1463,7 @@ impl TerminalView { view.keep_unread_on_focus = false; } else { view.agent_result_unread = false; + view.note_agent_result_unread(cx); } view.report_focus_change(true); cx.notify(); @@ -1526,6 +1583,7 @@ impl TerminalView { scroll_anim: None, scroll_anim_epoch: 0, gesture_until: None, + gesture_zoom: None, title: DEFAULT_TITLE.to_string(), pending_title: None, default_title: DEFAULT_TITLE.to_string(), @@ -1556,6 +1614,7 @@ impl TerminalView { search_scan_epoch: 0, search_scan_armed: false, bell_flash: false, + bell_epoch: 0, last_at_prompt: false, last_typeahead_blocked: false, running_since: None, @@ -1567,6 +1626,7 @@ impl TerminalView { agent_was_rich: false, agent_result_unread: false, keep_unread_on_focus: false, + agent_status_seen: false, git_status_cwd: None, last_agent_activity: 0, cmd: CmdEditor::new(), @@ -1846,9 +1906,38 @@ impl TerminalView { self.agent_result_unread } - pub fn mark_agent_result_unread(&mut self, refocus_incoming: bool) { + pub fn mark_agent_result_unread(&mut self, refocus_incoming: bool, cx: &mut App) { self.agent_result_unread = true; self.keep_unread_on_focus = refocus_incoming; + self.note_agent_result_unread(cx); + } + + /// Leave what the reader has seen of this pane's agent where the pane's + /// next view will look for it — see [`AgentReadMarks`]. A mark left at any + /// status other than `Done` never vouches for a finished turn, but it still + /// records that this app was watching: a turn that was running when the + /// view went and finished before the next one came must badge. + fn record_agent_read_mark(&self, turns: u64, cx: &mut App) { + cx.default_global::().0.insert( + (self.host_id, self.pane_id), + AgentReadMark { + session: self.last_agent_session.clone(), + status: self.last_agent_status, + turns, + unread: self.agent_result_unread, + }, + ); + } + + /// Carry a change to the badge alone into the mark the last status left. + fn note_agent_result_unread(&self, cx: &mut App) { + if !cx.has_global::() { + return; + } + let key = (self.host_id, self.pane_id); + if let Some(mark) = cx.global_mut::().0.get_mut(&key) { + mark.unread = self.agent_result_unread; + } } pub fn git_status(&self, cx: &App) -> Option { @@ -2273,7 +2362,8 @@ impl TerminalView { let kitty = self.key_flags(); if let Some(bytes) = super::input::keystroke_to_bytes(ks, kitty) { let plain = !m.control && !m.alt && !m.platform; - let interrupt = is_typeahead_interrupt(ks.key.as_str(), m); + let boundary = typeahead_boundary(ks.key.as_str(), m); + let interrupt = boundary.is_some(); let shell_owns_prompt = self.shell_owns_prompt(); let held = plain && ks.key == "backspace" @@ -2290,11 +2380,11 @@ impl TerminalView { }; if !held { self.release_hold(); - if !shell_owns_prompt && interrupt { - // Ctrl-C cancels the foreground input transaction. Clear - // the gap before delivering it so a prompt transition - // cannot flush this interrupt as a later Ctrl-U. - self.observe_typeahead(RawInput::Interrupt); + if let Some(boundary) = boundary.filter(|_| !shell_owns_prompt) { + // Ctrl-C interrupts and Ctrl-D can close the foreground reader. + // Discard the gap before sending either so a prompt transition + // cannot turn the pending record into a later Ctrl-U. + self.observe_typeahead(boundary); } self.terminal.write(bytes); if !shell_owns_prompt && !interrupt { @@ -2880,7 +2970,10 @@ impl TerminalView { } pub(super) fn key_flags(&self) -> super::input::KeyFlags { - super::input::KeyFlags::from_mode(self.terminal.term.lock().mode()) + super::input::KeyFlags::from_mode_with_local_conpty( + self.terminal.term.lock().mode(), + self.terminal.is_local_conpty(), + ) } fn tab_bytes(&self, shift: bool) -> Vec { @@ -2958,6 +3051,8 @@ impl TerminalView { } fn flash_bell(&mut self, cx: &mut Context) { + self.bell_epoch += 1; + let epoch = self.bell_epoch; self.bell_flash = true; cx.notify(); cx.spawn(async move |this, cx| { @@ -2965,6 +3060,12 @@ impl TerminalView { .timer(std::time::Duration::from_millis(150)) .await; let _ = this.update(cx, |view, cx| { + // A bell rung since this one owns the flash now. Holding + // Backspace on an empty bash prompt rings at key-repeat rate, + // and clearing here would blank it every few frames (#874). + if view.bell_epoch != epoch { + return; + } view.bell_flash = false; cx.notify(); }); @@ -3460,16 +3561,32 @@ impl TerminalView { } pub fn set_font_family(&mut self, family: String, cx: &mut Context) { - let fallbacks = self.font.fallbacks.clone(); let mut font = gpui::font(family); - font.fallbacks = fallbacks; if let Some(features) = &self.font_features { font.features = features.clone(); } self.font = font; + // Rebuild rather than carry the chain over: `fallback_chain` skips + // pinning a last-resort face that the family already is, so the chain + // that went with the old family can be missing a pin the new one needs. + self.reread_fallback_chain(cx); cx.notify(); } + /// Rebuild the fallback chain from the config and put it on all three faces. + /// + /// The chain is built once in `with_terminal` and then only ever cloned + /// around, so a `font_fallbacks` edit reaches new panes and no one else. + pub fn reread_fallback_chain(&mut self, cx: &mut Context) { + let chain = fallback_chain(&self.font.family, &cx.global::().font_fallbacks); + apply_fallback_chain( + chain, + &mut self.font, + &mut self.font_bold, + &mut self.font_italic, + ); + } + pub fn set_font_family_bold(&mut self, family: Option, cx: &mut Context) { self.font_bold = self.alt_font(family); cx.notify(); @@ -3849,6 +3966,33 @@ impl TerminalView { ) -> bool { use crate::core::cli_agent::AgentStatus; + // Attaching to a pane the daemon kept alive — the app restarting onto + // last session's tabs above all — has the daemon replay the pane's + // stored agent status as an ordinary report. When this app has never + // watched the pane, that is a baseline, not an edge: the turn it + // describes ended before this view existed, often before this process + // did, and reading it as "a result just landed" is what used to bring + // every restored agent tab up wearing an unread badge for output its + // reader had long since read. + // + // When an earlier view of this app did watch the pane (a workspace + // switched out and back, a window reopened from the tray), its read + // mark knows more than the replay does, so the replay stays an edge and + // the mark decides below: the same finished turn takes its badge back + // as the reader left it, anything else is news (#870). + let adopted_baseline = match self.terminal.take_replayed_agent_status() { + Some(restored) + if !cx + .try_global::() + .is_some_and(|marks| marks.0.contains_key(&(self.host_id, self.pane_id))) => + { + self.last_agent_status = restored; + self.agent_status_seen = true; + true + } + _ => false, + }; + let session = self.terminal.agent_session(); if session.as_ref().is_some_and(|s| s.rich) { self.agent_was_rich = true; @@ -3867,10 +4011,43 @@ impl TerminalView { } let status = session.as_ref().map(|s| s.status); + let turns = session.as_ref().map_or(0, |s| s.turns); + if adopted_baseline { + // From here on this app is watching the pane, so a later rebuild + // must find a mark rather than adopt its own replay. + self.record_agent_read_mark(turns, cx); + } if status == self.last_agent_status { return false; } let prev = std::mem::replace(&mut self.last_agent_status, status); + let first_sight = !std::mem::replace(&mut self.agent_status_seen, true); + + // A view built over a pane that already holds a finished turn sees + // `Done` arrive from nothing, the same as a turn finishing now. If the + // pane's previous view left a mark for this session at this turn count, + // it is the turn the reader was already shown: take their badge back as + // they left it instead of raising a new one (#870). A turn that + // finished after the old view went has no such mark, or a lower count. + if first_sight + && status == Some(AgentStatus::Done) + && let Some(mark) = cx + .try_global::() + .and_then(|marks| marks.0.get(&(self.host_id, self.pane_id))) + .filter(|mark| { + mark.status == Some(AgentStatus::Done) + && mark.session == self.last_agent_session + && mark.turns == turns + }) + .cloned() + { + self.agent_result_unread = mark.unread && !self.focus_handle.is_focused(window); + self.keep_unread_on_focus = false; + self.record_agent_read_mark(turns, cx); + cx.notify(); + return false; + } + let turn_finished = status == Some(AgentStatus::Done) && prev != Some(AgentStatus::Done); match status { @@ -3890,6 +4067,7 @@ impl TerminalView { self.keep_unread_on_focus = false; } } + self.record_agent_read_mark(turns, cx); let rich = session.as_ref().is_some_and(|s| s.rich); let agent_name = self @@ -4327,7 +4505,7 @@ impl TerminalView { { cx.propagate(); } else { - self.send_shortcut_bytes(b"\n", "enter", cx); + self.send_shortcut_bytes(self.key_flags().legacy_newline_bytes(), "enter", cx); } } @@ -5299,12 +5477,38 @@ impl TerminalView { } fn on_scroll(&mut self, ev: &ScrollWheelEvent, window: &mut Window, cx: &mut Context) { + let gesturing = self.track_scroll_gesture(ev.touch_phase); // One modifier turns the wheel into a zoom, the way it does in a // browser. Which one is the user's to say, because the default is the // platform modifier and on macOS that is a key half the world is // already holding for something else (#668). - if zoom_wheel(cx.global::().mouse_zoom_modifier, &ev.modifiers) { - self.zoom_scroll(ev, window, cx); + let wants_zoom = zoom_wheel(cx.global::().mouse_zoom_modifier, &ev.modifiers); + // A trackpad gesture answers "scroll or zoom?" once, on its first + // event, and keeps that answer until the stream dies — momentum tail + // included. The tail is why: those events are the system's, not the + // hand's, yet each one is stamped with whatever modifiers happen to be + // down as it is delivered. Reaching for ⌘ during a flick's coast — + // ⌘-Tab, ⌘-C, anything — would otherwise turn hundreds of coasting + // lines into zoom steps and leave the font at its minimum, from a + // gesture that was never a zoom (#912). + // The answer is latched per gesture, not per stream: fingers going + // back down ask it again. Carrying it over would be the same bug + // wearing the other coat — one ⌘-zoom, and every later flick zooms + // with nothing held at all, because `Started` keeps the gesture live + // and would find the old answer still sitting there. + if !gesturing || matches!(ev.touch_phase, gpui::TouchPhase::Started) { + self.gesture_zoom = None; + // Leftover travel belongs to the gesture that earned it; a new one + // must not start already part-way to a step. + self.zoom_debt = 0.; + } + let zoom = if gesturing { + *self.gesture_zoom.get_or_insert(wants_zoom) + } else { + wants_zoom + }; + if zoom { + self.zoom_scroll(ev, gesturing, window, cx); return; } let mult = cx.global::().mouse_scroll_multiplier; @@ -5313,7 +5517,6 @@ impl TerminalView { ScrollDelta::Pixels(p) => p.y.as_f32() / self.line_height.as_f32(), }; let delta = raw * mult; - let gesturing = self.track_scroll_gesture(ev.touch_phase); let quantized = !ev.modifiers.shift && { let mode = *self.terminal.term.lock().mode(); @@ -5349,7 +5552,13 @@ impl TerminalView { /// asked for the wheel. Steps go out as the same actions the keyboard and /// the View menu send, so the min/max clamp and the saved setting live in /// one place — [`Tty7App::change_font_size`](crate::ui::app::Tty7App). - fn zoom_scroll(&mut self, ev: &ScrollWheelEvent, window: &mut Window, cx: &mut Context) { + fn zoom_scroll( + &mut self, + ev: &ScrollWheelEvent, + gesturing: bool, + window: &mut Window, + cx: &mut Context, + ) { // Whatever the scrollback still had in flight is dropped: it was // travelling in lines of a font that is about to change size. self.cancel_scroll_anim(); @@ -5357,7 +5566,6 @@ impl TerminalView { ScrollDelta::Lines(p) => p.y, ScrollDelta::Pixels(p) => p.y.as_f32() / self.line_height.as_f32(), }; - let gesturing = self.track_scroll_gesture(ev.touch_phase); let (steps, debt) = zoom_scroll_steps(lines, self.zoom_debt, gesturing); self.zoom_debt = debt; for _ in 0..steps.unsigned_abs() { @@ -6728,8 +6936,15 @@ impl TerminalView { } } -fn is_typeahead_interrupt(key: &str, modifiers: &Modifiers) -> bool { - modifiers.control && !modifiers.alt && !modifiers.platform && key == "c" +fn typeahead_boundary(key: &str, modifiers: &Modifiers) -> Option> { + if !modifiers.control || modifiers.alt || modifiers.platform { + return None; + } + match key { + "c" => Some(RawInput::Interrupt), + "d" => Some(RawInput::EndOfInput), + _ => None, + } } fn sync_typeahead_owner_state( @@ -6820,6 +7035,11 @@ impl Render for TerminalView { div() .id("terminal-surface") + // The surface, not the grid inside it, is what carries the role: + // a11y focus is only ever reported for a `div` that tracks a focus + // handle *and* has a node of its own, so a terminal with no role + // here is a window whose focused element is the window. + .role(gpui::Role::MultilineTextInput) .track_focus(&self.focus_handle) .key_context(self.key_context()) .size_full() @@ -7866,8 +8086,8 @@ mod tests { use super::{ COMPLETION_MENU_MAX_W, LoopbackPlan, PortRoute, RawInput, SelectEndCopy, Typeahead, WheelRoute, clipboard_paste_text, compose_notification_title, cwd_is_on_host, - display_width, is_typeahead_interrupt, link_path_style, loopback_plan, - observe_typeahead_for_owner, + display_width, link_path_style, loopback_plan, observe_typeahead_for_owner, + typeahead_boundary, }; use super::{SCROLL_ANIM_FRAME, scroll_anim_step}; use super::{ @@ -8040,20 +8260,28 @@ mod tests { } #[test] - fn only_plain_ctrl_c_is_a_typeahead_interrupt() { + fn ctrl_c_and_ctrl_d_discard_foreground_typeahead() { let ctrl = Modifiers { control: true, ..Default::default() }; - assert!(is_typeahead_interrupt("c", &ctrl)); - assert!(!is_typeahead_interrupt("d", &ctrl)); + assert!(matches!( + typeahead_boundary("c", &ctrl), + Some(RawInput::Interrupt) + )); + assert!(matches!( + typeahead_boundary("d", &ctrl), + Some(RawInput::EndOfInput) + )); + assert!(typeahead_boundary("u", &ctrl).is_none()); let ctrl_alt = Modifiers { control: true, alt: true, ..Default::default() }; - assert!(!is_typeahead_interrupt("c", &ctrl_alt)); + assert!(typeahead_boundary("c", &ctrl_alt).is_none()); + assert!(typeahead_boundary("d", &ctrl_alt).is_none()); } fn ws(target: RemoteTarget, with_spec: bool) -> PaneWorkspace { @@ -8574,6 +8802,33 @@ mod tests { ); } + #[test] + fn apply_fallback_chain_reaches_every_face_a_view_has() { + // Bold and italic are the ones at risk: they hold a copy taken off the + // regular face when `alt_font` built them, so a rebuild that wrote only + // the regular face would strand them on the chain it replaced. + let mut font = gpui::font("Hack"); + let mut bold = Some(gpui::font("Hack Bold")); + let mut italic = Some(gpui::font("Hack Italic")); + + super::apply_fallback_chain(vec!["Menlo".to_string()], &mut font, &mut bold, &mut italic); + + for face in [&font, bold.as_ref().unwrap(), italic.as_ref().unwrap()] { + assert_eq!(face.fallbacks.as_ref().unwrap().fallback_list(), ["Menlo"]); + } + + // Neither is configured by default, and a view carries `None` for one + // it was never given. + let (mut none_bold, mut none_italic) = (None, None); + super::apply_fallback_chain( + vec!["Menlo".to_string()], + &mut font, + &mut none_bold, + &mut none_italic, + ); + assert_eq!(font.fallbacks.unwrap().fallback_list(), ["Menlo"]); + } + #[test] fn fallback_chain_appends_platform_stock_faces() { let stock = crate::core::config::platform_last_resort_fallbacks(); @@ -9610,6 +9865,22 @@ pub(crate) fn quiet_test_pane( (view, daemon_side) } +/// The same pane, but reattached rather than spawned — what restoring last +/// session's tabs builds, and the only shape in which the daemon replays state +/// the pane already had. +#[cfg(test)] +pub(crate) fn quiet_reattached_test_pane( + pane_id: u64, + window: &mut Window, + cx: &mut gpui::App, +) -> (gpui::Entity, crate::daemon::transport::Stream) { + let (client_side, daemon_side) = test_stream_pair(); + let terminal = RemoteTerminal::from_stream_reattached(client_side, TermSize::new(80, 24)) + .expect("quiet reattached test terminal"); + let view = cx.new(|cx| TerminalView::with_terminal(terminal, pane_id, window, cx)); + (view, daemon_side) +} + /// A quiet pane that was dialled by hand, with no saved host behind it. /// /// Ungated on purpose: the transport this hands back is already @@ -9661,9 +9932,22 @@ mod gpui_tests { use super::*; use crate::daemon::protocol::{ClientMsg, DaemonMsg}; use crate::daemon::transport::Stream; + use crate::terminal::remote::PtySource; use gpui::{Entity, TestAppContext, point}; fn harness(cx: &mut TestAppContext) -> (gpui::WindowHandle, Stream) { + let pty = if cfg!(windows) { + PtySource::LocalConpty + } else { + PtySource::Raw + }; + harness_on(cx, pty) + } + + fn harness_on( + cx: &mut TestAppContext, + pty: PtySource, + ) -> (gpui::WindowHandle, Stream) { // Building a view reads the config. Whether that hit the real user // directory used to come down to which test happened to pin the // scratch dir first. @@ -9675,8 +9959,13 @@ mod gpui_tests { cx.set_global(Config::default()); }); let window = cx.add_window(|window, cx| { - let terminal = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)) - .expect("socketpair-backed terminal"); + let terminal = RemoteTerminal::from_stream_with( + client_side, + TermSize::new(80, 24), + Vec::new(), + pty, + ) + .expect("socketpair-backed terminal"); TerminalView::with_terminal(terminal, 1, window, cx) }); (window, daemon_side) @@ -9785,6 +10074,7 @@ mod gpui_tests { rich: true, cwd: None, activity: 0, + turns: 0, })) .encode(daemon) .unwrap(); @@ -9840,6 +10130,7 @@ mod gpui_tests { rich: true, cwd: None, activity: 0, + turns: 0, })) .encode(&mut daemon) .unwrap(); @@ -9884,10 +10175,21 @@ mod gpui_tests { pane: &gpui::Entity, cx: &mut TestAppContext, daemon: &mut Stream, + ) { + report_agent_turn(status, 0, pane, cx, daemon); + } + + /// The same, for a session that has finished `turns` turns so far. + fn report_agent_turn( + status: crate::core::cli_agent::AgentStatus, + turns: u64, + pane: &gpui::Entity, + cx: &mut TestAppContext, + daemon: &mut Stream, ) { use crate::core::cli_agent::AgentSessionState; - DaemonMsg::AgentStatus(Some(AgentSessionState { + let state = AgentSessionState { status, message: None, session_id: Some("sid-abc".into()), @@ -9895,13 +10197,13 @@ mod gpui_tests { rich: true, cwd: None, activity: 0, - })) - .encode(daemon) - .unwrap(); + turns, + }; + DaemonMsg::AgentStatus(Some(state.clone())) + .encode(daemon) + .unwrap(); for _ in 0..200 { - if cx.update(|cx| pane.read(cx).terminal.agent_session().map(|s| s.status)) - == Some(status) - { + if cx.update(|cx| pane.read(cx).terminal.agent_session()) == Some(state.clone()) { return; } std::thread::sleep(std::time::Duration::from_millis(5)); @@ -9909,6 +10211,161 @@ mod gpui_tests { panic!("the agent status never reached the pane"); } + /// Poll `pane`'s agent status inside `window` and read its badge back. + fn poll_unread( + window: gpui::WindowHandle, + pane: &gpui::Entity, + cx: &mut TestAppContext, + ) -> bool { + // Through the untyped handle: the typed one leases the root view, and + // `pane` may be that view. + cx.update_window(window.into(), |_, window, cx| { + pane.update(cx, |pane, cx| { + pane.poll_agent_status(false, window, cx); + pane.agent_result_unread() + }) + }) + .unwrap() + } + + /// Switching workspaces, or reopening a window from the tray, throws the + /// pane's view away and builds a new one on the same daemon pane (#870). + /// The new view's first look at a `Done` agent is not a turn finishing — + /// the reader watched that one finish before the old view went. + #[gpui::test] + fn a_rebuilt_pane_does_not_re_badge_a_turn_the_reader_already_saw(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + let (window, mut before_daemon) = harness(cx); + let before = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &before, cx, &mut before_daemon); + assert!( + !poll_unread(window, &before, cx), + "the reader watched it finish" + ); + + // The same daemon pane, rebuilt the way `tabs_from_session` rebuilds it, + // with the reader's focus somewhere else. + let (after, mut daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(1, window, cx)) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &after, cx, &mut daemon); + assert!( + !poll_unread(window, &after, cx), + "rebuilding the pane is not a turn finishing" + ); + } + + /// What the rebuild must not swallow: a turn that was still running when + /// the view went away and finished before the new one arrived. + #[gpui::test] + fn a_turn_that_finished_while_the_pane_was_away_still_badges(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + let (window, mut before_daemon) = harness(cx); + let before = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Working, 0, &before, cx, &mut before_daemon); + assert!(!poll_unread(window, &before, cx)); + + let (after, mut daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(1, window, cx)) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &after, cx, &mut daemon); + assert!( + poll_unread(window, &after, cx), + "nobody saw this turn finish" + ); + } + + /// Nor a whole later turn: the reader saw turn one, the agent was sent + /// another and finished it while the pane was away. The status reads + /// `Done` both times; only the turn count tells them apart. + #[gpui::test] + fn a_later_turn_that_finished_while_the_pane_was_away_still_badges(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + let (window, mut before_daemon) = harness(cx); + let before = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &before, cx, &mut before_daemon); + assert!(!poll_unread(window, &before, cx)); + + let (after, mut daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(1, window, cx)) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 2, &after, cx, &mut daemon); + assert!( + poll_unread(window, &after, cx), + "the second turn finished unseen" + ); + } + + /// And a badge the reader had not cleared yet comes back with the pane. + #[gpui::test] + fn an_unread_turn_is_still_unread_after_the_pane_is_rebuilt(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + let (window, mut before_daemon) = harness(cx); + let before = window.update(cx, |_, _, cx| cx.entity()).unwrap(); + // Building another pane takes the window's focus off `before`. + let (_elsewhere, _elsewhere_daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(5, window, cx)) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &before, cx, &mut before_daemon); + assert!(poll_unread(window, &before, cx), "nobody was looking"); + + let (after, mut daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(1, window, cx)) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + report_agent_turn(AgentStatus::Done, 1, &after, cx, &mut daemon); + assert!( + poll_unread(window, &after, cx), + "rebuilding the pane is not reading it" + ); + } + /// The badge answers "did the reader see this?", so it has to read the /// window's live focus rather than anything a focus callback left behind. /// @@ -9954,6 +10411,197 @@ mod gpui_tests { .unwrap(); } + /// Reinstalling or restarting the app leaves the daemon — and every agent + /// in it — running, so each restored tab reattaches to a pane whose agent + /// finished its turn long ago. The daemon replays that status, and reading + /// it as a turn that just landed put an unread badge on every agent tab in + /// the window the moment it opened. + #[gpui::test] + fn a_restored_pane_does_not_badge_the_turn_it_reattached_to(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + crate::core::config::pin_test_config_dir(); + let (window, _root_daemon) = harness(cx); + let (pane, mut daemon) = window + .update(cx, |_, window, cx| { + super::quiet_reattached_test_pane(2, window, cx) + }) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx); + }) + .unwrap(); + cx.run_until_parked(); + + report_agent_status(AgentStatus::Done, &pane, cx, &mut daemon); + window + .update(cx, |_, window, cx| { + pane.update(cx, |pane, cx| { + pane.poll_agent_status(false, window, cx); + assert!( + !pane.agent_result_unread(), + "the replayed status is where this pane starts, not a result that \ + just arrived" + ); + }); + }) + .unwrap(); + + // And the pane is still armed: the next turn it actually watches finish + // badges exactly as it would have without the reattach. + report_agent_status(AgentStatus::Working, &pane, cx, &mut daemon); + window + .update(cx, |_, window, cx| { + pane.update(cx, |pane, cx| { + pane.poll_agent_status(false, window, cx); + }); + }) + .unwrap(); + report_agent_status(AgentStatus::Done, &pane, cx, &mut daemon); + window + .update(cx, |_, window, cx| { + pane.update(cx, |pane, cx| { + pane.poll_agent_status(false, window, cx); + assert!( + pane.agent_result_unread(), + "a turn that finished while the reader was elsewhere is unread" + ); + }); + }) + .unwrap(); + } + + /// Build `pane_id`'s first view (unfocused), let it watch `status` at + /// `turns`, then rebuild the pane the way restoring a workspace does — a + /// reattach, whose head is the daemon replaying `replayed` — and read the + /// rebuilt view's badge. + fn rebuild_by_reattach( + watched: (crate::core::cli_agent::AgentStatus, u64), + replayed: (crate::core::cli_agent::AgentStatus, u64), + cx: &mut TestAppContext, + ) -> bool { + let (window, _root_daemon) = harness(cx); + let focus_root = |cx: &mut TestAppContext| { + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx) + }) + .unwrap(); + cx.run_until_parked(); + }; + let (before, mut before_daemon) = window + .update(cx, |_, window, cx| super::quiet_test_pane(2, window, cx)) + .unwrap(); + focus_root(cx); + report_agent_turn(watched.0, watched.1, &before, cx, &mut before_daemon); + poll_unread(window, &before, cx); + drop(before); + + let (after, mut daemon) = window + .update(cx, |_, window, cx| { + super::quiet_reattached_test_pane(2, window, cx) + }) + .unwrap(); + focus_root(cx); + report_agent_turn(replayed.0, replayed.1, &after, cx, &mut daemon); + poll_unread(window, &after, cx) + } + + /// A reattach whose pane this app already watched is a rebuild, not a + /// restart: the read mark, not the replay, says whether the turn is news. + #[gpui::test] + fn a_reattached_pane_takes_back_the_badge_its_reader_left(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + assert!( + rebuild_by_reattach((AgentStatus::Done, 1), (AgentStatus::Done, 1), cx), + "the reader never cleared that badge; rebuilding the pane is not reading it" + ); + } + + #[gpui::test] + fn a_reattached_pane_badges_a_turn_that_was_running_when_its_view_went( + cx: &mut TestAppContext, + ) { + use crate::core::cli_agent::AgentStatus; + assert!( + rebuild_by_reattach((AgentStatus::Working, 0), (AgentStatus::Done, 1), cx), + "nobody saw this turn finish" + ); + } + + #[gpui::test] + fn a_reattached_pane_badges_a_later_turn_that_finished_while_away(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + // Turn one left a mark; turn two finishing before the reattach is a + // different count, so the mark does not vouch for it. + assert!( + rebuild_by_reattach((AgentStatus::Done, 1), (AgentStatus::Done, 2), cx), + "the second turn finished unseen" + ); + } + + /// A relink keeps the view and what it last saw. A turn that was running + /// when the link dropped and finished before it came back reaches the view + /// only as the daemon's replay, and that replay has to badge: nobody saw + /// the turn finish. + #[gpui::test] + fn a_turn_that_finished_while_the_link_was_down_still_badges(cx: &mut TestAppContext) { + use crate::core::cli_agent::AgentStatus; + + crate::core::config::pin_test_config_dir(); + let (window, _root_daemon) = harness(cx); + let (pane, mut daemon) = window + .update(cx, |_, window, cx| { + super::quiet_reattached_test_pane(2, window, cx) + }) + .unwrap(); + window + .update(cx, |view, window, cx| { + view.focus_handle.clone().focus(window, cx); + }) + .unwrap(); + cx.run_until_parked(); + + report_agent_status(AgentStatus::Working, &pane, cx, &mut daemon); + window + .update(cx, |_, window, cx| { + pane.update(cx, |pane, cx| { + pane.poll_agent_status(false, window, cx); + assert!(!pane.agent_result_unread(), "the turn is still running"); + }); + }) + .unwrap(); + + let (new_client, mut new_daemon) = super::test_stream_pair(); + pane.update(cx, |pane, cx| { + pane.adopt_relink( + new_client, + Vec::new(), + &crate::terminal::PaneRoute::Local, + TermSize::new(80, 24), + 8, + 17, + cx, + ) + .expect("the swap itself cannot fail"); + }); + drop(daemon); + + report_agent_status(AgentStatus::Done, &pane, cx, &mut new_daemon); + window + .update(cx, |_, window, cx| { + pane.update(cx, |pane, cx| { + pane.poll_agent_status(false, window, cx); + assert!( + pane.agent_result_unread(), + "the turn finished while the link was down, so nobody read it" + ); + }); + }) + .unwrap(); + } + #[gpui::test] fn a_finished_turn_on_the_focused_pane_is_already_read(cx: &mut TestAppContext) { use crate::core::cli_agent::AgentStatus; @@ -10006,6 +10654,7 @@ mod gpui_tests { rich: true, cwd: Some(working_in.clone()), activity: 0, + turns: 0, })) .encode(&mut daemon) .unwrap(); @@ -11007,11 +11656,26 @@ mod gpui_tests { #[gpui::test] fn passthrough_ctrl_c_discards_typeahead_before_the_shell_can_resume(cx: &mut TestAppContext) { + assert_foreground_interrupt_does_not_wipe_prompt(cx, "agent input", "ctrl-c", 0x03); + } + + #[gpui::test] + fn passthrough_ctrl_d_discards_typeahead_before_the_shell_can_resume(cx: &mut TestAppContext) { + // Ctrl-D only ends input on an empty line; with text it is an edit. + assert_foreground_interrupt_does_not_wipe_prompt(cx, "", "ctrl-d", 0x04); + } + + fn assert_foreground_interrupt_does_not_wipe_prompt( + cx: &mut TestAppContext, + pending: &str, + chord: &str, + byte: u8, + ) { let (window, mut daemon) = harness(cx); window .update(cx, |view, window, cx| { assert!(!view.input_active(), "the foreground process owns input"); - view.typeahead.observe(RawInput::Text("agent input"), false); + view.typeahead.observe(RawInput::Text(pending), false); view.typeahead.observe( RawInput::Key { key: "up", @@ -11022,23 +11686,70 @@ mod gpui_tests { view.on_key_down( &KeyDownEvent { - keystroke: key("ctrl-c"), + keystroke: key(chord), is_held: false, prefer_character_input: false, }, window, cx, ); - assert_eq!(view.typeahead.drain(), None); + // Exercise the consumers without draining their input first. + view.adopt_typeahead(); view.flush_typeahead(); + assert!(view.cmd.text().is_empty()); }) .unwrap(); - assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x03])); + assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![byte])); assert_eq!( next_input_until_timeout(&mut daemon), None, - "resuming the shell must not synthesize Ctrl-U after Ctrl-C" + "resuming the shell must not synthesize Ctrl-U after {chord}" + ); + } + + #[gpui::test] + fn submitted_exit_typeahead_does_not_wipe_the_returned_prompt(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + window + .update(cx, |view, window, cx| { + // Ordinary SSH need not take the alternate screen or identify + // as an agent. Its input reaches the passthrough recorder. + assert!(!view.input_active()); + for ch in ["e", "x", "i", "t"] { + type_char(view, ch, window, cx); + } + view.on_key_down( + &KeyDownEvent { + keystroke: key("enter"), + is_held: false, + prefer_character_input: false, + }, + window, + cx, + ); + }) + .unwrap(); + for bytes in [b"e", b"x", b"i", b"t", b"\r"] { + assert_eq!(next_input_until_timeout(&mut daemon), Some(bytes.to_vec())); + } + + prompt_ready(&window, cx, &mut daemon); + window + .update(cx, |view, _, _| { + assert!(view.input_active()); + view.adopt_typeahead(); + view.flush_typeahead(); + assert!( + view.cmd.text().is_empty(), + "exit belongs to the finished session" + ); + }) + .unwrap(); + assert_eq!( + next_input_until_timeout(&mut daemon), + None, + "returning from exit must not inject Ctrl-U into the local prompt" ); } @@ -11861,7 +12572,7 @@ mod gpui_tests { #[gpui::test] fn shift_enter_reaches_a_foreground_tui_with_kitty_encoding(cx: &mut TestAppContext) { crate::core::config::pin_test_config_dir(); - let (window, mut daemon) = harness(cx); + let (window, mut daemon) = harness_on(cx, PtySource::LocalConpty); cx.update(|cx| crate::ui::keymap::init(cx)); DaemonMsg::Output(b"\x1b[>1u".to_vec()) .encode(&mut daemon) @@ -11891,12 +12602,18 @@ mod gpui_tests { next_input_until_timeout(&mut daemon), Some(b"\x1b[13;2u".to_vec()) ); + + vcx.simulate_keystrokes("ctrl-j"); + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"\x1b[106;5u".to_vec()) + ); } #[gpui::test] fn shift_enter_reaches_a_foreground_tui_as_lf_without_kitty(cx: &mut TestAppContext) { crate::core::config::pin_test_config_dir(); - let (window, mut daemon) = harness(cx); + let (window, mut daemon) = harness_on(cx, PtySource::Raw); cx.update(|cx| crate::ui::keymap::init(cx)); window .update(cx, |view, window, cx| { @@ -11910,6 +12627,32 @@ mod gpui_tests { vcx.simulate_keystrokes("shift-enter"); assert_eq!(next_input_until_timeout(&mut daemon), Some(b"\n".to_vec())); + + vcx.simulate_keystrokes("ctrl-j"); + assert_eq!(next_input_until_timeout(&mut daemon), Some(b"\n".to_vec())); + } + + #[gpui::test] + fn newline_chords_reach_conpty_as_ctrl_j_without_kitty(cx: &mut TestAppContext) { + let (window, mut daemon) = harness_on(cx, PtySource::LocalConpty); + cx.update(|cx| crate::ui::keymap::init(cx)); + window + .update(cx, |view, window, cx| { + assert!(!view.input_active()); + window.activate_window(); + view.focus_handle.focus(window, cx); + }) + .unwrap(); + + let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx); + for chord in ["shift-enter", "ctrl-j"] { + vcx.simulate_keystrokes(chord); + assert_eq!( + next_input_until_timeout(&mut daemon), + Some(b"\x1b[74;36;10;1;8;1_\x1b[74;36;10;0;8;1_".to_vec()), + "{chord} must preserve Ctrl+J for native console readers" + ); + } } #[gpui::test] @@ -12607,6 +13350,73 @@ mod gpui_tests { .unwrap(); } + /// The bug this guards: a two-finger flick coasts long after the fingers + /// are gone, and every coasting event carries whatever modifiers are down + /// when it lands. Grabbing ⌘ for something else mid-coast used to read as + /// a zoom and run the font down to its minimum in a blink (#912). + #[gpui::test] + fn a_modifier_pressed_mid_flick_does_not_hijack_it_into_a_zoom(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, w, cx| { + scroll_into_history(view, 10); + view.on_scroll(&wheel(view, -0.5, gpui::TouchPhase::Started), w, cx); + let before = display_offset(view); + // The tail, now stamped with ⌘ the hand reached for. + let mut tail = wheel(view, -3., gpui::TouchPhase::Moved); + tail.modifiers = Modifiers::secondary_key(); + view.on_scroll(&tail, w, cx); + assert!( + display_offset(view) != before || view.scroll_anim.is_some(), + "the tail stayed a scroll, the way the gesture started" + ); + assert_eq!(view.zoom_debt, 0., "and never paid into the zoom"); + }) + .unwrap(); + } + + /// The same latch the other way round: a zoom gesture that outlives the + /// modifier keeps zooming rather than dumping its tail into the buffer. + #[gpui::test] + fn a_zoom_gesture_keeps_zooming_after_the_modifier_is_released(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, w, cx| { + scroll_into_history(view, 10); + let mut start = wheel(view, -0.5, gpui::TouchPhase::Started); + start.modifiers = Modifiers::secondary_key(); + view.on_scroll(&start, w, cx); + view.on_scroll(&wheel(view, -0.5, gpui::TouchPhase::Moved), w, cx); + assert_eq!(display_offset(view), 10, "the grid never moved"); + assert!(view.scroll_anim.is_none(), "and nothing was queued for it"); + }) + .unwrap(); + } + + /// The latch is per gesture, not per stream: fingers going down again + /// start a fresh question. Carrying the last answer over would mean one + /// ⌘-zoom left every later flick zooming with no modifier held at all. + #[gpui::test] + fn a_new_gesture_is_not_bound_by_what_the_last_one_answered(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + window + .update(cx, |view, w, cx| { + scroll_into_history(view, 10); + let mut zoom = wheel(view, -0.5, gpui::TouchPhase::Started); + zoom.modifiers = Modifiers::secondary_key(); + view.on_scroll(&zoom, w, cx); + assert_eq!(display_offset(view), 10, "that one was a zoom"); + // Fingers down again, nothing held: a plain scroll. + view.on_scroll(&wheel(view, -3., gpui::TouchPhase::Started), w, cx); + assert_ne!( + display_offset(view), + 10, + "the new gesture asked the question again" + ); + }) + .unwrap(); + } + /// A detent is one step however many lines the platform bills it as — /// macOS calls a single notch five. #[test] @@ -14163,6 +14973,40 @@ mod gpui_tests { .unwrap(); } + /// Holding Backspace on an empty bash prompt (or Tab with nothing to + /// complete) rings the bell at key-repeat rate. Every flash used to arm its + /// own clear timer, so the first bell's timer blanked a flash the fifth bell + /// had just re-lit, and the pane strobed for as long as the key was held + /// (#874). + #[gpui::test] + fn a_bell_rung_at_key_repeat_rate_holds_one_steady_flash(cx: &mut TestAppContext) { + let (window, _daemon) = harness(cx); + let lit = + |cx: &mut TestAppContext| window.update(cx, |view, _, _| view.bell_flash).unwrap(); + + let repeat = std::time::Duration::from_millis(33); + let mut dark = Vec::new(); + for i in 0..30 { + window + .update(cx, |view, _, cx| view.handle_event(AlacEvent::Bell, cx)) + .unwrap(); + cx.executor().advance_clock(repeat); + cx.run_until_parked(); + if !lit(cx) { + dark.push(i); + } + } + assert!( + dark.is_empty(), + "the flash went dark between bells after repeats {dark:?}" + ); + + cx.executor() + .advance_clock(std::time::Duration::from_millis(300)); + cx.run_until_parked(); + assert!(!lit(cx), "the flash outlived the last bell"); + } + #[gpui::test] fn text_area_size_request_replies_with_the_current_geometry(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -14675,6 +15519,125 @@ mod gpui_tests { ); } + /// #844: a TUI that resets DECTCEM and draws its own reverse-video caret + /// gets no terminal caret painted over it — focused, unfocused, and after + /// a re-attach replays its screen — and `?25h` brings the caret back. + /// + /// The stream is the reporter's shape: an alternate screen and 69 `?25l` + /// interleaved with 75 `?25h`, the last one a hide. What is checked is the + /// snapshot a real paint left behind, through the same `painted_cursor` + /// the element paints from. + #[gpui::test] + fn dectcem_reset_paints_no_terminal_caret_over_the_tuis_own(cx: &mut TestAppContext) { + use crate::core::config::CursorStyle; + + // An Ink-style frame: our own caret as one reverse-video cell at + // row 5 column 13, and the real cursor parked on it. + const FRAME: &[u8] = b"\x1b[5;1H\x1b[2K> type here \x1b[7m \x1b[27m\x1b[5;13H"; + let mut stream = b"\x1b[?1049h".to_vec(); + stream.extend(std::iter::repeat_n(&b"\x1b[?25h"[..], 7).flatten()); + for _ in 0..68 { + stream.extend_from_slice(b"\x1b[?25l"); + stream.extend_from_slice(FRAME); + stream.extend_from_slice(b"\x1b[?25h"); + } + stream.extend_from_slice(b"\x1b[?25l"); + stream.extend_from_slice(FRAME); + assert_eq!(stream.windows(6).filter(|w| w == b"\x1b[?25l").count(), 69); + assert_eq!(stream.windows(6).filter(|w| w == b"\x1b[?25h").count(), 75); + + type Painted = Option<(usize, usize, CursorStyle)>; + // Paints frames until the one the pane settles on matches `want`, and + // returns the last painted caret either way. + let paint_until = |window: &gpui::WindowHandle, + cx: &mut TestAppContext, + focused: bool, + want: &dyn Fn(Painted) -> bool| { + let mut painted = None; + for _ in 0..400 { + window + .update(cx, |view, window, cx| { + if focused { + window.activate_window(); + view.focus_handle.focus(window, cx); + } else { + window.blur(); + } + cx.notify(); + }) + .unwrap(); + let mut vcx = gpui::VisualTestContext::from_window((*window).into(), cx); + vcx.update(|window, _| window.refresh()); + vcx.run_until_parked(); + let (text, snap) = window + .update(cx, |view, window, _| { + assert_eq!(view.focus_handle.is_focused(window), focused); + use alacritty_terminal::grid::Dimensions as _; + use alacritty_terminal::index::{Column, Line}; + let term = view.terminal.term.lock(); + let row = &term.grid()[Line(4)]; + let text = (0..term.grid().columns()) + .map(|col| row[Column(col)].c) + .collect::(); + (text, view.grid_snap.as_ref().map(|s| s.painted_cursor())) + }) + .unwrap(); + if text.starts_with("> type here") + && let Some(p) = snap + { + painted = p; + if want(p) { + break; + } + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + painted + }; + + let (window, mut daemon) = harness(cx); + DaemonMsg::Output(stream.clone()) + .encode(&mut daemon) + .unwrap(); + let focused = paint_until(&window, cx, true, &|p| p.is_none()); + assert_eq!( + focused, None, + "a focused pane painted its caret over a TUI that reset DECTCEM" + ); + let unfocused = paint_until(&window, cx, false, &|p| p.is_none()); + assert_eq!( + unfocused, None, + "an unfocused pane painted its hollow caret over a TUI that reset DECTCEM" + ); + + DaemonMsg::Output(b"\x1b[?25h".to_vec()) + .encode(&mut daemon) + .unwrap(); + let shown = paint_until(&window, cx, true, &|p| p.is_some()); + assert_eq!( + shown.map(|(row, col, _)| (row, col)), + Some((4, 12)), + "`?25h` must bring the caret back where the program parked it" + ); + + // Switching back to the tab: a brand new view replays the screen the + // daemon kept, then the prompt state, which says a program is running. + let (window, mut daemon) = harness(cx); + DaemonMsg::Snapshot(stream).encode(&mut daemon).unwrap(); + DaemonMsg::Prompt { + active: true, + at_prompt: false, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + let replayed = paint_until(&window, cx, true, &|p| p.is_none()); + assert_eq!( + replayed, None, + "a re-attached pane painted its caret over a TUI that reset DECTCEM" + ); + } + #[gpui::test] fn child_exit_emits_the_close_event_but_disconnect_does_not(cx: &mut TestAppContext) { use std::cell::Cell; @@ -15073,6 +16036,127 @@ mod prompt_handover_tests { panic!("never settled: {what}"); } + /// Bytes from the pane's pty, the way the daemon forwards them. + fn output(daemon: &mut Stream, bytes: &[u8]) { + DaemonMsg::Output(bytes.to_vec()).encode(daemon).unwrap(); + } + + /// Waits for the tab's reading of what the pane calls itself to become + /// `expect`, running out the wait a new title is held for on each pass. + fn titled( + cx: &mut TestAppContext, + window: &gpui::WindowHandle, + expect: Option<&str>, + ) { + for _ in 0..300 { + cx.run_until_parked(); + cx.executor().advance_clock(TITLE_SETTLE * 2); + cx.run_until_parked(); + let showing = window + .update(cx, |view, _, _| view.stated_title().map(str::to_string)) + .unwrap(); + if showing.as_deref() == expect { + return; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + panic!("the tab never came to read {expect:?}"); + } + + /// #889: a tab went on reading the name Claude Code had left on it long + /// after Claude exited and the pane was back at its own prompt in a real + /// directory. An OSC 0/2 had no end — only another OSC 0/2 replaced it — + /// so the last title any program wrote in a pane outlived it forever. + #[gpui::test] + fn a_title_a_command_set_is_retired_when_that_command_finishes(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + output(&mut daemon, b"\x1b]133;A\x07\x1b]133;B\x07"); + output( + &mut daemon, + b"\x1b]133;C;claude\x07\x1b]2;\xe2\x9c\xb3 fixing the switcher\x1b\\", + ); + titled(cx, &window, Some("✳ fixing the switcher")); + + // Claude exits and the shell reports the command finished. Nothing + // titles the pane after it, so the tab has to fall back down the + // label ladder — `stated_title` saying nothing is how it does that. + output(&mut daemon, b"\x1b]133;D;0\x07"); + titled(cx, &window, None); + } + + /// The two cases a title is *supposed* to outlive: a program that is still + /// running, and a shell that titles its own prompt (which it does between + /// the `D` and the `A`, so it is the last word rather than a thing undone). + #[gpui::test] + fn a_running_program_and_a_shells_own_prompt_title_both_keep_theirs(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + output( + &mut daemon, + b"\x1b]133;C;vim\x07\x1b]2;vim \xe2\x80\x94 main.rs\x1b\\", + ); + titled(cx, &window, Some("vim — main.rs")); + + output( + &mut daemon, + b"\x1b]133;D;0\x07\x1b]0;me@box:~/dev\x07\x1b]133;A\x07\x1b]133;B\x07", + ); + titled(cx, &window, Some("me@box:~/dev")); + settle(cx, &window, "the prompt is reading", |view| { + view.terminal.zle_reading() + }); + + // And that prompt title is nobody's command to retire. Both marks are + // chased by an edge the pane reports, so the assertion below cannot + // pass on bytes that have not landed yet. + output(&mut daemon, b"\x1b]133;C;ls\x07"); + settle(cx, &window, "a command takes the pane", |view| { + !view.terminal.zle_reading() + }); + output(&mut daemon, b"\x1b]133;D;0\x07\x1b]133;B\x07"); + settle(cx, &window, "the prompt comes back", |view| { + view.terminal.zle_reading() + }); + titled(cx, &window, Some("me@box:~/dev")); + } + + /// #889 on a reattached window: a long session outran the daemon's replay + /// ring, so the replay carries the program's title but not the `C` that + /// started it. The replayed prompt state says a command owns the pane, + /// and that is enough to know the title is the command's to lose at `D`. + #[gpui::test] + fn a_reattached_window_retires_a_title_whose_c_rolled_out_of_the_replay( + cx: &mut TestAppContext, + ) { + crate::core::config::pin_test_config_dir(); + cx.executor().allow_parking(); + let (client_side, mut daemon) = test_stream_pair(); + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(Config::default()); + }); + let window = cx.add_window(|window, cx| { + let terminal = + RemoteTerminal::from_stream_reattached(client_side, TermSize::new(80, 24)) + .expect("reattached link-backed terminal"); + TerminalView::with_terminal(terminal, 1, window, cx) + }); + + DaemonMsg::Snapshot(b"\x1b]2;\xe2\x9c\xb3 fixing the switcher\x1b\\redraw".to_vec()) + .encode(&mut daemon) + .unwrap(); + DaemonMsg::Prompt { + active: true, + at_prompt: false, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + titled(cx, &window, Some("✳ fixing the switcher")); + + output(&mut daemon, b"\x1b]133;D;0\x07"); + titled(cx, &window, None); + } + /// Printable text arrives the way the platform delivers it — through the /// text-input path, which is what the gap hold and the typeahead record see. fn type_text(window: &gpui::WindowHandle, cx: &mut TestAppContext, text: &str) { diff --git a/src/ui/app.rs b/src/ui/app.rs index 90e34dfb..fcb5134a 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -802,6 +802,7 @@ pub struct Tty7App { pub(crate) font_family: String, pub(crate) font_family_bold: Option, pub(crate) font_family_italic: Option, + pub(crate) font_fallbacks: Vec, pub(crate) font_features: Option, terminal_cursor_style: ConfigCursorStyle, terminal_scrollback_limit: usize, @@ -1115,6 +1116,31 @@ fn clear_window_override_values(config: &mut Config, backdrop_is_local: bool) { } } +/// The id the fullscreen hint is pushed under, so that entering again replaces +/// it and leaving takes it away. +struct FullscreenHint; + +/// Whether the title bar carries minimize, maximize and close right now. +/// +/// Not in fullscreen. A fullscreen window has no caption: Windows clears +/// `WS_CAPTION` and answers `HTCLIENT` along the whole top edge, so the three +/// buttons would draw, light up under the pointer and do nothing when clicked. +/// The row they sit at the end of stays, because it is also the tab strip. +/// +/// Never on macOS, which draws no buttons of its own: those are the system's +/// traffic lights, and the system hides them itself. +pub(crate) fn window_controls_drawn(fullscreen: bool) -> bool { + !cfg!(target_os = "macos") && !fullscreen +} + +/// How much of the title bar's trailing end the window buttons take. +pub(crate) fn window_controls_w(fullscreen: bool) -> f32 { + match window_controls_drawn(fullscreen) { + true => WINDOW_CONTROLS_W, + false => 0., + } +} + impl Tty7App { pub fn for_workspace( id: Option, @@ -1272,6 +1298,7 @@ impl Tty7App { font_family, font_family_bold, font_family_italic, + font_fallbacks, font_features, terminal_cursor_style, terminal_scrollback_limit, @@ -1283,6 +1310,7 @@ impl Tty7App { cfg.font_family.clone(), cfg.font_family_bold.clone(), cfg.font_family_italic.clone(), + cfg.font_fallbacks.clone(), cfg.font_features .as_ref() .map(crate::core::config::gpui_font_features), @@ -1418,6 +1446,7 @@ impl Tty7App { font_family, font_family_bold, font_family_italic, + font_fallbacks, font_features, terminal_cursor_style, terminal_scrollback_limit, @@ -3590,6 +3619,46 @@ impl Tty7App { remember_leaf_in(&mut self.tabs, leaf); } + /// Toggle fullscreen, and say how to leave it on the way in. + /// + /// Only on the way in, and only from the action: entering is an instant in + /// which the window buttons disappear, and a window that starts fullscreen + /// because the setting says so is not a surprise anybody needs explaining. + /// The chord comes from the keymap rather than from a string, because it is + /// `F11` on Windows and Linux, `Cmd+Enter` on macOS, and either of them may + /// have been rebound. + /// + /// The hint carries an id of its own, which is what keeps a held-down + /// `F11` to one notice rather than a column of identical ones: pushing + /// under an id already on screen replaces that one. Leaving through the + /// action takes it back too, so a quick in-and-out does not leave the way + /// out on screen after it has been taken. Leaving some other way — a + /// window manager with a chord of its own — just lets it time out, which + /// is a second or two of a stale notice and not worth watching every + /// frame for. + fn toggle_fullscreen(&self, window: &mut Window, cx: &mut App) { + let entering = !window.is_fullscreen(); + window.toggle_fullscreen(); + window.remove_notification::(cx); + // Nothing disappeared where there were no buttons to begin with, so + // there is nothing to explain. + if !entering || !window_controls_drawn(false) { + return; + } + let hint = match crate::ui::home::key_hint("ToggleFullscreen", cx) { + Some(chord) => t_fmt(L10nKey::AppFullscreenEntered, &[("key", &chord)]), + // Rebound to nothing at all: still worth saying the buttons are gone, + // just without naming a key that would not work. + None => t(L10nKey::AppFullscreenEnteredNoKey).to_string(), + }; + window.push_notification( + gpui_component::notification::Notification::new() + .id::() + .message(hint), + cx, + ); + } + fn focus_leaf(&self, leaf: &PaneSlot, window: &mut Window, cx: &mut App) { let handle = leaf.focus_handle(cx); window.focus(&handle, cx); @@ -4829,7 +4898,7 @@ impl Tty7App { refocus.as_ref().map(|s| s.entity_id()) == Some(leaf.entity_id()); leaf.update(cx, |view, cx| { if view.agent_session().map(|s| s.status) == Some(AgentStatus::Done) { - view.mark_agent_result_unread(refocus_incoming); + view.mark_agent_result_unread(refocus_incoming, cx); cx.notify(); } }); @@ -5422,7 +5491,7 @@ impl Tty7App { NextTab => self.cycle_tab(true, window, cx), PrevTab => self.cycle_tab(false, window, cx), ToggleMaximizePane => self.toggle_maximize(window, cx), - ToggleFullscreen => window.toggle_fullscreen(), + ToggleFullscreen => self.toggle_fullscreen(window, cx), ToggleTabSidebar => self.toggle_tab_sidebar(cx), ToggleLeftPanel => self.toggle_left_panel(cx), ToggleRightPanel => self.toggle_right_panel(cx), @@ -6695,12 +6764,13 @@ impl Tty7App { self.terminal_scrollback_limit = config.scrollback_limit; self.apply_terminal_config_to_panes(&config, cx); } - let (font_size, line_height, font_family, font_features) = { + let (font_size, line_height, font_family, font_fallbacks, font_features) = { let cfg = cx.global::(); ( cfg.font_size, cfg.line_height, cfg.font_family.clone(), + cfg.font_fallbacks.clone(), cfg.font_features .as_ref() .map(crate::core::config::gpui_font_features), @@ -6743,6 +6813,21 @@ impl Tty7App { } } } + // A `font_fallbacks` edit on its own reached no live pane at all: the + // chain is built once per view and from then on only cloned around. + // `set_font_family` rereads it too, so an edit that moves both ends up + // building the same chain twice rather than disagreeing about it. + if font_fallbacks != self.font_fallbacks { + self.font_fallbacks = font_fallbacks; + for tab in &self.tabs { + for leaf in tab.pane.terminals() { + leaf.update(cx, |v, cx| { + v.reread_fallback_chain(cx); + cx.notify(); + }); + } + } + } if font_features != self.font_features { self.font_features = font_features.clone(); for tab in &self.tabs { @@ -7412,7 +7497,7 @@ impl Tty7App { cx.notify(); } else { self.stop_recording(cx); - self.reset_keybinding(action, cx); + self.unbind_keybinding(action, cx); } return; } @@ -7472,16 +7557,32 @@ impl Tty7App { // only `same_chord` sees it. Compared as text, the displacement never // fires and both bindings survive onto that keystroke, where which one // wins is arbitrary (#750). - let displaced = crate::ui::keymap::effective_bindings(cx) + // + // Only that chord moves: the action that had it keeps any others it + // has, since an action can carry several (#868) and emptying it would + // take away keys that were never on this keystroke. An extra default — + // Alt+Enter beside Shift+Enter — follows its action's first chord rather + // than being one of its own, so its owner is unbound outright, as it + // always was. + use crate::ui::keymap::same_chord; + let displaced: Option<(String, Vec)> = crate::ui::keymap::effective_chords(cx) .into_iter() - .chain(crate::ui::keymap::extra_bindings(cx)) - .find(|(a, k)| *a != action && crate::ui::keymap::same_chord(k, &spec)) - .map(|(a, _)| a); + .find(|(a, chords)| *a != action && chords.iter().any(|k| same_chord(k, &spec))) + .map(|(a, chords)| { + let rest = chords.into_iter().filter(|k| !same_chord(k, &spec)); + (a, rest.collect()) + }) + .or_else(|| { + crate::ui::keymap::extra_bindings(cx) + .into_iter() + .find(|(a, k)| *a != action && same_chord(k, &spec)) + .map(|(a, _)| (a, Vec::new())) + }); // A trailing "…" on an action name marks a command that opens // something; it is not punctuation, and inside a sentence it reads as // the sentence trailing off — "Rename Tab… took the shortcut from". let in_prose = |name: &str| name.trim_end_matches('…').to_string(); - let note = displaced.as_ref().map(|other| { + let note = displaced.as_ref().map(|(other, _)| { t_fmt( L10nKey::AppKeybindingDisplacedNote, &[ @@ -7496,11 +7597,17 @@ impl Tty7App { ], ) }); + // Both written as lists. Recording a shortcut sets it — the row showed + // one chord and now shows another — and a bare string in config adds a + // chord beside the default instead (#868). self.update_config(cx, |cfg| { - if let Some(other) = &displaced { - cfg.keybindings.insert(other.clone(), String::new()); + use crate::core::config::KeybindingOverride; + if let Some((other, rest)) = &displaced { + cfg.keybindings + .insert(other.clone(), KeybindingOverride::Exact(rest.clone())); } - cfg.keybindings.insert(action, spec); + cfg.keybindings + .insert(action, KeybindingOverride::Exact(vec![spec])); }); crate::ui::keymap::rebind(cx); if let Some(s) = self.active_settings_mut() { @@ -7509,6 +7616,34 @@ impl Tty7App { cx.notify(); } + /// Takes every chord off an action, and keeps it off. + /// + /// ⌫ on a row that has recorded nothing used to *reset* it — drop the + /// override so the action gets its shipped chord back. On a row nobody has + /// overridden, which is every row the first time it is looked at, that is a + /// no-op: someone pressing Backspace over Alt+1 to be rid of it watched + /// Alt+1 sit exactly where it was and read it as the default restoring + /// itself (#901). Nothing anywhere in the app said "this action should have + /// no key", though `config.json` has spelled it `[]` since #868. + /// + /// So ⌫ writes that empty list, and the **Reset** button beside the row — + /// which appears the moment an action is overridden, this way included — is + /// the way back to the default. + pub(crate) fn unbind_keybinding(&mut self, action: String, cx: &mut Context) { + self.update_config(cx, |cfg| { + cfg.keybindings.insert( + action, + crate::core::config::KeybindingOverride::Exact(Vec::new()), + ); + }); + crate::ui::keymap::rebind(cx); + if let Some(s) = self.active_settings_mut() { + s.recording = None; + s.rebinding_note = None; + } + cx.notify(); + } + pub(crate) fn reset_keybinding(&mut self, action: String, cx: &mut Context) { self.update_config(cx, |cfg| { cfg.keybindings.remove(&action); @@ -7972,11 +8107,41 @@ impl Render for Tty7App { } }; - let title_bar = TitleBar::new() - .h(px(TITLE_BAR_HEIGHT)) - .bg(cx.theme().transparent) - .border_color(cx.theme().transparent) - .child(strip); + // No window buttons in fullscreen, where they cannot work: the window + // has no caption for the platform to hit-test, so they would draw, + // light up under the pointer and do nothing when clicked. `TitleBar` + // always draws them, so the strip goes into a plain row of the same + // geometry instead — the row itself stays, since it holds the tabs, + // the chrome tiles and the docked document's header. + let title_bar = + if window_controls_drawn(window.is_fullscreen()) || cfg!(target_os = "macos") { + TitleBar::new() + .h(px(TITLE_BAR_HEIGHT)) + .bg(cx.theme().transparent) + .border_color(cx.theme().transparent) + .child(strip) + .into_any_element() + } else { + div() + .flex_shrink_0() + .flex() + .flex_row() + .items_center() + .h(px(TITLE_BAR_HEIGHT)) + .pl(px(TITLE_BAR_LEAD)) + .border_b_1() + .border_color(cx.theme().transparent) + .child( + div() + .flex() + .flex_row() + .items_center() + .h_full() + .flex_1() + .child(strip), + ) + .into_any_element() + }; let body_area = div() .flex_1() .relative() @@ -8178,7 +8343,9 @@ impl Render for Tty7App { .right(px(panel_px)) .w(px(document_px)) .when(panel_px <= 0., |d| { - d.pr(px(crate::ui::tab_strip::trailing_chrome_w())) + d.pr(px(crate::ui::tab_strip::trailing_chrome_w( + window.is_fullscreen(), + ))) }) .child(header), ) @@ -8375,9 +8542,9 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &ToggleMaximizePane, window, cx| { this.toggle_maximize(window, cx) })) - .on_action( - cx.listener(|_, _: &ToggleFullscreen, window, _cx| window.toggle_fullscreen()), - ) + .on_action(cx.listener(|this, _: &ToggleFullscreen, window, cx| { + this.toggle_fullscreen(window, cx) + })) .on_action(cx.listener(|this, _: &ToggleTabSidebar, _window, cx| { this.toggle_tab_sidebar(cx) })) @@ -9764,6 +9931,24 @@ mod tests { assert_eq!(band.size.height, px(TITLE_BAR_HEIGHT)); } + /// Fullscreen takes the window buttons and nothing else: the strip keeps + /// its row, and the room reserved for the buttons at its end comes back. + /// macOS never had any to take. + #[test] + fn fullscreen_drops_the_window_buttons_but_not_their_row() { + assert!(!super::window_controls_drawn(true)); + assert_eq!(super::window_controls_w(true), 0.); + assert_eq!( + super::window_controls_drawn(false), + !cfg!(target_os = "macos") + ); + assert_eq!(super::window_controls_w(false), super::WINDOW_CONTROLS_W); + assert_eq!( + crate::ui::tab_strip::trailing_chrome_w(true), + crate::ui::tab_strip::trailing_chrome_tiles_w() + ); + } + /// A surface narrower than its own shadow is only reachable mid-resize, but /// a negative width would make `Bounds::contains` answer for a rectangle /// that is inside out. @@ -10639,12 +10824,17 @@ mod keybinding_gpui_tests { }); } - fn wait_for_binding(vcx: &mut VisualTestContext, action: &str, expected: &str) { + /// Waits for `action`'s entry in config to read `expected` — compared as the + /// JSON the file gets, since that is what the reader of `config.json` sees. + fn wait_for_binding(vcx: &mut VisualTestContext, action: &str, expected: serde_json::Value) { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); loop { vcx.background_executor.run_until_parked(); - let got = vcx.update(|_, cx| cx.global::().keybindings.get(action).cloned()); - if got.as_deref() == Some(expected) { + let got = vcx.update(|_, cx| { + serde_json::to_value(cx.global::().keybindings.get(action)) + .expect("a binding serializes") + }); + if got == expected { return; } assert!( @@ -10660,7 +10850,11 @@ mod keybinding_gpui_tests { let (app, mut vcx) = harness(cx); begin_capture(&app, &mut vcx, "NewTab"); vcx.simulate_keystrokes("secondary-shift-n"); - wait_for_binding(&mut vcx, "NewTab", "secondary-shift-n"); + // A list, because recording a shortcut on the Settings page *sets* it: + // the row showed one chord and now shows another. A bare string in + // config adds a chord beside the default (#868), which is not what + // the person at the row just did. + wait_for_binding(&mut vcx, "NewTab", serde_json::json!(["secondary-shift-n"])); let recording = app.update_in(&mut vcx, |app, _, _| { app.active_settings().map(|s| s.recording.is_some()) @@ -10678,7 +10872,30 @@ mod keybinding_gpui_tests { begin_capture(&app, &mut vcx, "CloseActiveTab"); vcx.simulate_keystrokes("secondary-b"); vcx.simulate_keystrokes("x"); - wait_for_binding(&mut vcx, "CloseActiveTab", "secondary-b x"); + wait_for_binding( + &mut vcx, + "CloseActiveTab", + serde_json::json!(["secondary-b x"]), + ); + } + + #[gpui::test] + fn recording_a_chord_another_action_also_has_takes_only_that_chord(cx: &mut TestAppContext) { + let (app, mut vcx) = harness(cx); + vcx.update(|_, cx| { + cx.global_mut::().keybindings = serde_json::from_value(serde_json::json!({ + "NextTab": ["ctrl-tab", "secondary-alt-n"], + })) + .expect("the binding loads"); + crate::ui::keymap::rebind(cx); + }); + begin_capture(&app, &mut vcx, "NewTab"); + vcx.simulate_keystrokes("secondary-alt-n"); + wait_for_binding(&mut vcx, "NewTab", serde_json::json!(["secondary-alt-n"])); + // Emptying the other action was right when an action had one chord. + // With two, it would take Ctrl+Tab away as well, for a keystroke that + // was never on it. + wait_for_binding(&mut vcx, "NextTab", serde_json::json!(["ctrl-tab"])); } #[gpui::test] @@ -10686,8 +10903,8 @@ mod keybinding_gpui_tests { let (app, mut vcx) = harness(cx); begin_capture(&app, &mut vcx, "NewTab"); vcx.simulate_keystrokes("alt-enter"); - wait_for_binding(&mut vcx, "NewTab", "alt-enter"); - wait_for_binding(&mut vcx, "InsertNewline", ""); + wait_for_binding(&mut vcx, "NewTab", serde_json::json!(["alt-enter"])); + wait_for_binding(&mut vcx, "InsertNewline", serde_json::json!([])); let note = app.update_in(&mut vcx, |app, _, _| { app.active_settings().and_then(|s| s.rebinding_note.clone()) @@ -10699,6 +10916,56 @@ mod keybinding_gpui_tests { ); } + /// #901, the half that happens in the UI: Alt+1…9 belongs to vim, and the + /// only gesture in the app that looks like "take this shortcut away" used + /// to *reset* the row instead — a no-op on a row nobody had overridden, + /// so the default appeared to restore itself however many times it was + /// pressed. + #[gpui::test] + fn backspace_on_a_row_unbinds_the_action_rather_than_restoring_its_default( + cx: &mut TestAppContext, + ) { + let (app, mut vcx) = harness(cx); + let shipped = vcx + .update(|_, cx| crate::ui::keymap::effective_key("ActivateTab1", cx)) + .expect("Go to Tab 1 ships with a chord"); + + begin_capture(&app, &mut vcx, "ActivateTab1"); + vcx.simulate_keystrokes("backspace"); + wait_for_binding(&mut vcx, "ActivateTab1", serde_json::json!([])); + + vcx.update(|_, cx| { + assert_eq!( + crate::ui::keymap::effective_key("ActivateTab1", cx), + None, + "the row has no chord left to show" + ); + let typed = [gpui::Keystroke::parse(&shipped).expect("the chord parses")]; + let context = [gpui::KeyContext::parse("Terminal").expect("the context parses")]; + assert!( + cx.key_bindings() + .borrow() + .bindings_for_input(&typed, &context) + .0 + .is_empty(), + "{shipped} must reach the terminal now, not the tab switcher" + ); + }); + + // Reversible, and by the button that is already on the row: an + // overridden action — unbound counts — shows **Reset**. + app.update_in(&mut vcx, |app, _, cx| { + app.reset_keybinding("ActivateTab1".to_string(), cx) + }); + vcx.update(|_, cx| { + assert_eq!( + crate::ui::keymap::effective_key("ActivateTab1", cx).as_deref(), + Some(shipped.as_str()), + "Reset is the way back to the shipped chord" + ); + }); + } + #[gpui::test] fn escape_cancels_capture_without_writing(cx: &mut TestAppContext) { let (app, mut vcx) = harness(cx); diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 8b26dc62..b6930ed3 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -62,6 +62,8 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/agents/omp.svg" => include_bytes!("../../assets/icons/agents/omp.svg"), "icons/agents/qwen.svg" => include_bytes!("../../assets/icons/agents/qwen.svg"), "icons/agents/kimi.svg" => include_bytes!("../../assets/icons/agents/kimi.svg"), + "icons/agents/qodercli.svg" => include_bytes!("../../assets/icons/agents/qodercli.svg"), + "icons/agents/crush.svg" => include_bytes!("../../assets/icons/agents/crush.svg"), _ => return None, }; Some(bytes) diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index bb01e4e9..ad6cb041 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -6,12 +6,12 @@ use std::sync::Arc; use crate::core::config::RightPanelTab; use crate::core::git::status::{DecoStatus, DirRollup, StatusIndex}; use crate::terminal::git_data::index_of; -use crate::ui::app::Tty7App; +use crate::ui::app::{CONTENT_INSET, Tty7App}; use crate::ui::file_copy; use crate::ui::host_ops::{ByHost, HostId, HostOps, InFlight, SharedHost, WatchSub}; use crate::ui::host_registry::HostRegistry; use crate::ui::i18n::{L10nKey, t, t_fmt}; -use crate::ui::right_panel::{ROW_GLYPH, git_badge}; +use crate::ui::right_panel::{ROW_GLYPH, ROW_INSET, git_badge}; use crate::ui::scm::status::{status_color, status_glyph}; use gpui::prelude::*; use gpui::{ @@ -24,6 +24,18 @@ use gpui_component::{ ActiveTheme as _, Icon, IconName, Sizable as _, WindowExt as _, h_flex, v_flex, }; +// The tree is laid out the way every other list in this panel is: the column +// sits a `ROW_INSET` short of `CONTENT_INSET` and each row pads itself back +// out, so a depth-0 name lands on the panel's 12px rail while the row's hover +// and selection fill bleeds past it to 8. Depth is added on top of that inset, +// so `INDENT` is the step between levels and nothing else. +// +// It used to run its own pair of numbers instead — a `px_1()` column and a 6px +// row — which put the tree's names 2px left of the search field directly above +// them and let a selected row's fill reach twice as close to the panel edge as +// an Info or Source Control row's. Two adjacent left edges that disagree by +// 2px is the one misalignment a reader can actually catch, because the search +// glyph sits right there to compare against. const INDENT: f32 = 14.0; const REFRESH_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200); @@ -1579,7 +1591,7 @@ impl Tty7App { .min_h_0() .overflow_y_scroll() .track_scroll(&self.right_panel.tree_scroll) - .px_1() + .px(px(CONTENT_INSET - ROW_INSET)) .pb_1() .track_focus(&self.file_tree.focus_handle) .on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| { @@ -1718,9 +1730,9 @@ impl Tty7App { return vec![ h_flex() // Aligned with the label column of a real row at this - // depth: 6 for the row's own inset, INDENT for the depth, - // then the width of the icon and its gap. - .pl(px(6.0 + row.depth as f32 * INDENT + 20.0)) + // depth: ROW_INSET for the row's own inset, INDENT for the + // depth, then the width of the icon and its gap. + .pl(px(ROW_INSET + row.depth as f32 * INDENT + 20.0)) .py_1() .items_center() .text_xs() @@ -1815,8 +1827,8 @@ impl Tty7App { .id(SharedString::from(format!("tree-{}", path.display()))) .items_center() .gap_1() - .pl(px(6.0 + row.depth as f32 * INDENT)) - .pr_1() + .pl(px(ROW_INSET + row.depth as f32 * INDENT)) + .pr(px(ROW_INSET)) .py_1() .rounded(cx.theme().radius) .cursor_pointer() @@ -1917,8 +1929,8 @@ impl Tty7App { h_flex() .items_center() .gap_1() - .pl(px(6.0 + (row.depth + 1) as f32 * INDENT)) - .pr_1() + .pl(px(ROW_INSET + (row.depth + 1) as f32 * INDENT)) + .pr(px(ROW_INSET)) .py_0p5() .child(Input::new(&input).xsmall()) .into_any_element(), diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 6d446525..4a211a47 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -279,13 +279,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::SettingsKeepEditing => "Keep Editing", L10nKey::SettingsName => "Name", - L10nKey::SettingsNameDesc => "A label for this connection.", L10nKey::SettingsHost => "Host", - L10nKey::SettingsHostDesc => "Hostname or IP address.", L10nKey::SettingsHostRequired => "Needs a host — won't be saved.", L10nKey::SettingsPortInvalid => "Port must be 1-65535 — blank means 22.", L10nKey::SettingsUser => "User", - L10nKey::SettingsUserDesc => "Login user (blank = resolve at connect).", L10nKey::SettingsAuth => "Auth", L10nKey::SettingsAuthDesc => "Authentication method. Auto tries every applicable method.", L10nKey::SettingsAuthModeAuto => "Auto", @@ -293,6 +290,24 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAuthModeKey => "Key", L10nKey::SettingsAuthModeAgent => "Agent", L10nKey::SettingsAuthMode2Fa => "2FA", + L10nKey::SettingsPassword => "Password", + L10nKey::SettingsNameHint => "Optional label", + L10nKey::SettingsHostHint => "hostname or IP", + L10nKey::SettingsUserHint => "resolved at connect", + L10nKey::SettingsPasswordDesc => "Kept in the system keychain, never in the config file.", + L10nKey::SettingsPasswordHint => "Ask when connecting", + L10nKey::SettingsKeyPassphrase => "Key passphrase", + L10nKey::SettingsKeyPassphraseDesc => "Unlocks the key above. Kept in the system keychain.", + L10nKey::SettingsPassphraseNeedsKey => { + "Name a key file first — a passphrase is stored against the key it unlocks." + } + L10nKey::SettingsBrowseKey => "Browse…", + L10nKey::SettingsCouldntSavePassword => { + "Could not save the password for {endpoint}: {error}" + } + L10nKey::SettingsCouldntSavePassphrase => { + "Could not save the passphrase for {key}: {error}" + } L10nKey::SettingsJumpHost => "Jump host", L10nKey::SettingsJumpHostDesc => { "Name of another profile to tunnel through (blank = direct)." @@ -631,10 +646,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { "tmux remaps pane/tab actions onto prefix sequences (e.g. Ctrl-B then C)." } L10nKey::SettingsPrefix => "Prefix", - L10nKey::SettingsPressKeys => "Press keys…", + L10nKey::SettingsPressKeys => "Press keys… · ⌫ for no shortcut", L10nKey::SettingsPauseToSaveEsc => "pause to save · Esc", L10nKey::SettingsKeybindingsIntroDesc => { - "Click a shortcut, then press the new keys — it saves after a brief pause. Chain keys for a sequence like Ctrl-B then X. Esc cancels; Backspace removes the last key, or resets to default if pressed first." + "Click a shortcut, then press the new keys — it saves after a brief pause. Chain keys for a sequence like Ctrl-B then X. Esc cancels; Backspace removes the last key, or, pressed first, leaves the action with no shortcut — Reset brings the default back." } L10nKey::SettingsPrefixNote => { "With a prefix active, a bare prefix key reaches the shell after a ~1s pause, and prefix + an unbound key is sent through to the terminal." @@ -778,6 +793,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAgentQwenCode => "Qwen Code", L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsAgentKimiCode => "Kimi Code", + L10nKey::SettingsAgentQoderCLI => "Qoder CLI", + L10nKey::SettingsAgentCrush => "Crush", L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github", L10nKey::SettingsSearchAppHttpProxyKeywords => { "proxy http https socks socks5 clash v2ray network download update" @@ -873,6 +890,8 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsSearchKimiCodeKeywords => { "agent integration hooks install kimi code kimi-code moonshot" } + L10nKey::SettingsSearchQoderCLIKeywords => "agent integration hooks install qoder qodercli", + L10nKey::SettingsSearchCrushKeywords => "agent integration hooks install crush", L10nKey::SettingsSearchPiKeywords => "agent integration extension install pi", L10nKey::SettingsSearchPortForwardingKeywords => { "ssh tunnel local remote dynamic socks forward rule" @@ -1088,6 +1107,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::PanelPortsRestricted => { "Something here runs as another user, whose ports aren't visible." } + L10nKey::PanelLatency => "latency", L10nKey::PortAutoForwarded => "Remote :{port} is now http://localhost:{local}", L10nKey::PanelCwd => "cwd", L10nKey::PanelShell => "shell", @@ -1595,6 +1615,10 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::AppReopenTabFailed => "Could not reopen the tab: no terminal started", L10nKey::AppOpenTerminalFailed => "Could not open a terminal: {error}", L10nKey::AppTabsNotRestored => "{count} tabs from last time could not be reopened", + L10nKey::AppFullscreenEntered => "Fullscreen — press {key} to leave", + L10nKey::AppFullscreenEnteredNoKey => { + "Fullscreen — the window buttons are hidden until you leave" + } L10nKey::LaunchWorkspacesLeftRunning => { "Only this window was restored — {count} workspaces are still running in the background. Reopen them from the sidebar." } @@ -1763,7 +1787,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::AppMenuCopyWorkingDirectory => "Copy Working Directory", L10nKey::AppMenuCopySessionId => "Copy Session ID", L10nKey::AppMenuForkSession => "Fork Session", - L10nKey::AppMenuClosePaneTab => "Close Pane / Tab", + L10nKey::AppMenuClosePaneTab => "Close", L10nKey::AppMenuCloseOtherTabs => "Close Other Tabs", L10nKey::AppMenuCloseTabsRight => "Close Tabs to the Right", L10nKey::AppMenuReopenClosedTab => "Reopen Closed Tab", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 52a56077..e93afd4e 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -279,13 +279,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsDiscardChangesBody => "編集中の接続に、まだ保存していない変更があります。", L10nKey::SettingsKeepEditing => "編集を続ける", L10nKey::SettingsName => "名前", - L10nKey::SettingsNameDesc => "この接続の表示名", L10nKey::SettingsHost => "ホスト名", - L10nKey::SettingsHostDesc => "ホスト名または IP アドレス", L10nKey::SettingsHostRequired => "ホスト名が必要です — 保存されません", L10nKey::SettingsPortInvalid => "ポートは 1-65535 の範囲です — 空欄なら 22 です", L10nKey::SettingsUser => "ユーザー名", - L10nKey::SettingsUserDesc => "ログインユーザー (空欄 = 接続時に解決)", L10nKey::SettingsAuth => "認証方式", L10nKey::SettingsAuthDesc => "認証方式。自動の場合は適用可能なすべての方式を試します", L10nKey::SettingsAuthModeAuto => "自動", @@ -293,6 +290,24 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAuthModeKey => "公開鍵", L10nKey::SettingsAuthModeAgent => "SSH エージェント", L10nKey::SettingsAuthMode2Fa => "二要素認証 (2FA)", + L10nKey::SettingsPassword => "パスワード", + L10nKey::SettingsNameHint => "任意のラベル", + L10nKey::SettingsHostHint => "ホスト名または IP", + L10nKey::SettingsUserHint => "接続時に解決", + L10nKey::SettingsPasswordDesc => { + "システムのキーチェーンに保存され、設定ファイルには書き込まれません。" + } + L10nKey::SettingsPasswordHint => "接続時に入力する", + L10nKey::SettingsKeyPassphrase => "鍵のパスフレーズ", + L10nKey::SettingsKeyPassphraseDesc => { + "上の鍵を解錠します。システムのキーチェーンに保存されます。" + } + L10nKey::SettingsPassphraseNeedsKey => { + "先に鍵ファイルを指定してください。パスフレーズは解錠する鍵ごとに保存されます。" + } + L10nKey::SettingsBrowseKey => "参照…", + L10nKey::SettingsCouldntSavePassword => "{endpoint} のパスワードを保存できません: {error}", + L10nKey::SettingsCouldntSavePassphrase => "{key} のパスフレーズを保存できません: {error}", L10nKey::SettingsJumpHost => "ジャンプホスト", L10nKey::SettingsJumpHostDesc => { "トンネリングに使用する別のプロファイル名 (空欄 = 直接接続)" @@ -639,10 +654,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { "tmux では、ペイン/タブの操作をプレフィックスキーの後に行います(例: Ctrl-B の後に C)" } L10nKey::SettingsPrefix => "プレフィックスキー", - L10nKey::SettingsPressKeys => "キーを入力…", + L10nKey::SettingsPressKeys => "キーを入力… · ⌫ でショートカットなし", L10nKey::SettingsPauseToSaveEsc => "一時停止して保存 · Esc", L10nKey::SettingsKeybindingsIntroDesc => { - "ショートカットをクリックして新しいキーを押すと、少し間を置いて保存されます。Ctrl-B の後に X のようなシーケンスはキーを続けて入力。Esc でキャンセル、Backspace は最後のキーを削除し、最初に押すとデフォルトに戻します" + "ショートカットをクリックして新しいキーを押すと、少し間を置いて保存されます。Ctrl-B の後に X のようなシーケンスはキーを続けて入力。Esc でキャンセル、Backspace は最後のキーを削除し、最初に押すとショートカットなしになり、「リセット」でデフォルトに戻せます" } L10nKey::SettingsPrefixNote => { "プレフィックスが有効な場合、プレフィックスキーを単独で押すと約 1 秒後にシェルに渡され、プレフィックス + 未割り当てのキーはターミナルへそのまま送信されます" @@ -788,6 +803,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentQwenCode => "Qwen Code", L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsAgentKimiCode => "Kimi Code", + L10nKey::SettingsAgentQoderCLI => "Qoder CLI", + L10nKey::SettingsAgentCrush => "Crush", L10nKey::SettingsSearchAboutKeywords => { "バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check" } @@ -925,6 +942,12 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchKimiCodeKeywords => { "エージェント 統合 フック インストール kimi code moonshot agent integration hooks install" } + L10nKey::SettingsSearchQoderCLIKeywords => { + "エージェント 統合 フック インストール qoder qodercli agent integration hooks install" + } + L10nKey::SettingsSearchCrushKeywords => { + "エージェント 統合 フック インストール crush agent integration hooks install" + } L10nKey::SettingsSearchPiKeywords => { "エージェント 統合 拡張 インストール pi agent integration extension install" } @@ -1148,6 +1171,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::PanelSessionSubtitle => "セッション", L10nKey::PanelProcessesSubtitle => "プロセス", L10nKey::PanelPortsSubtitle => "ポート", + L10nKey::PanelLatency => "遅延", L10nKey::PanelPortsUnsupported => "リモートの tty7-server が古く、ポートを列挙できません。", L10nKey::PanelPortsProbeFailed => { "このペインが何をリッスンしているか確認できませんでした。" @@ -1655,6 +1679,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::AppReopenTabFailed => "タブを開き直せませんでした: ターミナルが起動しませんでした", L10nKey::AppOpenTerminalFailed => "ターミナルを開けませんでした: {error}", L10nKey::AppTabsNotRestored => "前回のタブ {count} 個を開き直せませんでした", + L10nKey::AppFullscreenEntered => "全画面表示 — 解除するには {key}", + L10nKey::AppFullscreenEnteredNoKey => { + "全画面表示 — 解除するまでウィンドウボタンは非表示です" + } L10nKey::LaunchWorkspacesLeftRunning => { "このウィンドウだけを復元しました — あと {count} 個のワークスペースがバックグラウンドで実行中です。サイドバーから開き直せます。" } @@ -1835,7 +1863,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuCopyWorkingDirectory => "作業ディレクトリをコピー", L10nKey::AppMenuCopySessionId => "セッション ID をコピー", L10nKey::AppMenuForkSession => "セッションをフォーク", - L10nKey::AppMenuClosePaneTab => "ペイン / タブを閉じる", + L10nKey::AppMenuClosePaneTab => "閉じる", L10nKey::AppMenuCloseOtherTabs => "他のタブを閉じる", L10nKey::AppMenuCloseTabsRight => "右側のタブを閉じる", L10nKey::AppMenuReopenClosedTab => "閉じたタブをもう一度開く", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 3b08c2bd..1b540a57 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -273,13 +273,10 @@ l10n_keys! { SettingsDiscardChangesBody, SettingsKeepEditing, SettingsName, - SettingsNameDesc, SettingsHost, - SettingsHostDesc, SettingsHostRequired, SettingsPortInvalid, SettingsUser, - SettingsUserDesc, SettingsAuth, SettingsAuthDesc, SettingsAuthModeAuto, @@ -287,6 +284,18 @@ l10n_keys! { SettingsAuthModeKey, SettingsAuthModeAgent, SettingsAuthMode2Fa, + SettingsNameHint, + SettingsHostHint, + SettingsUserHint, + SettingsPassword, + SettingsPasswordDesc, + SettingsPasswordHint, + SettingsKeyPassphrase, + SettingsKeyPassphraseDesc, + SettingsPassphraseNeedsKey, + SettingsBrowseKey, + SettingsCouldntSavePassword, + SettingsCouldntSavePassphrase, SettingsJumpHost, SettingsJumpHostDesc, SettingsJumpHostUnknown, @@ -603,6 +612,8 @@ l10n_keys! { SettingsAgentQwenCode, SettingsAgentGoose, SettingsAgentKimiCode, + SettingsAgentQoderCLI, + SettingsAgentCrush, SettingsSearchAppHttpProxyKeywords, SettingsSearchAboutKeywords, SettingsSearchAutoDownloadKeywords, @@ -657,6 +668,8 @@ l10n_keys! { SettingsSearchPortForwardingKeywords, SettingsSearchProgramKeywords, SettingsSearchQwenCodeKeywords, + SettingsSearchQoderCLIKeywords, + SettingsSearchCrushKeywords, SettingsSearchRememberWindowSizeKeywords, SettingsSearchReportMouseToAppsKeywords, SettingsSearchRestoreLastLayoutKeywords, @@ -805,6 +818,7 @@ l10n_keys! { PanelPortsUnsupported, PanelPortsProbeFailed, PanelPortsRestricted, + PanelLatency, PortAutoForwarded, PanelCwd, PanelShell, @@ -1293,6 +1307,8 @@ l10n_keys! { AppReopenTabFailed, AppOpenTerminalFailed, AppTabsNotRestored, + AppFullscreenEntered, + AppFullscreenEnteredNoKey, LaunchWorkspacesLeftRunning, AppSshConnectionFailed, AppSshReconnectFailed, @@ -1582,6 +1598,8 @@ mod tests { L10nKey::SettingsAgentOpencode, L10nKey::SettingsAgentPi, L10nKey::SettingsAgentQwenCode, + L10nKey::SettingsAgentQoderCLI, + L10nKey::SettingsAgentCrush, // Windows names its backdrop materials, and Japanese Windows keeps // those names in Latin script — so does this list. Chinese does // translate them (云母 / 亚克力), which is what Microsoft's own diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 37e40fa3..b3f8c6de 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -251,13 +251,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsDiscardChangesBody => "当前连接有未保存的更改。", L10nKey::SettingsKeepEditing => "继续编辑", L10nKey::SettingsName => "名称", - L10nKey::SettingsNameDesc => "此连接的标签。", L10nKey::SettingsHost => "主机", - L10nKey::SettingsHostDesc => "主机名或 IP 地址。", L10nKey::SettingsHostRequired => "需要填写主机——不会被保存。", L10nKey::SettingsPortInvalid => "端口必须在 1-65535 之间——留空表示 22。", L10nKey::SettingsUser => "用户", - L10nKey::SettingsUserDesc => "登录用户(留空表示连接时解析)。", L10nKey::SettingsAuth => "认证", L10nKey::SettingsAuthDesc => "认证方式。自动会依次尝试所有适用的方式。", L10nKey::SettingsAuthModeAuto => "自动", @@ -265,6 +262,18 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAuthModeKey => "密钥", L10nKey::SettingsAuthModeAgent => "ssh-agent", L10nKey::SettingsAuthMode2Fa => "2FA", + L10nKey::SettingsPassword => "密码", + L10nKey::SettingsNameHint => "可不填", + L10nKey::SettingsHostHint => "主机名或 IP", + L10nKey::SettingsUserHint => "连接时再定", + L10nKey::SettingsPasswordDesc => "存在系统钥匙串里,不会写进配置文件。", + L10nKey::SettingsPasswordHint => "连接时再问", + L10nKey::SettingsKeyPassphrase => "密钥口令", + L10nKey::SettingsKeyPassphraseDesc => "用来解锁上面那个密钥,存在系统钥匙串里。", + L10nKey::SettingsPassphraseNeedsKey => "先填一个密钥文件——口令是跟着它解锁的那个密钥存的。", + L10nKey::SettingsBrowseKey => "浏览…", + L10nKey::SettingsCouldntSavePassword => "无法保存 {endpoint} 的密码:{error}", + L10nKey::SettingsCouldntSavePassphrase => "无法保存 {key} 的口令:{error}", L10nKey::SettingsJumpHost => "跳板主机", L10nKey::SettingsJumpHostDesc => "用于中转的另一个主机配置的名称(留空 = 直连)。", L10nKey::SettingsJumpHostUnknown => "没有名为 {jump_name} 的主机配置——不会被保存。", @@ -562,10 +571,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { "tmux 预设把窗格/标签页操作映射为前缀序列(例如 Ctrl-B 后按 C)。" } L10nKey::SettingsPrefix => "前缀", - L10nKey::SettingsPressKeys => "按下按键…", + L10nKey::SettingsPressKeys => "按下按键… · ⌫ 表示不设快捷键", L10nKey::SettingsPauseToSaveEsc => "暂停以保存 · Esc", L10nKey::SettingsKeybindingsIntroDesc => { - "点击某个快捷键,再按下新按键,短暂停顿后保存。连续按键可组成序列,例如 Ctrl-B 后按 X。Esc 取消;Backspace 移除最后一个按键,最先按下则重置为默认。" + "点击某个快捷键,再按下新按键,短暂停顿后保存。连续按键可组成序列,例如 Ctrl-B 后按 X。Esc 取消;Backspace 移除最后一个按键,最先按下则不设快捷键,点「重置」可恢复默认。" } L10nKey::SettingsPrefixNote => { "启用前缀后,单独按前缀键约 1 秒后会传给 shell,前缀 + 未绑定的按键会直接发送到终端。" @@ -691,6 +700,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentQwenCode => "Qwen Code", L10nKey::SettingsAgentGoose => "Goose", L10nKey::SettingsAgentKimiCode => "Kimi Code", + L10nKey::SettingsAgentQoderCLI => "Qoder CLI", + L10nKey::SettingsAgentCrush => "Crush", L10nKey::SettingsSearchAboutKeywords => { "关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update" } @@ -826,6 +837,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchKimiCodeKeywords => { "Kimi Code 月之暗面 agent 集成 钩子 安装 kimi code moonshot agent integration hooks install" } + L10nKey::SettingsSearchQoderCLIKeywords => "Qoder CLI agent 集成 钩子 安装 qoder qodercli", + L10nKey::SettingsSearchCrushKeywords => "Crush agent 集成 钩子 安装 crush", L10nKey::SettingsSearchPiKeywords => { "Pi agent 集成 扩展 安装 pi agent integration extension install" } @@ -1035,6 +1048,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::PanelSessionSubtitle => "会话", L10nKey::PanelProcessesSubtitle => "进程", L10nKey::PanelPortsSubtitle => "端口", + L10nKey::PanelLatency => "延迟", L10nKey::PanelPortsUnsupported => "对端的 tty7-server 太旧,列不出端口。", L10nKey::PanelPortsProbeFailed => "没能查出这个窗格在监听什么。", L10nKey::PanelPortsRestricted => "这里有以其他用户身份运行的进程,看不到它们的端口。", @@ -1508,6 +1522,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppReopenTabFailed => "无法重新打开标签页:没有启动终端", L10nKey::AppOpenTerminalFailed => "无法打开终端:{error}", L10nKey::AppTabsNotRestored => "上次的 {count} 个标签页没能重新打开", + L10nKey::AppFullscreenEntered => "已进入全屏 —— 按 {key} 退出", + L10nKey::AppFullscreenEnteredNoKey => "已进入全屏 —— 窗口按钮在退出前会一直隐藏", L10nKey::LaunchWorkspacesLeftRunning => { "只恢复了这个窗口——还有 {count} 个工作区在后台运行,可从侧边栏重新打开。" } @@ -1670,7 +1686,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::AppMenuCopyWorkingDirectory => "复制工作目录", L10nKey::AppMenuCopySessionId => "复制会话 ID", L10nKey::AppMenuForkSession => "Fork 会话", - L10nKey::AppMenuClosePaneTab => "关闭窗格 / 标签页", + L10nKey::AppMenuClosePaneTab => "关闭", L10nKey::AppMenuCloseOtherTabs => "关闭其他标签页", L10nKey::AppMenuCloseTabsRight => "关闭右侧标签页", L10nKey::AppMenuReopenClosedTab => "重新打开已关闭的标签页", diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 6fb2d808..f7b77e86 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -1,7 +1,7 @@ use gpui::{App, Global, KeyBinding, Keystroke, NoAction}; use crate::core::actions::*; -use crate::core::config::Config; +use crate::core::config::{Config, KeybindingOverride}; use crate::terminal::view::{ AlternatePaste, ClearScrollback, CopyText, FindInTerminal, FindNext, FindPrevious, InsertNewline, InsertNewlineFallback, PasteText, @@ -106,9 +106,9 @@ pub fn rebind(cx: &mut App) { /// own `save()`, which fires on a sidebar drag — so it compares the triple /// before and after and only rebinds when one of these actually moved; /// otherwise each save would rebuild the keymap for nothing (#548). -pub(crate) fn keybinding_config(cx: &App) -> (Vec<(String, String)>, String, String) { +pub(crate) fn keybinding_config(cx: &App) -> (Vec<(String, KeybindingOverride)>, String, String) { let cfg = cx.global::(); - let mut overrides: Vec<(String, String)> = cfg + let mut overrides: Vec<(String, KeybindingOverride)> = cfg .keybindings .iter() .map(|(a, k)| (a.clone(), k.clone())) @@ -881,28 +881,114 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> { }) } -pub(crate) fn effective_bindings(cx: &App) -> Vec<(String, String)> { - let cfg = cx.global::(); - let mut effective: Vec<(String, String)> = default_bindings() - .into_iter() - .map(|(a, k)| (a.to_string(), k.to_string())) - .collect(); - for (action, key) in preset_bindings(&cfg.keybinding_preset, &cfg.prefix) { - set_binding(&mut effective, &action, key); - } - for (action, key) in &cfg.keybindings { - set_binding(&mut effective, action, key.clone()); - } - effective +/// One action and the chords it answers to, split by where they came from: +/// the ones it ships with — its default, or the preset's in its place — and +/// the ones `config.json` names. +/// +/// Kept apart because they install apart. gpui resolves two bindings on one +/// chord to the one added last, and a chord the user asked for by name has to +/// be that one, wherever its action sits in the table. +struct ActionChords { + action: String, + shipped: Vec, + configured: Vec, } -fn set_binding(effective: &mut [(String, String)], action: &str, key: String) { - match effective.iter_mut().find(|(a, _)| a == action) { - Some(slot) => slot.1 = key, - // A hand-edited config.json with a typo used to vanish into this - // branch. The Keybindings page lists every name that works. - None => log::warn!("keybinding for unknown action {action:?} ignored"), +fn resolve_chords( + preset: &str, + prefix: &str, + overrides: &std::collections::HashMap, +) -> Vec { + let mut resolved: Vec = default_bindings() + .into_iter() + .map(|(action, key)| ActionChords { + action: action.to_string(), + shipped: [key] + .into_iter() + .filter(|k| !k.is_empty()) + .map(str::to_string) + .collect(), + configured: Vec::new(), + }) + .collect(); + // A preset is a scheme rather than an addition: its chord takes the + // default's place. + for (action, key) in preset_bindings(preset, prefix) { + if let Some(slot) = resolved.iter_mut().find(|s| s.action == action) { + slot.shipped = vec![key]; + } } + for (action, value) in overrides { + let Some(slot) = resolved.iter_mut().find(|s| s.action == *action) else { + // A hand-edited config.json with a typo used to vanish into this + // branch. The Keybindings page lists every name that works. + log::warn!("keybinding for unknown action {action:?} ignored"); + continue; + }; + match value { + // `""` has always been how a config retires a chord — the docs + // spell `"AlternatePaste": ""`, and Settings wrote it for an action + // whose chord was taken — so it goes on unbinding the action. + KeybindingOverride::Add(key) if key.is_empty() => { + slot.shipped.clear(); + } + // A chord beside the ones the action has, not in place of them + // (#868): `"NextTab": "cmd-shift-]"` is a second way to switch tabs, + // and Ctrl+Tab going dead because of it was the bug. + KeybindingOverride::Add(key) => { + if !slot.shipped.iter().any(|k| same_chord(k, key)) { + slot.configured.push(key.clone()); + } + } + KeybindingOverride::Exact(keys) => { + slot.shipped.clear(); + for key in keys { + if !key.is_empty() && !slot.configured.iter().any(|k| same_chord(k, key)) { + slot.configured.push(key.clone()); + } + } + } + } + } + resolved +} + +/// Every chord as an `(action, chord)` pair, in the order the keymap is built +/// from: all the shipped chords, then all the configured ones, so that a +/// configured chord landing on another action's default is the binding that +/// wins it. An action with no chord at all has no pair. +fn flatten_chords(resolved: &[ActionChords]) -> Vec<(String, String)> { + let pairs = |pick: fn(&ActionChords) -> &Vec| { + resolved + .iter() + .flat_map(move |s| pick(s).iter().map(|k| (s.action.clone(), k.clone()))) + }; + pairs(|s| &s.shipped) + .chain(pairs(|s| &s.configured)) + .collect() +} + +pub(crate) fn effective_bindings(cx: &App) -> Vec<(String, String)> { + let cfg = cx.global::(); + flatten_chords(&resolve_chords( + &cfg.keybinding_preset, + &cfg.prefix, + &cfg.keybindings, + )) +} + +/// Every action in table order with all of its chords, shipped first — an +/// empty list for an action that has none. What a page listing the actions +/// reads; the keymap reads [`effective_bindings`]. +pub(crate) fn effective_chords(cx: &App) -> Vec<(String, Vec)> { + let cfg = cx.global::(); + resolve_chords(&cfg.keybinding_preset, &cfg.prefix, &cfg.keybindings) + .into_iter() + .map(|mut s| { + s.shipped.append(&mut s.configured); + (s.action, s.shipped) + }) + .collect() } fn preset_bindings(preset: &str, prefix: &str) -> Vec<(String, String)> { @@ -1726,14 +1812,16 @@ mod tests { // one line in a `config.json`, so both are asserted against the whole // default table with that line applied — a bare one-entry table would // pass either assertion without the escape hatch working at all. - let mut retired = effective.clone(); - set_binding(&mut retired, "AlternatePaste", String::new()); + let with = |action: &str, value: KeybindingOverride| { + let overrides = std::collections::HashMap::from([(action.to_string(), value)]); + flatten_chords(&resolve_chords("default", "ctrl-b", &overrides)) + }; + let retired = with("AlternatePaste", KeybindingOverride::Add(String::new())); assert!( dispatched(&retired, "ctrl-v", "Terminal").is_empty(), "an emptied AlternatePaste gives Ctrl+V back to the shell" ); - let mut everywhere = effective.clone(); - set_binding(&mut everywhere, "PasteText", "ctrl-v".to_string()); + let everywhere = with("PasteText", KeybindingOverride::Add("ctrl-v".to_string())); for context in ["Terminal", "Terminal alt_screen"] { assert!( dispatched(&everywhere, "ctrl-v", context).contains(&PasteText::name_for_type()), @@ -2200,23 +2288,25 @@ mod gpui_tests { { let cfg = cx.global_mut::(); cfg.keybinding_preset = "tmux".to_string(); - cfg.keybindings - .insert("NewTab".to_string(), "secondary-shift-n".to_string()); + cfg.keybindings.insert( + "NewTab".to_string(), + KeybindingOverride::Add("secondary-shift-n".to_string()), + ); } rebind(cx); let eff = effective_bindings(cx); - let key_of = |action: &str| { + let keys_of = |action: &str| { eff.iter() - .find(|(a, _)| a == action) + .filter(|(a, _)| a == action) .map(|(_, k)| k.clone()) - .unwrap() + .collect::>() }; - assert_eq!(key_of("NewTab"), "secondary-shift-n"); - assert_eq!(key_of("SplitRight"), "ctrl-b %"); + assert_eq!(keys_of("NewTab"), ["ctrl-b c", "secondary-shift-n"]); + assert_eq!(keys_of("SplitRight"), ["ctrl-b %"]); assert_eq!( - key_of("TogglePalette"), - per_platform("secondary-p", "secondary-shift-p") + keys_of("TogglePalette"), + [per_platform("secondary-p", "secondary-shift-p")] ); cx.global_mut::().keybinding_preset = "default".to_string(); @@ -2251,9 +2341,10 @@ mod gpui_tests { assert_eq!(keybinding_config(cx), before); // A real binding edit moves it. - cx.global_mut::() - .keybindings - .insert("RenameTab".to_string(), "ctrl-shift-r".to_string()); + cx.global_mut::().keybindings.insert( + "RenameTab".to_string(), + KeybindingOverride::Add("ctrl-shift-r".to_string()), + ); assert_ne!(keybinding_config(cx), before); // So does the preset, and the prefix. @@ -2266,6 +2357,213 @@ mod gpui_tests { }); } + /// An app whose `config.json` reads `json`, keymap and all. Parsed from + /// text rather than built in Rust, because the file is the surface #868 is + /// about: what a hand-written line means is the thing under test. + fn running_on_json(cx: &mut gpui::App, json: &str) { + gpui_component::init(cx); + cx.set_global(Config( + serde_json::from_str(json).expect("the config parses"), + )); + init(cx); + } + + /// What the live keymap dispatches for `keys` typed in a terminal, best + /// match first — the first entry is the action a real keypress runs. + fn fired(cx: &gpui::App, keys: &str) -> Vec<&'static str> { + let input: Vec = keys + .split(' ') + .map(|k| Keystroke::parse(k).expect("the typed keystroke parses")) + .collect(); + let context = [gpui::KeyContext::parse("Terminal").expect("the context parses")]; + cx.key_bindings() + .borrow() + .bindings_for_input(&input, &context) + .0 + .iter() + .map(|b| b.action().name()) + .collect() + } + + #[gpui::test] + fn a_chord_added_in_config_keeps_the_default_one(cx: &mut TestAppContext) { + use gpui::Action as _; + cx.update(|cx| { + // The report, word for word: a second way to reach the tab switcher + // took the first one away (#868). + running_on_json( + cx, + r#"{"keybindings": {"NextTab": "ctrl-alt-]", "PrevTab": "ctrl-alt-["}}"#, + ); + assert_eq!( + fired(cx, "ctrl-tab").first(), + Some(&NextTab::name_for_type()), + "the default chord survives a chord added beside it" + ); + assert_eq!( + fired(cx, "ctrl-shift-tab").first(), + Some(&PrevTab::name_for_type()) + ); + assert_eq!( + fired(cx, "ctrl-alt-]").first(), + Some(&NextTab::name_for_type()), + "and the added chord works too" + ); + assert_eq!( + fired(cx, "ctrl-alt-[").first(), + Some(&PrevTab::name_for_type()) + ); + // The palette hint and the menus still name the chord the app + // ships with. + assert_eq!(effective_key("NextTab", cx).as_deref(), Some("ctrl-tab")); + }); + } + + #[gpui::test] + fn an_empty_chord_still_unbinds_the_action(cx: &mut TestAppContext) { + cx.update(|cx| { + // What config files already say to retire a default — the docs spell + // `"AlternatePaste": ""` — and what Settings used to write for an + // action that lost its chord. It has to keep meaning "no key". + running_on_json(cx, r#"{"keybindings": {"NextTab": ""}}"#); + assert!(fired(cx, "ctrl-tab").is_empty()); + assert_eq!(effective_key("NextTab", cx), None); + }); + } + + #[gpui::test] + fn a_list_is_the_whole_set_of_chords_for_an_action(cx: &mut TestAppContext) { + use gpui::Action as _; + cx.update(|cx| { + running_on_json( + cx, + r#"{"keybindings": {"NextTab": ["ctrl-alt-]", "ctrl-alt-n"]}}"#, + ); + assert!( + fired(cx, "ctrl-tab").is_empty(), + "a list replaces the default rather than joining it" + ); + for chord in ["ctrl-alt-]", "ctrl-alt-n"] { + assert_eq!( + fired(cx, chord).first(), + Some(&NextTab::name_for_type()), + "{chord} is in the list" + ); + } + }); + } + + #[gpui::test] + fn an_empty_list_unbinds_the_action(cx: &mut TestAppContext) { + cx.update(|cx| { + running_on_json(cx, r#"{"keybindings": {"NextTab": []}}"#); + assert!(fired(cx, "ctrl-tab").is_empty()); + assert_eq!(effective_key("NextTab", cx), None); + }); + } + + /// #901: Alt+1…9 is how vim switches tabs, and tty7's default was eating + /// the whole row of them. Both halves of what the reporter wanted have to + /// hold, and hold across a restart — the complaint was that the default + /// "keeps coming back". + #[gpui::test] + fn alt_digits_can_be_moved_off_the_tab_actions_for_good(cx: &mut TestAppContext) { + use gpui::Action as _; + cx.update(|cx| { + let default_chord = per_platform("secondary-1", "alt-1"); + running_on_json( + cx, + r#"{"keybindings": {"ActivateTab1": ["alt-shift-1"], + "ActivateTab2": []}}"#, + ); + assert!( + fired(cx, default_chord).is_empty(), + "a list replaces the shipped chord, so the digit reaches the shell" + ); + assert!( + fired(cx, per_platform("secondary-2", "alt-2")).is_empty(), + "and an empty list leaves the action with no chord at all" + ); + assert_eq!( + fired(cx, "alt-shift-1").first(), + Some(&ActivateTab1::name_for_type()), + "the chord asked for in its place is live" + ); + assert_eq!(effective_key("ActivateTab2", cx), None); + + // The half that reads as "老是自动恢复": whatever the app saves has + // to load back as the same thing. A `Config` serialized and read + // again is exactly what a restart does with `config.json`. + let saved = + serde_json::to_string(&**cx.global::()).expect("the config serializes"); + cx.set_global(Config( + serde_json::from_str(&saved).expect("the saved config parses"), + )); + rebind(cx); + assert!( + fired(cx, default_chord).is_empty(), + "the shipped chord must not come back across a save and reload" + ); + assert_eq!( + fired(cx, "alt-shift-1").first(), + Some(&ActivateTab1::name_for_type()), + ); + assert_eq!(effective_key("ActivateTab2", cx), None); + }); + } + + #[gpui::test] + fn a_chord_added_under_the_tmux_preset_joins_the_preset_chord(cx: &mut TestAppContext) { + use gpui::Action as _; + cx.update(|cx| { + running_on_json( + cx, + r#"{"keybinding_preset": "tmux", + "keybindings": {"NextTab": "ctrl-alt-]", "SplitRight": ["ctrl-alt-d"]}}"#, + ); + // The preset is a scheme, and it still replaces the default chord. + assert!(fired(cx, "ctrl-tab").is_empty()); + // A chord added on top of it is added to the preset's chord. + assert_eq!( + fired(cx, "ctrl-b n").first(), + Some(&NextTab::name_for_type()) + ); + assert_eq!( + fired(cx, "ctrl-alt-]").first(), + Some(&NextTab::name_for_type()) + ); + // A list replaces the preset's chord the way it replaces a default. + assert!(fired(cx, "ctrl-b %").is_empty()); + assert_eq!( + fired(cx, "ctrl-alt-d").first(), + Some(&SplitRight::name_for_type()) + ); + }); + } + + #[gpui::test] + fn a_chord_the_user_adds_wins_over_a_default_already_on_it(cx: &mut TestAppContext) { + use gpui::Action as _; + cx.update(|cx| { + // `ctrl-tab` is `NextTab`'s default, and `NewTab` sits above it in the + // table. The keymap resolves a tie to the binding added last, so a + // user chord laid down in table order lost to the default whenever + // its action happened to come first — the line in config.json was + // read and did nothing. + running_on_json(cx, r#"{"keybindings": {"NewTab": "ctrl-tab"}}"#); + assert_eq!( + fired(cx, "ctrl-tab").first(), + Some(&NewTab::name_for_type()), + "the chord the user asked for is the one that runs" + ); + assert_eq!( + fired(cx, per_platform("secondary-t", "secondary-shift-t")).first(), + Some(&NewTab::name_for_type()), + "and NewTab keeps its own default" + ); + }); + } + #[gpui::test] fn repeated_rebinds_do_not_grow_the_keymap(cx: &mut TestAppContext) { cx.update(|cx| { diff --git a/src/ui/presets.rs b/src/ui/presets.rs index 5bc1b689..e4ae4f79 100644 --- a/src/ui/presets.rs +++ b/src/ui/presets.rs @@ -579,12 +579,41 @@ fn channel_distance(a: u32, b: u32) -> u32 { d(16).max(d(8)).max(d(0)) } -fn contrast(a: u32, b: u32) -> f32 { +pub(crate) fn contrast(a: u32, b: u32) -> f32 { let (l1, l2) = (relative_luminance(a), relative_luminance(b)); let (hi, lo) = if l1 >= l2 { (l1, l2) } else { (l2, l1) }; (hi + 0.05) / (lo + 0.05) } +/// The opaque ink SGR 2 (faint) text is painted in on a light cell, when the +/// plain fade would drop it under the text floor — `None` when the fade is +/// already legible and should stay a fade. +/// +/// Faint text is `opacity` of its ink over the cell. That costs a fixed share +/// of the ink's luminance distance, and on a light background the ratio that +/// distance buys collapses fast: Catppuccin Latte's own foreground fades from +/// 7.1:1 to 3.2:1, and every bright-black the palette rescue lifted to 4.5:1 +/// fades back to 2.5:1 — the "illegible secondary text" of #858. So the fade +/// is walked back toward the ink until it clears `TEXT_FLOOR` again, capped at +/// the ink's own ratio: text that was never above the floor is left as dim as +/// it would have been undimmed, not darkened past what the app asked for. +/// +/// Light backgrounds only. Dark themes read the same faint text at the same +/// ratios without complaint, and keeping them byte-for-byte as they were is +/// worth more than a symmetric rule nobody asked for. The test is the cell's +/// own background, so a light cell inside a dark theme is rescued too. +pub(crate) fn legible_dim(ink: u32, bg: u32, opacity: f32) -> Option { + if is_dark(bg) { + return None; + } + let faded = mix(bg, ink, opacity); + let floor = TEXT_FLOOR.min(contrast(ink, bg)); + if contrast(faded, bg) >= floor { + return None; + } + Some(bisect_contrast(faded, ink, bg, floor)) +} + fn is_dark(bg: u32) -> bool { relative_luminance(bg) < 0.5 } diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 1bc4e630..477b87b6 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -159,6 +159,16 @@ pub(crate) struct RightPanelState { /// and "nobody could tell us" are different sentences and the panel has to /// say which one it means. pub(crate) procs_unsupported: bool, + /// The last round trip measured to the machine `procs_pane` lives on. + /// `None` before the first ping comes back. + pub(crate) link_rtt: Option, + /// Which host `link_rtt` was measured against, and — since only a remote + /// pane has one — whether the latency row is drawn at all. Held per host + /// rather than per pane so that moving between two panes of the same + /// machine keeps the number on screen: it belongs to the link the two + /// panes share, and blanking it per pane would empty the row for as long + /// as the next poll takes to cross the network. + pub(crate) link_host: Option, /// How `procs_pane`'s loopback ports can be reached from this machine. /// Read by the Ports list to decide what a click on a port does, and by /// the watch to decide whether it has to keep looking with the panel shut. @@ -243,6 +253,32 @@ enum InfoValue { }, } +/// The table convention for a cell with nothing in it. Needs no translating, +/// and is shorter to read than any of the sentences it stands in for. +const EMPTY: &str = "—"; + +/// A round trip, at the precision the number is worth reading to. +/// +/// Whole milliseconds up to a second: tenths of a millisecond on a link that +/// varies by whole ones is noise dressed as measurement. Past a second the +/// millisecond stops mattering and the second is the unit anyone would say it +/// in. +fn format_rtt(rtt: std::time::Duration) -> String { + let ms = rtt.as_secs_f64() * 1000.; + if ms < 1. { + // Loopback and a peer on the same LAN both land here. Rounding to + // "0 ms" would read as a failed measurement rather than a fast one. + return "<1 ms".to_string(); + } + // Rounded before the comparison, so 999.6 ms is not shown as "1000 ms" — + // a millisecond reading that has run past the unit's own range. + let rounded = ms.round() as u64; + if rounded < 1000 { + return format!("{rounded} ms"); + } + format!("{:.1} s", rtt.as_secs_f64()) +} + /// One label/value line of the Session section. struct InfoRow { label: &'static str, @@ -753,6 +789,23 @@ impl Tty7App { if let Some(ssh) = view.ssh_spec() { rows.push(InfoRow::text(t(L10nKey::PanelSsh), ssh.host.clone()).copyable()); } + // Only where there is a network between here and the shell. On + // a pane of this machine's own the row would be reporting the + // round trip to a Unix socket, which is a number with nothing + // to compare it against. + if self.right_panel.link_host.is_some() { + rows.push(InfoRow::text( + t(L10nKey::PanelLatency), + // A link whose first ping has not come back yet, + // rather than one measured at zero. The dash is the + // table's empty cell, the same one a clean working + // tree gets. + self.right_panel + .link_rtt + .map(format_rtt) + .unwrap_or_else(|| EMPTY.to_string()), + )); + } git = view.git_status(cx); } // Read off the same pane the rows above describe, rather than off @@ -900,7 +953,7 @@ impl Tty7App { this.child( div() .text_color(cx.theme().muted_foreground) - .child("—".to_string()), + .child(EMPTY.to_string()), ) }) .when(added > 0, |this| { @@ -1574,6 +1627,14 @@ impl Tty7App { let Some(pane_id) = pane_id else { return }; self.right_panel.procs_forwards = forwards.clone(); self.right_panel.procs_host = host.clone(); + // Per host, not per pane — see `link_host`. A pane of this machine's + // own has no host at all, which is what clears the section rather than + // leaving the last remote pane's numbers under a local one. + let link_host = host.as_ref().map(|h| h.id()); + if self.right_panel.link_host != link_host { + self.right_panel.link_host = link_host; + self.right_panel.link_rtt = None; + } if self.right_panel.procs_pane != Some(pane_id) { self.right_panel.procs_pane = Some(pane_id); self.right_panel.procs = None; @@ -1605,7 +1666,15 @@ impl Tty7App { ) { cx.spawn(async move |this, cx| { let route = forwards.clone(); - let (procs, managed) = cx + // Only while someone is looking. This poll also runs with the panel + // shut, watching for ports to forward, and a round trip per round + // for a row nobody can see is the far end's time spent on nothing. + let want_link = this + .read_with(cx, |app, _| { + app.right_panel_visible && app.right_panel_tab == RightPanelTab::Info + }) + .unwrap_or(false); + let (procs, managed, link) = cx .background_executor() .spawn(async move { // A remote workspace's pane runs on the peer, so the peer @@ -1622,7 +1691,11 @@ impl Tty7App { None => Some(crate::terminal::RemoteTerminal::query_procs(pane_id)), }; let managed = route.map(|r| r.list()).unwrap_or_default(); - (procs, managed) + let link = match (want_link, &host) { + (true, Some(host)) => host.link_rtt(), + _ => None, + }; + (procs, managed, link) }) .await; let keep_polling = this @@ -1640,6 +1713,13 @@ impl Tty7App { if forwards.is_some() { app.loopback_panel.managed = managed; } + // Only when this round actually asked. A round that did not + // leaves the last answer in place, so reopening the panel + // shows the number it was closed on rather than a dash + // until the next poll lands. + if want_link { + app.right_panel.link_rtt = link; + } cx.notify(); let wanted = app.procs_wanted(); if !wanted { @@ -1798,7 +1878,7 @@ fn compact_path(path: &std::path::Path, home: Option<&std::path::Path>) -> Strin #[cfg(test)] mod tests { - use super::{InfoRow, InfoValue, forwards_port}; + use super::{InfoRow, InfoValue, format_rtt, forwards_port}; use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind}; fn forward(kind: SshForwardKind, target_host: &str, target_port: u16) -> ManagedForward { @@ -1904,6 +1984,25 @@ mod tests { ); } + #[test] + fn a_round_trip_is_read_at_the_precision_it_is_worth() { + use std::time::Duration; + // A peer on the same machine or the same LAN. "0 ms" would read as a + // measurement that failed rather than one that was fast. + assert_eq!(format_rtt(Duration::from_micros(120)), "<1 ms"); + assert_eq!(format_rtt(Duration::from_micros(999)), "<1 ms"); + assert_eq!(format_rtt(Duration::from_millis(1)), "1 ms"); + assert_eq!(format_rtt(Duration::from_micros(23_400)), "23 ms"); + assert_eq!(format_rtt(Duration::from_millis(999)), "999 ms"); + // Rounding up out of the millisecond's own range hands the number to + // the unit above rather than printing a four-digit millisecond. + assert_eq!(format_rtt(Duration::from_micros(999_600)), "1.0 s"); + // Past a second the millisecond has stopped carrying information, and + // the second is the unit anyone would say the number in. + assert_eq!(format_rtt(Duration::from_millis(1_450)), "1.4 s"); + assert_eq!(format_rtt(Duration::from_secs(4)), "4.0 s"); + } + #[test] fn copyable_takes_the_text_the_row_shows_and_nothing_else() { // `copyable()` reads the value it was given; rows built with an diff --git a/src/ui/scm/detail.rs b/src/ui/scm/detail.rs index 0a148518..f169122f 100644 --- a/src/ui/scm/detail.rs +++ b/src/ui/scm/detail.rs @@ -46,7 +46,7 @@ use tty7_core::core::git::status::DecoStatus; use crate::terminal::git_diff::DiffSource; use crate::ui::app::{CONTENT_INSET, Tty7App}; use crate::ui::i18n::{L10nKey, t, t_plural}; -use crate::ui::right_panel::{META, META_MONO, ROW_INSET, TEXT, TEXT_MONO, git_badge, info_chip}; +use crate::ui::right_panel::{META, META_MONO, ROW_INSET, TEXT, git_badge, info_chip}; use crate::ui::scm::path::{relative_time, split_display_path}; use crate::ui::scm::state::{CommitDetailView, RepoKey}; use crate::ui::scm::status::{status_color, status_glyph}; diff --git a/src/ui/scm/panel.rs b/src/ui/scm/panel.rs index 8b7d6ced..7954aa25 100644 --- a/src/ui/scm/panel.rs +++ b/src/ui/scm/panel.rs @@ -32,7 +32,7 @@ use crate::ui::app::{CONTENT_INSET, TILE_GLYPH_XS, TILE_SIZE_XS, Tty7App}; use crate::ui::host_ops::{HostId, SharedHost}; use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural}; use crate::ui::right_panel::{ - HEADING, META, META_MONO, ROW_INSET, SEARCH_H, TEXT_MONO, action_strip, git_badge, info_chip, + HEADING, META, META_MONO, ROW_INSET, SEARCH_H, action_strip, git_badge, info_chip, }; use crate::ui::rounding::{CARD_RADIUS, HAIRLINE, RoundedCorners as _, segment_corners}; use crate::ui::scm::ScmIntent; diff --git a/src/ui/settings.rs b/src/ui/settings.rs index e79cb9fa..febaf1f1 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -26,7 +26,9 @@ use crate::core::config::{ BellMode, Config, CursorStyle, LinkFileOpen, MouseZoomModifier, NewTabPosition, NotifyMode, TabBarPosition, UI_FONT_SIZE_DEFAULT, UpdateChannel, WindowBackdrop, }; -use crate::core::keychain::CredentialRef; +use crate::core::keychain::{ + CredentialRef, CredentialStore as _, OsCredentialStore, key_account_from_contents, +}; use crate::core::ssh_profile::{ Algorithms, AuthMode, ForwardKind, ForwardRule, HostPort, SshProfile, to_connect_string, }; @@ -228,6 +230,19 @@ fn ui_scale(cx: &App) -> f32 { /// without being truncated. const FIELD_W: f32 = 260.; +/// The host editor's own two numbers: the column its labels stand in, and how +/// wide a field beside one grows to. The label column fits the longest field +/// name in any locale at the default interface font; the field is wider than +/// [`FIELD_W`] because the form is what a path, a key file and a hostname are +/// actually typed into. +const SSH_LABEL_W: f32 = 104.; +const FORM_FIELD_W: f32 = 320.; + +/// Width a host-editor row needs before its label column and its field fit +/// side by side. Under it the label goes above the field instead, which is +/// what the narrowest window leaves the SSH page room for. +const STACK_FIELD_ROW_BELOW: f32 = 420.; + /// Width a settings row needs before its label and its control fit side by /// side: a [`FIELD_W`] control, the `gap_8` between them, and enough left for a /// description to read as prose rather than as a column of words. @@ -745,6 +760,16 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsAgentKimiCode, keywords: SettingsSearchKimiCodeKeywords, }, + SearchEntry { + section: Agents, + title: SettingsAgentQoderCLI, + keywords: SettingsSearchQoderCLIKeywords, + }, + SearchEntry { + section: Agents, + title: SettingsAgentCrush, + keywords: SettingsSearchCrushKeywords, + }, SearchEntry { section: General, title: SettingsStartupWindow, @@ -1362,6 +1387,21 @@ pub(crate) struct SshProfileForm { auth: AuthMode, auth_select: Entity>>, + /// The secret half of a connection. Neither of these is part of the + /// profile — they live in the system keychain, and the config file holds + /// no copy — so the form carries what the keychain had when it opened and + /// compares against it on the way out. A form that was only read writes + /// nothing back, and one that cleared a field says so. + password: Entity, + passphrase: Entity, + loaded_password: String, + loaded_passphrase: String, + /// The endpoint the password above was read for, and the key file the + /// passphrase belongs to. An edit to the address moves the entry, and + /// without these there is nothing left pointing at the one to remove. + loaded_endpoint: (String, String, u16), + loaded_key: Option, + jump: Entity, forwards: Vec, @@ -1407,10 +1447,72 @@ impl SshProfileForm { /// needs a host before anyone had the chance to type one. Same deal the /// forward rows strike with `ForwardRuleForm::is_blank`. fn core_is_blank(&self, cx: &App) -> bool { - [&self.name, &self.host, &self.port, &self.user] + // The port is not in the list: it opens on 22 and is never empty, so + // counting it meant a brand-new host was never "untouched" and the + // form opened with "Needs a host" already in red under an empty box + // nobody had reached yet. + [&self.name, &self.host, &self.user] .iter() .all(|e| e.read(cx).value().trim().is_empty()) } + + /// Whether either secret differs from what the keychain handed over. + /// + /// Nothing about a password reaches the profile, so the dirty check that + /// compares profiles cannot see one being typed — without this, Save stays + /// greyed out over a password the user just entered. + /// + /// Untrimmed on purpose: a trailing space is a character of the secret, + /// and the server is the one that decides whether it belongs. + fn secrets_changed(&self, cx: &App) -> bool { + self.password.read(cx).value().as_ref() != self.loaded_password + || self.passphrase.read(cx).value().as_ref() != self.loaded_passphrase + } + + fn wants_password(&self) -> bool { + auth_uses_password(self.auth) + } + + fn wants_key(&self) -> bool { + auth_uses_key(self.auth) + } +} + +/// Which credential fields a method actually uses — the same split +/// `ssh_connect::build_spec_inner` makes when it decides what to hand the +/// daemon. A password box under "Agent" would be a secret that is stored and +/// then never offered, and a form holding one quietly is worse than a form +/// that has none. +fn auth_uses_password(mode: AuthMode) -> bool { + matches!(mode, AuthMode::Auto | AuthMode::Password) +} + +fn auth_uses_key(mode: AuthMode) -> bool { + matches!(mode, AuthMode::Auto | AuthMode::PublicKey) +} + +/// What saving does to the keychain for the password field: which entry to +/// drop, and whether to write one. +/// +/// A plain function because these are the cases a keychain makes expensive to +/// reach by hand — an address edited out from under a saved password, a field +/// cleared to mean "stop remembering this", a form opened and closed without a +/// keystroke. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +struct PasswordPlan { + drop_old: bool, + store: bool, +} + +fn password_plan(was: &str, typed: &str, moved: bool) -> PasswordPlan { + PasswordPlan { + // Only what this form read is ours to drop, and only once it is no + // longer the entry this form would write to. + drop_old: !was.is_empty() && (moved || typed.is_empty()), + // A move rewrites even an unchanged secret: the account it is filed + // under is the address, and the address is what changed. + store: !typed.is_empty() && (typed != was || moved), + } } pub(crate) struct ForwardRuleForm { @@ -1916,6 +2018,124 @@ fn seed_input( }) } +fn seed_hinted_multi( + window: &mut Window, + cx: &mut Context, + value: &str, + placeholder: &'static str, +) -> Entity { + let value = value.to_string(); + cx.new(|cx| { + InputState::new(window, cx) + .multi_line(true) + .placeholder(placeholder) + .default_value(value) + }) +} + +/// A picked path written the way a config file spells it. `~/.ssh/id_ed25519` +/// keeps meaning the right file on another machine, or after the account is +/// renamed; the absolute path the system picker hands back does not. +fn tildify(path: &str) -> String { + #[cfg(windows)] + let home = std::env::var("USERPROFILE").ok(); + #[cfg(not(windows))] + let home = std::env::var("HOME").ok(); + tildify_with(path, home.as_deref().filter(|h| !h.is_empty())) +} + +fn tildify_with(path: &str, home: Option<&str>) -> String { + let Some(home) = home else { + return path.to_string(); + }; + let home = home.trim_end_matches(['/', '\\']); + match path.strip_prefix(home) { + // A separator has to follow, or `/Users/adalovelace` would come back + // as a file inside `/Users/ada`. + Some(rest) if rest.starts_with('/') || rest.starts_with('\\') => format!( + "~/{}", + rest.trim_start_matches(['/', '\\']).replace('\\', "/") + ), + _ => path.to_string(), + } +} + +/// What the key field shows while it is empty: the file ssh would reach for on +/// its own. A hint, not a value — an empty field still means "try the usual +/// `~/.ssh` keys", which is exactly what `default_identity_candidates` does. +const DEFAULT_KEY_HINT: &str = "~/.ssh/id_ed25519"; + +/// The password the keychain holds for this profile's endpoint, or nothing. +/// +/// Read once, when a host is opened for editing — not per render, and not per +/// keystroke. A profile with no host yet has no endpoint to ask about: the +/// account would come out as `@:22`, which belongs to no server. +fn stored_password(profile: &SshProfile) -> String { + if profile.host.trim().is_empty() { + return String::new(); + } + OsCredentialStore + .password_for(&profile.user, &profile.host, profile.port) + .ok() + .flatten() + .unwrap_or_default() +} + +/// The first key file a profile would offer that is actually there. +/// +/// A passphrase is accounted by the key's *contents*, not by its path, so a +/// file that cannot be read is a key nothing can be stored against. +fn first_readable_key(profile: &SshProfile) -> Option { + first_readable_key_in(&profile.identity_files, &profile.host, &profile.user) +} + +/// The same answer for a form that has not been collected into a profile yet: +/// the key field as typed, with the host and user beside it filling in `%h` +/// and `%r`. +fn first_readable_key_in(files: &[String], host: &str, user: &str) -> Option { + first_readable_key_or( + files, + host, + user, + crate::core::ssh_profile::default_identity_candidates, + ) +} + +/// An empty key field is not "no key": `build_spec_inner` offers the `~/.ssh` +/// defaults then, and looks their passphrases up by those exact strings. The +/// box has to follow the same list, or the most common setup — no key named, +/// an encrypted `id_ed25519` — could never be given a passphrase here. +fn first_readable_key_or( + files: &[String], + host: &str, + user: &str, + defaults: impl FnOnce() -> Vec, +) -> Option { + let candidates = if files.is_empty() { + defaults() + } else { + files + .iter() + .map(|f| crate::core::ssh_profile::expand_identity_placeholders(f, host, user)) + .collect() + }; + candidates + .into_iter() + .find(|p| std::fs::metadata(p).is_ok()) +} + +fn stored_passphrase(key_path: &str) -> String { + let path = crate::core::ssh_profile::expand_tilde(key_path); + let Ok(bytes) = std::fs::read(&path) else { + return String::new(); + }; + OsCredentialStore + .passphrase_for_key(&key_account_from_contents(&bytes)) + .ok() + .flatten() + .unwrap_or_default() +} + impl Tty7App { pub(crate) fn with_settings_edits_resolved( &mut self, @@ -1960,7 +2180,9 @@ impl Tty7App { return; } } - if this.ssh_form_dirty(cx) && this.save_editing_profile(cx).is_none() { + if this.ssh_form_dirty(cx) + && this.save_editing_profile(window, cx).is_none() + { return; } if !this.save_theme_draft(window, cx) { @@ -4532,17 +4754,45 @@ impl Tty7App { }) .unwrap_or_default(); - let name = seed_input(window, cx, &profile.name, false); - let host = seed_input(window, cx, &profile.host, false); + let name = seed_hinted(window, cx, &profile.name, t(L10nKey::SettingsNameHint)); + let host = seed_hinted(window, cx, &profile.host, t(L10nKey::SettingsHostHint)); let port = seed_input(window, cx, &profile.port.to_string(), false); - let user = seed_input(window, cx, &profile.user, false); + let user = seed_hinted(window, cx, &profile.user, t(L10nKey::SettingsUserHint)); let jump = seed_input(window, cx, &jump_name, false); let forwards: Vec = profile .forwards .iter() .map(|r| seed_forward_row(window, cx, r)) .collect(); - let identity_files = seed_input(window, cx, &profile.identity_files.join("\n"), true); + let identity_files = seed_hinted_multi( + window, + cx, + &profile.identity_files.join("\n"), + DEFAULT_KEY_HINT, + ); + + // One keychain read per host opened, not one per keystroke: the + // password box shows what is actually stored, the way every other SSH + // client shows it, so it can be read back, corrected or cleared + // without connecting first. + let loaded_password = stored_password(profile); + let loaded_key = first_readable_key(profile); + let loaded_passphrase = loaded_key + .as_deref() + .map(stored_passphrase) + .unwrap_or_default(); + let password = cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder(t(L10nKey::SettingsPasswordHint)) + .default_value(loaded_password.clone()) + }); + let passphrase = cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder(t(L10nKey::SettingsPasswordHint)) + .default_value(loaded_passphrase.clone()) + }); let proxy_command = seed_input( window, cx, @@ -4619,12 +4869,28 @@ impl Tty7App { } }, )); + // The passphrase belongs to whichever key the field above names, so + // when that answer changes the box has to change with it. Without this + // a form opened on one key and pointed at another would carry the + // first key's passphrase across and save it over the second's. Host and + // user count too: they fill in a `%h` / `%r` in the key's path. + for input in [&identity_files, &host, &user] { + subs.push( + cx.subscribe_in(input, window, |this, _i, ev: &InputEvent, window, cx| { + if matches!(ev, InputEvent::Change) { + this.resync_key_passphrase(window, cx); + } + }), + ); + } let mut watch = vec![ &name, &host, &port, &user, &jump, + &password, + &passphrase, &identity_files, &proxy_command, &socks, @@ -4672,6 +4938,12 @@ impl Tty7App { user, auth: profile.auth, auth_select, + password, + passphrase, + loaded_password, + loaded_passphrase, + loaded_endpoint: (profile.user.clone(), profile.host.clone(), profile.port), + loaded_key, jump, forwards, identity_files, @@ -4753,7 +5025,76 @@ impl Tty7App { )) } - pub(crate) fn save_editing_profile(&mut self, cx: &mut Context) -> Option { + /// Point the passphrase box at the key the form now names. + /// + /// A passphrase is stored against the contents of the key it unlocks, so + /// the box is only ever right about one key at a time. A box the user has + /// started typing in is left alone — it is the one place where what is on + /// screen outranks what the keychain holds. + fn resync_key_passphrase(&mut self, window: &mut Window, cx: &mut Context) { + let Some(form) = self.active_settings().and_then(|s| s.ssh_form.as_ref()) else { + return; + }; + let files = split_lines(&form.identity_files.read(cx).value()); + let host = form.host.read(cx).value().trim().to_string(); + let user = form.user.read(cx).value().trim().to_string(); + let key = first_readable_key_in(&files, &host, &user); + if key == form.loaded_key { + return; + } + let stored = key.as_deref().map(stored_passphrase).unwrap_or_default(); + // A box the user has already typed in keeps what they typed — only + // the key it will be saved against moves under it. + let untouched = form.passphrase.read(cx).value().as_ref() == form.loaded_passphrase; + let input = form.passphrase.clone(); + if let Some(form) = self.ssh_form_mut() { + form.loaded_key = key; + form.loaded_passphrase = stored.clone(); + } + if untouched { + input.update(cx, |i, cx| i.set_value(stored, window, cx)); + } + cx.notify(); + } + + /// Add key files to the profile from the system file picker, one per line + /// alongside whatever is already named. Typing the path still works — this + /// is for the far more common case of knowing the key by sight and not by + /// path. + pub(crate) fn pick_ssh_identity_file(&mut self, window: &mut Window, cx: &mut Context) { + let rx = cx.prompt_for_paths(gpui::PathPromptOptions { + files: true, + directories: false, + multiple: true, + prompt: None, + }); + cx.spawn_in(window, async move |this, cx| { + let Ok(Ok(Some(paths))) = rx.await else { + return; + }; + let _ = this.update_in(cx, |this, window, cx| { + let Some(form) = this.active_settings().and_then(|s| s.ssh_form.as_ref()) else { + return; + }; + let input = form.identity_files.clone(); + let mut lines = split_lines(&input.read(cx).value()); + for path in paths { + let path = tildify(&path.to_string_lossy()); + if !lines.contains(&path) { + lines.push(path); + } + } + input.update(cx, |i, cx| i.set_value(lines.join("\n"), window, cx)); + }); + }) + .detach(); + } + + pub(crate) fn save_editing_profile( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { let (profile, errors) = self.ssh_form_collect(cx)?; // Save and Connect are both disabled while anything is wrong, but this // is the door all of them go through, and what gets past it lands in @@ -4765,9 +5106,9 @@ impl Tty7App { let id = profile.id; self.update_config(cx, |cfg| { if let Some(slot) = cfg.ssh_profiles.iter_mut().find(|p| p.id == id) { - *slot = profile; + *slot = profile.clone(); } else { - cfg.ssh_profiles.push(profile); + cfg.ssh_profiles.push(profile.clone()); } }); if self @@ -4776,6 +5117,7 @@ impl Tty7App { { return None; } + self.save_ssh_form_secrets(&profile, window, cx); Some(id) } @@ -4800,8 +5142,147 @@ impl Tty7App { cx.notify(); } - pub(crate) fn save_ssh_form(&mut self, cx: &mut Context) { - self.save_editing_profile(cx); + /// Move the two secrets in the form into the keychain — or out of it. + /// + /// The config file never holds either of them, so this is the whole of + /// what saving means for a password: an entry keyed by the endpoint the + /// profile now names. Editing the address moves the entry rather than + /// leaving the old one behind to be offered to a host that no longer + /// exists, and clearing the field removes it. + fn save_ssh_form_secrets( + &mut self, + profile: &SshProfile, + window: &mut Window, + cx: &mut Context, + ) { + let Some(form) = self.active_settings().and_then(|s| s.ssh_form.as_ref()) else { + return; + }; + let typed = form.password.read(cx).value().to_string(); + let typed_passphrase = form.passphrase.read(cx).value().to_string(); + let (old_user, old_host, old_port) = form.loaded_endpoint.clone(); + let was = form.loaded_password.clone(); + let was_passphrase = form.loaded_passphrase.clone(); + let moved = (old_user.as_str(), old_host.as_str(), old_port) + != (profile.user.as_str(), profile.host.as_str(), profile.port); + + let mut failures: Vec = Vec::new(); + let endpoint = |u: &str, h: &str, p: u16| format!("{u}@{h}:{p}"); + let plan = password_plan(&was, &typed, moved); + + // The entry the form read from, once it is no longer the entry the + // form would write to. Left alone when another profile still dials the + // same address — the keychain accounts by endpoint, not by profile. + let stranded = plan.drop_old && !old_host.trim().is_empty(); + if stranded + && !self.endpoint_still_in_use(&old_user, &old_host, old_port, profile.id, cx) + && let Err(e) = OsCredentialStore.delete_password(&old_user, &old_host, old_port) + { + failures.push(t_fmt( + L10nKey::SettingsCouldntForgetPassword, + &[ + ("endpoint", &endpoint(&old_user, &old_host, old_port)), + ("error", &e.to_string()), + ], + )); + } + if plan.store + && !profile.host.trim().is_empty() + && let Err(e) = + OsCredentialStore.set_password(&profile.user, &profile.host, profile.port, &typed) + { + failures.push(t_fmt( + L10nKey::SettingsCouldntSavePassword, + &[ + ( + "endpoint", + &endpoint(&profile.user, &profile.host, profile.port), + ), + ("error", &e.to_string()), + ], + )); + } + + // A passphrase belongs to the key it unlocks, not to this profile, so + // a save only ever touches the entry for the key named here. Pointing + // the profile at a different key leaves the first key's passphrase + // alone — other hosts use that key too. + let key = first_readable_key(profile); + if typed_passphrase != was_passphrase { + match key.as_deref() { + Some(path) => self.write_key_passphrase(path, &typed_passphrase, &mut failures), + // Nowhere to put it: the field names no key, or names one that + // is not on this machine. Storing nothing quietly would lose a + // passphrase the user watched themselves type. + None if !typed_passphrase.is_empty() => { + failures.push(t(L10nKey::SettingsPassphraseNeedsKey).to_string()) + } + None => {} + } + } + + for line in failures { + window.push_notification(line, cx); + } + + // What the form would now read back, so a save leaves it clean. + if let Some(form) = self.ssh_form_mut() { + form.loaded_password = typed; + form.loaded_passphrase = typed_passphrase; + form.loaded_endpoint = (profile.user.clone(), profile.host.clone(), profile.port); + form.loaded_key = key; + } + } + + /// A blank secret deletes rather than stores: an empty string is not a + /// passphrase, and leaving one behind would keep offering it. + fn write_key_passphrase(&self, key_path: &str, secret: &str, failures: &mut Vec) { + let path = crate::core::ssh_profile::expand_tilde(key_path); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) => { + failures.push(t_fmt( + L10nKey::SettingsCouldntSavePassphrase, + &[("key", key_path), ("error", &e.to_string())], + )); + return; + } + }; + let account = key_account_from_contents(&bytes); + let result = if secret.is_empty() { + OsCredentialStore.delete_key_passphrase(&account) + } else { + OsCredentialStore + .set_key_passphrase(&account, secret) + .map(|_| ()) + }; + if let Err(e) = result { + failures.push(t_fmt( + L10nKey::SettingsCouldntSavePassphrase, + &[("key", key_path), ("error", &e.to_string())], + )); + } + } + + /// Whether some other saved host still dials this endpoint. The keychain + /// entry is the address's, not the profile's — the same reason "Forget + /// password" counts the hosts it would sign out. + fn endpoint_still_in_use( + &self, + user: &str, + host: &str, + port: u16, + except: Uuid, + cx: &App, + ) -> bool { + cx.global::() + .ssh_profiles + .iter() + .any(|p| p.id != except && p.user == user && p.host == host && p.port == port) + } + + pub(crate) fn save_ssh_form(&mut self, window: &mut Window, cx: &mut Context) { + self.save_editing_profile(window, cx); cx.notify(); } @@ -4820,7 +5301,7 @@ impl Tty7App { .iter() .find(|p| p.id == form.editing) .cloned(); - self.ssh_form_collect(cx).map(|(profile, _)| profile) != saved + self.ssh_form_collect(cx).map(|(profile, _)| profile) != saved || form.secrets_changed(cx) } /// Closing from Escape or the X is the user leaving; every other caller @@ -4842,7 +5323,26 @@ impl Tty7App { if !errors.is_empty() { return; } - let spec = Box::new(self.native_ssh_spec_for_profile(&profile, cx)); + let mut spec = Box::new(self.native_ssh_spec_for_profile(&profile, cx)); + // The spec is built from the keychain, so without this Test would dial + // with the *saved* password while a new one sits typed on screen — + // and report a failure the form could not explain. + if let Some(form) = self.active_settings().and_then(|s| s.ssh_form.as_ref()) { + if form.wants_password() { + let typed = form.password.read(cx).value().to_string(); + if !typed.is_empty() { + spec.password = Some(typed); + } + } + if form.wants_key() { + let typed = form.passphrase.read(cx).value().to_string(); + if let (false, Some(key)) = (typed.is_empty(), first_readable_key(&profile)) { + spec.key_passphrases + .get_or_insert_with(Default::default) + .insert(key, typed); + } + } + } let editing = profile.id; if let Some(form) = self.ssh_form_mut() { form.test = Some(SshTestState::Running); @@ -4868,7 +5368,7 @@ impl Tty7App { } pub(crate) fn save_and_connect_profile(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(id) = self.save_editing_profile(cx) { + if let Some(id) = self.save_editing_profile(window, cx) { self.close_settings(window, cx); self.connect_ssh_profile(id, window, cx); } @@ -5221,7 +5721,10 @@ impl Tty7App { .cloned(); let (collected, errors) = self.ssh_form_collect(cx).unzip(); let errors = errors.unwrap_or_default(); - let dirty = collected != saved; + // A password is not part of the profile, so a change to one is + // invisible to the comparison above — and Save would sit greyed out + // over a secret the user just typed. + let dirty = collected != saved || form.secrets_changed(cx); let address = collected .as_ref() .map(to_connect_string) @@ -5330,7 +5833,9 @@ impl Tty7App { .label(t(L10nKey::Save)) .small() .disabled(!dirty || !errors.is_empty()) - .on_click(cx.listener(|this, _, _w, cx| this.save_ssh_form(cx))), + .on_click( + cx.listener(|this, _, window, cx| this.save_ssh_form(window, cx)), + ), ) .child( // Connect saves first, so it answers to the same @@ -5361,78 +5866,40 @@ impl Tty7App { .map(|e| field_error(e.message(), cx)); let port_error = errors.port.as_ref().map(|e| field_error(e.message(), cx)); + // Three fields whose labels say everything a sentence under them + // would: what goes in them is shown in the box itself, as a hint that + // gets out of the way the moment anything is typed. let core = v_flex() - .gap_3() + .gap_1() + .child(self.ssh_field_row( + t(L10nKey::SettingsName), + Input::new(&form.name).small().w_full().into_any_element(), + vec![], + cx, + )) .child( - self.settings_row( - t(L10nKey::SettingsName), - t(L10nKey::SettingsNameDesc), - div() - .w(px(FIELD_W)) - .max_w_full() - .child(Input::new(&form.name).small()) - .into_any_element(), - cx, - ), - ) - .child( - self.settings_row( + self.ssh_field_row( t(L10nKey::SettingsHost), - t(L10nKey::SettingsHostDesc), - v_flex() - .gap_1() - .max_w_full() - .child( - h_flex() - .gap_2() - .max_w_full() - .child( - div() - .w(px(172.)) - .min_w_0() - .child(Input::new(&form.host).small()), - ) - .child( - div() - .w(px(80.)) - .flex_shrink_0() - .child(Input::new(&form.port).small()), - ), - ) - .when_some(host_error, |col, line| col.child(line)) - .when_some(port_error, |col, line| col.child(line)) + h_flex() + .w_full() + .gap_2() + .child(Input::new(&form.host).small().flex_1().min_w_0()) + .child(Input::new(&form.port).small().w(px(64. * ui_scale(cx)))) .into_any_element(), + host_error + .into_iter() + .chain(port_error) + .map(IntoElement::into_any_element) + .collect(), cx, ), ) - .child( - self.settings_row( - t(L10nKey::SettingsUser), - t(L10nKey::SettingsUserDesc), - div() - .w(px(FIELD_W)) - .max_w_full() - .child(Input::new(&form.user).small()) - .into_any_element(), - cx, - ), - ) - .child( - self.settings_row( - t(L10nKey::SettingsAuth), - t(L10nKey::SettingsAuthDesc), - // Six methods is more than a segmented control can label without - // squeezing, and this row is the one that stacks first on a - // narrow page. A dropdown carries the same choice at a fixed - // width, the way the other long-form pickers on this page do. - Select::new(&form.auth_select) - .small() - .w(px(FIELD_W)) - .max_w_full() - .into_any_element(), - cx, - ), - ); + .child(self.ssh_field_row( + t(L10nKey::SettingsUser), + Input::new(&form.user).small().w_full().into_any_element(), + vec![], + cx, + )); v_flex() .gap_4() @@ -5443,12 +5910,169 @@ impl Tty7App { col.child(h_flex().w_full().justify_end().child(line)) }) .child(core) + .child(self.render_ssh_profile_auth_section(form, cx)) .child(self.render_ssh_profile_jump_section(form, &errors, cx)) .child(self.render_ssh_profile_forwards_section(form, cx)) .child(self.render_ssh_profile_advanced_section(form, &errors, cx)) .into_any_element() } + /// One field of the host editor: its label in a narrow column on the left, + /// the field immediately beside it, and whatever the field has to say — + /// its description, or a complaint about what is in it — underneath the + /// field rather than underneath the label. + /// + /// The settings rows on the rest of this page push their control to the + /// far right edge of the page, which is right for a list of independent + /// switches and wrong for a form: it left a hand's width of nothing + /// between the word "Host" and the box a hostname goes in, and the eye had + /// to cross it once per field. Every SSH client worth borrowing from keeps + /// the two together. + fn ssh_field_row( + &self, + label: &str, + control: AnyElement, + under: Vec, + cx: &Context, + ) -> AnyElement { + let stacked = self.settings_row_under(STACK_FIELD_ROW_BELOW, cx); + let scale = ui_scale(cx); + div() + .flex() + .w_full() + .py_1p5() + .when(stacked, |row| row.flex_col().items_start().gap_1()) + .when(!stacked, |row| row.flex_row().items_start().gap_3()) + .child( + div() + .when(!stacked, |l| { + // Right up against the field, and level with the text + // inside it rather than with the top of its border — + // a left-aligned column of short words would leave a + // different-sized hole after every label. + l.w(px(SSH_LABEL_W * scale)) + .flex_shrink_0() + .pt(px(6.)) + .text_right() + }) + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(cx.theme().foreground) + .child(label.to_string()), + ) + .child( + // A definite width, not `flex_1`: a percentage inside a + // flex-grown box has no definite parent to resolve against, + // and every `w_full` control in here came out at its intrinsic + // size — a hostname field one character wide. + v_flex() + .w(px(FORM_FIELD_W * scale)) + .max_w_full() + .gap_1() + .child(control) + .children(under), + ) + .into_any_element() + } + + /// The credential half of the form, and the only part of it that is not + /// stored in the config file. + /// + /// Which boxes appear follows the method, the way every SSH client does + /// it: a password box under a key-only method would be a secret that is + /// stored and never offered. The split is the one `build_spec_inner` + /// makes when it decides what to hand the daemon. + fn render_ssh_profile_auth_section( + &self, + form: &SshProfileForm, + cx: &mut Context, + ) -> AnyElement { + // Whether there is a key to *store a passphrase against* — which is a + // readable file, not merely a path someone typed. `loaded_key` is that + // answer, kept current by `resync_key_passphrase`. + let has_key = form.loaded_key.is_some(); + v_flex() + .gap_1() + .child(self.subgroup_header(L10nKey::SettingsGroupAuthentication, cx)) + .child( + self.ssh_field_row( + t(L10nKey::SettingsAuth), + // Six methods is more than a segmented control can label + // without squeezing, so a dropdown carries the choice — the + // way the other long-form pickers on this page do. + Select::new(&form.auth_select) + .small() + .w_full() + .into_any_element(), + vec![field_note(t(L10nKey::SettingsAuthDesc), cx).into_any_element()], + cx, + ), + ) + .when(form.wants_password(), |col| { + col.child( + self.ssh_field_row( + t(L10nKey::SettingsPassword), + Input::new(&form.password) + .small() + .mask_toggle() + .w_full() + .into_any_element(), + vec![field_note(t(L10nKey::SettingsPasswordDesc), cx).into_any_element()], + cx, + ), + ) + }) + .when(form.wants_key(), |col| { + col.child( + self.ssh_field_row( + t(L10nKey::SettingsIdentityFiles), + h_flex() + .w_full() + .items_start() + .gap_2() + .child(Input::new(&form.identity_files).small().flex_1().min_w_0()) + .child( + Button::new("ssh-form-browse-key") + .label(t(L10nKey::SettingsBrowseKey)) + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.pick_ssh_identity_file(window, cx) + })), + ) + .into_any_element(), + vec![ + field_note(t(L10nKey::SettingsIdentityFilesDesc), cx) + .into_any_element(), + ], + cx, + ), + ) + .child( + self.ssh_field_row( + t(L10nKey::SettingsKeyPassphrase), + Input::new(&form.passphrase) + .small() + .mask_toggle() + .disabled(!has_key) + .w_full() + .into_any_element(), + vec![ + field_note( + match has_key { + true => t(L10nKey::SettingsKeyPassphraseDesc), + false => t(L10nKey::SettingsPassphraseNeedsKey), + }, + cx, + ) + .into_any_element(), + ], + cx, + ), + ) + }) + .into_any_element() + } + fn disclosure_header( &self, id: &'static str, @@ -5862,14 +6486,12 @@ impl Tty7App { }; section = section + // The key file and the two secrets moved up to the Authentication + // block on the form itself — they are what a connection is made + // of, not a corner of it. What is left here is the option that + // hands this host the local agent, which is a decision about + // trust rather than about how to log in. .child(self.subgroup_header(L10nKey::SettingsGroupAuthentication, cx)) - .child(text_row( - self, - t(L10nKey::SettingsIdentityFiles), - t(L10nKey::SettingsIdentityFilesDesc), - &form.identity_files, - cx, - )) .child( self.settings_row( t(L10nKey::SettingsAgentForwarding), @@ -6853,8 +7475,19 @@ impl Tty7App { .when_some(row_note, |col, text| { col.child( div() - .max_w_80() - .max_w_full() + // One cap, not two: `max_w` holds a single + // length, so stating both left the note + // sized against a shrink-proof column that + // is itself sized to the note — no cap at + // all, and a long error (Codex's missing + // `codex` binary) inflated the row until + // the label column, `min_w_0`, came out one + // character per line. Stacked, the control + // column *is* the row, so a relative cap + // resolves; beside the label it cannot, and + // 320 is what the row has room for. + .when(stacked, |note| note.max_w_full()) + .when(!stacked, |note| note.max_w_80()) .text_xs() .when(!stacked, |note| note.text_right()) .text_color(muted_fg) @@ -7629,7 +8262,7 @@ impl Tty7App { ) }; let tmux = preset == "tmux"; - let effective = crate::ui::keymap::effective_bindings(cx); + let effective = crate::ui::keymap::effective_chords(cx); let recording = self .active_settings() @@ -7737,7 +8370,7 @@ impl Tty7App { let filtering = !query.is_empty() && section_match_count(section, &query) > 0; let mut grouped: Vec<( crate::ui::palette::CommandGroup, - Vec<(String, String, String)>, + Vec<(String, Vec, String)>, )> = Vec::new(); for (action, key) in effective { if filtering && !keybinding_matches_query(&action, &query) { @@ -7759,7 +8392,7 @@ impl Tty7App { .position(|o| o == g) .unwrap_or(usize::MAX) }); - let rows: Vec<(String, String, String)> = grouped + let rows: Vec<(String, Vec, String)> = grouped .iter() .flat_map(|(_, rows)| rows.iter().cloned()) .collect(); @@ -7843,7 +8476,23 @@ impl Tty7App { .child("—") .into_any_element() } else { - keycaps(&key).into_any_element() + // An action can answer to more than one chord — its default and + // one added beside it in config.json (#868) — and this is the + // one page that lists them, so a row shows every one. + h_flex() + .flex_wrap() + .items_center() + .gap_2() + .children(key.iter().enumerate().map(|(n, spec)| { + h_flex() + .items_center() + .gap_2() + .when(n > 0, |d| { + d.child(div().text_xs().text_color(muted).child("/")) + }) + .child(keycaps(spec)) + })) + .into_any_element() }; let action_for_click = action.clone(); @@ -8527,6 +9176,158 @@ mod tests { ); } + /// The credential boxes the form shows have to be the ones the connection + /// will actually offer. `build_spec_inner` sends a password for Auto and + /// Password and key passphrases for Auto and Key, and nothing for the + /// rest — a box outside that split collects a secret, stores it in the + /// keychain, and never hands it to anybody. + #[test] + fn a_credential_box_only_appears_where_the_handshake_would_use_it() { + for mode in AUTH_MODES { + assert_eq!( + auth_uses_password(mode), + matches!(mode, AuthMode::Auto | AuthMode::Password), + "{mode:?} password box" + ); + assert_eq!( + auth_uses_key(mode), + matches!(mode, AuthMode::Auto | AuthMode::PublicKey), + "{mode:?} key boxes" + ); + } + assert!(!auth_uses_password(AuthMode::Agent)); + assert!(!auth_uses_key(AuthMode::Gssapi)); + } + + /// The password lives in the keychain under the address, not in the + /// profile — so saving has to decide two things the config file cannot + /// record: whether the entry the form read is now stranded, and whether + /// there is anything new to write. + #[test] + fn saving_moves_a_password_with_the_address_it_belongs_to() { + let plan = |was, typed, moved| password_plan(was, typed, moved); + + // A form nobody typed in writes nothing at all. + assert_eq!( + plan("hunter2", "hunter2", false), + PasswordPlan { + drop_old: false, + store: false + } + ); + // A new secret replaces the old one in place. + assert_eq!( + plan("hunter2", "correct horse", false), + PasswordPlan { + drop_old: false, + store: true + } + ); + // Clearing the box is how a saved password is let go of. + assert_eq!( + plan("hunter2", "", false), + PasswordPlan { + drop_old: true, + store: false + } + ); + // Retargeting the host carries the secret across and leaves nothing + // behind under the old address — even when the secret itself is + // untouched, because the account it is filed under is the address. + assert_eq!( + plan("hunter2", "hunter2", true), + PasswordPlan { + drop_old: true, + store: true + } + ); + // A host that never had one, and still does not. + assert_eq!( + plan("", "", true), + PasswordPlan { + drop_old: false, + store: false + } + ); + // The first password a host is given. + assert_eq!( + plan("", "hunter2", false), + PasswordPlan { + drop_old: false, + store: true + } + ); + } + + /// A key picked from the system dialog arrives as an absolute path under + /// the home directory. Written back that way it names the right file on + /// this machine and the wrong one everywhere else — and `~` is how the + /// rest of the field, and `~/.ssh/config` itself, spells it. + #[test] + fn a_picked_key_is_written_the_way_the_config_spells_it() { + let home = Some("/Users/ada"); + assert_eq!( + tildify_with("/Users/ada/.ssh/id_ed25519", home), + "~/.ssh/id_ed25519" + ); + // Outside the home directory there is nothing to shorten. + assert_eq!(tildify_with("/etc/ssh/key", home), "/etc/ssh/key"); + // And a sibling that merely starts with the same letters is not + // inside it. + assert_eq!( + tildify_with("/Users/adalovelace/key", home), + "/Users/adalovelace/key" + ); + // A trailing separator on the home directory changes nothing. + assert_eq!( + tildify_with("/Users/ada/.ssh/id_rsa", Some("/Users/ada/")), + "~/.ssh/id_rsa" + ); + // Nowhere to anchor against leaves the path as it came. + assert_eq!( + tildify_with("/Users/ada/.ssh/id_rsa", None), + "/Users/ada/.ssh/id_rsa" + ); + } + + /// The passphrase box is about one key at a time: the first one named that + /// is actually on disk, with `%h` and `%r` filled in the way the daemon + /// will fill them. A path that is not there can hold no passphrase. + #[test] + fn the_passphrase_follows_the_first_key_that_is_really_there() { + let dir = std::env::temp_dir().join(format!("tty7-keyform-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let real = dir.join("id_example.com"); + std::fs::write(&real, b"key").unwrap(); + let missing = dir.join("absent").to_string_lossy().to_string(); + let pattern = dir.join("id_%h").to_string_lossy().to_string(); + + assert_eq!( + first_readable_key_in(&[missing.clone(), pattern], "example.com", "ada"), + Some(real.to_string_lossy().to_string()) + ); + assert_eq!( + first_readable_key_in(&[missing.clone()], "example.com", "ada"), + None + ); + // An empty field falls back to the defaults the handshake offers — + // and a named key, even a missing one, replaces them entirely. + let defaults = || vec![missing.clone(), real.to_string_lossy().to_string()]; + assert_eq!( + first_readable_key_or(&[], "example.com", "ada", defaults), + Some(real.to_string_lossy().to_string()) + ); + assert_eq!( + first_readable_key_or(&[missing.clone()], "example.com", "ada", defaults), + None + ); + assert_eq!( + first_readable_key_or(&[], "example.com", "ada", Vec::new), + None + ); + std::fs::remove_dir_all(&dir).ok(); + } + /// The dropdown resolves a pick by its row index, so the row a mode opens /// on and the mode that row saves have to be the same one. A list that /// drifted out of step would quietly save the wrong method. @@ -9339,6 +10140,49 @@ mod gpui_tests { (app, vcx) } + /// The password is the one field on the host editor that never reaches the + /// profile — it goes to the system keychain — so the dirty check that + /// compares profiles is blind to it. Without the secret folded in, Save + /// stays greyed out over a password the user has just typed, and the only + /// way to store one is to connect and wait to be asked. + #[gpui::test] + fn a_typed_password_is_something_the_form_has_to_save(cx: &mut TestAppContext) { + crate::core::config::pin_test_config_dir(); + let (app, mut vcx) = harness(cx); + app.update_in(&mut vcx, |app, window, cx| { + app.open_settings_section(SettingsSection::Ssh, window, cx); + // A saved host that names no address, so opening its editor asks + // the keychain nothing and starts out with nothing to save. + let profile = crate::core::ssh_profile::SshProfile::new("blank"); + app.update_config(cx, |cfg| cfg.ssh_profiles.push(profile.clone())); + app.ssh_form_load(&profile, window, cx); + }); + vcx.simulate_resize(size(px(1100.), px(800.))); + vcx.run_until_parked(); + + assert!( + !vcx.update(|_, cx| app.read(cx).ssh_form_dirty(cx)), + "a form nobody has typed in has nothing to save" + ); + + let password = vcx.update(|_, cx| { + app.read(cx) + .active_settings() + .and_then(|s| s.ssh_form.as_ref()) + .map(|f| f.password.clone()) + .expect("the host editor is open") + }); + app.update_in(&mut vcx, |_app, window, cx| { + password.update(cx, |input, cx| input.set_value("hunter2", window, cx)); + }); + vcx.run_until_parked(); + + assert!( + vcx.update(|_, cx| app.read(cx).ssh_form_dirty(cx)), + "a password typed into the form is an unsaved change" + ); + } + #[gpui::test] fn enter_on_a_shortcut_search_result_opens_its_local_filter(cx: &mut TestAppContext) { let (app, mut vcx) = harness(cx); diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index a00b3943..1891a9df 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -3725,8 +3725,8 @@ mod tests { view.name = None; assert_eq!( tab_view_label(&view, 0, None), - "✳ 修复 workspace switcher", - "then the title the local strip would be showing, verbatim" + "修复 workspace switcher", + "then the title the local strip would be showing — mark and all, which is to say without the mark" ); view.osc_title = Some("user@host:~/repo/025/tty7".to_string()); diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 53749015..59a33409 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -624,8 +624,8 @@ pub(crate) fn trailing_chrome_tiles_w() -> f32 { /// Anything else drawn into that end of the title bar has to stop short of it — /// which for the hoisted document header means the case where the detail panel /// is closed and the document column runs to the window's right edge. -pub(crate) fn trailing_chrome_w() -> f32 { - trailing_chrome_tiles_w() + crate::ui::app::WINDOW_CONTROLS_W +pub(crate) fn trailing_chrome_w(fullscreen: bool) -> f32 { + trailing_chrome_tiles_w() + crate::ui::app::window_controls_w(fullscreen) } pub(crate) fn chrome_tile_sized( @@ -1777,14 +1777,15 @@ impl Tty7App { // instead: that header carries no fill of its own, and a chip left // under it showed through the file name while staying clickable. let document_w = self.document_dock_px(window, cx).unwrap_or(0.); + let controls_w = crate::ui::app::window_controls_w(window.is_fullscreen()); let strip_w = if cfg!(target_os = "macos") { (window.viewport_size().width - px(80. + panel_w + document_w)).max(px(160.)) } else { - (window.viewport_size().width - px(114.)).max(px(140.)) + (window.viewport_size().width - px(crate::ui::app::TITLE_BAR_LEAD + controls_w)) + .max(px(140.)) }; - let chrome_band_w = (!cfg!(target_os = "macos") && self.right_panel_open(cx)).then(|| { - (self.right_panel_px(window, cx) - crate::ui::app::WINDOW_CONTROLS_W - 1.).max(0.) - }); + let chrome_band_w = (!cfg!(target_os = "macos") && self.right_panel_open(cx)) + .then(|| (self.right_panel_px(window, cx) - controls_w - 1.).max(0.)); // `corner_w` reserves the trailing window chrome. With the panel open on // macOS that chrome belongs to the panel's own header, which the strip // now stops short of, so reserving for it here would charge the chips diff --git a/src/ui/windows.rs b/src/ui/windows.rs index e249ba3b..3b1c8aaf 100644 --- a/src/ui/windows.rs +++ b/src/ui/windows.rs @@ -480,6 +480,30 @@ fn open_missing_cli_window_with( announce_detached_at_launch(cx, restore); } +/// Brings the UI back when macOS relaunches a tty7 that is already running. +/// +/// AppKit only asks when it found no visible window, and that is a state +/// tty7 lives in on purpose: closing the last window with the tray icon on +/// retires to the tray, process alive and Dock icon up. A window that is +/// still there is activated rather than doubled — `activate_window` is +/// `makeKeyAndOrderFront:`, which is what orders a window AppKit left behind +/// back to the front. None at all gets the last layout back the way a +/// pathless launch and the tray's windowless path do, so the workspace that +/// retired is the one that returns and not a blank one beside it. +pub fn reopen(cx: &mut App) { + reopen_with(cx, open_at); +} + +fn reopen_with( + cx: &mut App, + open: impl FnOnce(&mut App, Option, Option), +) { + match WindowRegistry::most_recent(cx) { + Some(workspace) => activate(cx, workspace), + None => open_missing_cli_window_with(cx, None, open), + } +} + pub fn refresh_menu(cx: &mut App) { crate::ui::theme::set_menus(cx); } @@ -988,6 +1012,68 @@ mod tests { assert_eq!(opened, Some((None, None))); } + #[gpui::test] + fn a_reopen_with_no_window_up_restores_the_workspace_that_retired( + cx: &mut gpui::TestAppContext, + ) { + // The Dock icon after the last window retired to the tray: the + // process is alive with nothing on screen, and the click has to bring + // back the layout that was there, not mint a blank workspace beside it. + // The restore saves `views.json`; run alone, this test would otherwise + // write it into the real config dir. + crate::core::config::pin_test_config_dir(); + let view = WindowView::default(); + let restored = view.id; + let mut opened = None; + + cx.update(|cx| { + WindowRegistry::init(cx); + WorkspaceStore::install_for_test( + cx, + WindowViews { + views: vec![view], + active: Some(restored), + }, + ); + reopen_with(cx, |_, workspace, path| { + opened = Some((workspace, path)); + }); + }); + + assert_eq!(opened, Some((Some(restored), None))); + } + + #[gpui::test] + fn a_reopen_with_a_window_up_activates_it_instead_of_opening_another( + cx: &mut gpui::TestAppContext, + ) { + // AppKit also asks while a window is still there but off screen. That + // window is the thing to activate; a second one would take the + // restore away from it. + use gpui::VisualContext as _; + + let (app, mut vcx) = crate::ui::app::test_window::harness(cx); + let handle = vcx.window_handle(); + app.update_in(&mut vcx, |_, _, cx| { + WindowRegistry::init(cx); + let view = WindowView::default(); + let open = view.id; + WorkspaceStore::install_for_test( + cx, + WindowViews { + views: vec![view], + active: Some(open), + }, + ); + WindowRegistry::register(cx, open, handle, app.downgrade()); + + reopen_with(cx, |_, workspace, _| { + panic!("a reopen with a window up opened another for {workspace:?}"); + }); + assert_eq!(WindowRegistry::count(cx), 1); + }); + } + #[gpui::test] fn a_request_carrying_a_path_restores_the_layout_and_brings_the_path_along( cx: &mut gpui::TestAppContext,