mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-10-02 00:02:00 +00:00
Merge main and preserve SSH credential saving in settings workflows
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 <path> [<path> ...]
|
||||
# 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 <path> [<path> ...]" }
|
||||
|
||||
$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."
|
||||
@@ -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
|
||||
|
||||
@@ -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 ----------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 · <kbd>⌃ R</kbd> fuzzy history |
|
||||
| **Window** | tabs & splits · <kbd>⌘ P</kbd> palette · <kbd>⌘ F</kbd> scrollback search · <kbd>⌘ J</kbd> 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.
|
||||
|
||||
<details>
|
||||
<summary>The full support matrix, all twenty</summary>
|
||||
<summary>The full support matrix, all twenty-two</summary>
|
||||
|
||||
| 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 | ✓ | | |
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<!-- Crush's pixel heart, from charmbracelet/crush (MIT). The source mark is
|
||||
the 512x512 notification icon; its silhouette is traced to a 24x20 grid,
|
||||
centred in tty7's 24x24 icon format. The avatars tint the whole mark flat,
|
||||
so the source's magenta-on-violet shading collapses to one sentinel fill. -->
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="#FF0000">
|
||||
<rect x="3" y="2" width="7" height="1"/>
|
||||
<rect x="14" y="2" width="7" height="1"/>
|
||||
<rect x="2" y="3" width="9" height="1"/>
|
||||
<rect x="13" y="3" width="9" height="1"/>
|
||||
<rect x="2" y="4" width="9" height="1"/>
|
||||
<rect x="13" y="4" width="9" height="1"/>
|
||||
<rect x="0" y="5" width="24" height="1"/>
|
||||
<rect x="0" y="6" width="24" height="1"/>
|
||||
<rect x="0" y="7" width="24" height="1"/>
|
||||
<rect x="0" y="8" width="24" height="1"/>
|
||||
<rect x="0" y="9" width="24" height="1"/>
|
||||
<rect x="0" y="10" width="24" height="1"/>
|
||||
<rect x="0" y="11" width="24" height="1"/>
|
||||
<rect x="0" y="12" width="24" height="1"/>
|
||||
<rect x="0" y="13" width="24" height="1"/>
|
||||
<rect x="2" y="14" width="20" height="1"/>
|
||||
<rect x="2" y="15" width="20" height="1"/>
|
||||
<rect x="2" y="16" width="20" height="1"/>
|
||||
<rect x="3" y="17" width="18" height="1"/>
|
||||
<rect x="4" y="18" width="16" height="1"/>
|
||||
<rect x="6" y="19" width="12" height="1"/>
|
||||
<rect x="8" y="20" width="8" height="1"/>
|
||||
<rect x="10" y="21" width="4" height="1"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 304 304" width="304" height="304">
|
||||
<path d="M231.5 283.39C212.11 292.92 189 301.61 166.75 297.81C153.5 295.55 142.22 289.6 130.68 283.17C115.17 274.53 99.21 266.04 83.28 258.21C74.31 253.8 65.61 248.73 56.69 244.16C39.8 235.52 27.3 228.1 17.19 211.3C6.03 192.76 6.99 171.12 6.99 150.17C6.99 134.63 5.88 118.64 9.18 103.38C16.37 70.11 37.46 40.35 66.32 22.17C85.66 9.98 115.65 1.77 138.41 7.15C150.97 10.12 161.64 17.11 173.02 22.81C189.54 31.08 205.77 40.01 222.1 48.74C245.52 61.26 270.45 69.84 283.84 94.65C297.12 119.25 293.01 150.06 293.01 177.17C293.01 197.72 293.01 218.28 293.01 238.83C293.01 248.73 294.8 260.66 291.76 270.19C287.47 283.7 273.26 294.72 258.6 293.5C252.45 292.98 247.76 290.52 242.24 288.23C238.74 286.77 234.68 285.48 231.5 283.39ZM191.9 63.5C187.95 59.69 181.25 57.23 176.29 54.85C167.96 50.85 159.83 46.17 151.7 41.8C145.39 38.41 139.18 34.97 132.17 33.26C122.25 30.84 112.15 33.2 102.61 35.81C72.6 44.02 50.57 67.35 39.21 95.73C32.39 112.77 33.84 131.45 33.84 149.5C33.84 166.9 32 185.63 42.15 200.69C49.73 211.94 62.59 217.37 74.29 223.2C82.51 227.3 90.62 231.76 98.72 236.14C101.09 237.43 105.96 241.15 108.54 240.83C108.5 236.87 107.2 232.85 107.07 228.83C106.72 217.74 106.99 206.6 106.99 195.5C106.99 184.24 106.63 173.01 108.11 161.84C111.9 133.07 128.92 104.76 150.97 86.14C159.42 79 168.85 72.6 179.02 68.19C182.64 66.62 188.93 65.5 191.9 63.5ZM134.04 226.5C133.71 230.93 134.73 235.44 135.82 239.68C141.78 262.78 164.37 275.32 187.6 270.81C196.28 269.13 204.62 266.22 212.7 262.84C217.41 260.88 221.61 256.31 226.59 255.15C235.92 252.97 245.64 261.06 253.98 264.19C258.08 265.72 263.09 268.43 265.74 262.9C267.25 259.77 266.06 249.39 266.06 245.5C266.06 231.17 266.06 216.83 266.06 202.5C266.06 184.5 266.16 166.5 266.16 148.5C266.16 137.6 267.14 125.92 263.79 115.4C257.07 94.29 236.49 81.97 214.51 85.73C206.58 87.08 198.51 89.11 191.17 92.27C190.26 96.24 190.94 101.07 190.94 105.17C190.94 117.19 191.88 129.73 189.82 141.62C185.49 166.6 174.02 189.29 156.68 207.84C151.56 213.31 145.67 218.38 139.59 222.76C137.8 224.05 135.67 225.04 134.04 226.5ZM163.5 111C147.55 126.96 134.79 149.54 134 172.5C133.84 177.17 133.84 181.83 133.84 186.5C133.84 188.15 133.3 190.79 134.5 192C150.7 175.79 163.86 153.06 163.99 129.5C164.01 126.31 165.05 112.87 163.5 111Z" fill="#0f0d0c" fill-rule="evenodd" stroke="#0f0d0c" stroke-width="0.25" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -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(
|
||||
|
||||
@@ -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::<serde_json::Value>(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.<Event>`, 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<String> {
|
||||
| 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()));
|
||||
|
||||
@@ -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<std::path::PathBuf>,
|
||||
#[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::<Vec<_>>();
|
||||
|
||||
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]
|
||||
|
||||
@@ -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<String>),
|
||||
}
|
||||
|
||||
#[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<String, String>,
|
||||
/// 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<String, KeybindingOverride>,
|
||||
#[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<HashMap<String, KeybindingOverride>, 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<T, D::Error>
|
||||
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();
|
||||
|
||||
@@ -147,6 +147,104 @@ pub fn parse_notification(payload: &[u8]) -> Option<(Option<String>, 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<String> {
|
||||
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!(
|
||||
|
||||
@@ -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<TabView> {
|
||||
|
||||
#[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()),
|
||||
|
||||
@@ -938,6 +938,11 @@ struct ClientInner {
|
||||
blobs: Mutex<HashMap<u64, Vec<u8>>>,
|
||||
connected: AtomicBool,
|
||||
last_inbound: Mutex<Instant>,
|
||||
/// 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<Arc<dyn LinkShutdown>>,
|
||||
reader_done: Mutex<bool>,
|
||||
@@ -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<Duration> {
|
||||
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<ReplyOk> {
|
||||
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())),
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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<std::time::Duration> {
|
||||
None
|
||||
}
|
||||
|
||||
fn separator(&self) -> char;
|
||||
|
||||
fn join(&self, dir: &Path, name: &str) -> PathBuf {
|
||||
|
||||
@@ -253,6 +253,22 @@ impl Host for RemoteHost {
|
||||
})
|
||||
}
|
||||
|
||||
fn link_rtt(&self) -> Option<std::time::Duration> {
|
||||
// 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
The hooks only do anything inside tty7. Running the same agent in another
|
||||
terminal is unaffected.
|
||||
|
||||
@@ -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 — <kbd>⌃ B</kbd> then <kbd>X</kbd> |
|
||||
| <kbd>Esc</kbd> | Cancel |
|
||||
| <kbd>⌫</kbd> | Remove the last key — or, pressed first, reset the shortcut to its default |
|
||||
| <kbd>⌫</kbd> | 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: <kbd>⌥ 1</kbd>…<kbd>⌥ 9</kbd> 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 — <kbd>⌃ ⇥</kbd> 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Note>
|
||||
The Windows package also carries a Linux `tty7-server` binary so a WSL
|
||||
distro can be served locally instead of downloading one. See
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ something floods the screen.
|
||||
syntax highlighting, click-to-place-caret, real multi-line editing.
|
||||
</Card>
|
||||
<Card title="Agent-aware" icon="robot" href="/agents/overview">
|
||||
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.
|
||||
</Card>
|
||||
<Card title="Remote work that feels local" icon="server" href="/remote/workspaces">
|
||||
|
||||
+144
-1
@@ -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<T: serde::de::DeserializeOwned>(
|
||||
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<T: serde::de::DeserializeOwned>(
|
||||
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::<GitHubError>(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::<u64>().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::<Vec<_>>().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(),
|
||||
|
||||
+26
-8
@@ -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::<Config>()
|
||||
.keybindings
|
||||
.get("SplitRight")
|
||||
.map(String::as_str),
|
||||
Some("ctrl-alt-9"),
|
||||
cx.global::<Config>().keybindings.get("SplitRight"),
|
||||
Some(&crate::core::config::KeybindingOverride::Add(
|
||||
"ctrl-alt-9".to_string()
|
||||
)),
|
||||
"the running config survives the broken file"
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
+221
-4
@@ -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<Option<(u32, u32, Option<u32>)>> =
|
||||
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::<crate::terminal::palette::ActivePalette>() {
|
||||
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::<Config>()
|
||||
.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 {
|
||||
|
||||
+82
-1
@@ -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<Ve
|
||||
|
||||
if m.control && !m.platform {
|
||||
if let Some(b) = ctrl_c0(key) {
|
||||
if b == b'\n' && !m.alt && !m.shift {
|
||||
return Some(flags.legacy_newline_bytes().to_vec());
|
||||
}
|
||||
if m.alt {
|
||||
return Some(vec![0x1b, b]);
|
||||
}
|
||||
@@ -550,6 +573,7 @@ mod tests {
|
||||
KeyFlags, defer_to_ime, keystroke_to_bytes, meta_chord_bypasses_ime,
|
||||
reshape_option_keystroke, tab_bytes,
|
||||
};
|
||||
use alacritty_terminal::term::TermMode;
|
||||
use gpui::{Keystroke, Modifiers};
|
||||
|
||||
fn full_mode() -> 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!(
|
||||
|
||||
+287
-34
@@ -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<AgentSessionState>,
|
||||
/// `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<Mutex<Option<PathBuf>>>,
|
||||
shell: Arc<Mutex<ShellState>>,
|
||||
remote: Arc<Mutex<Option<RemoteContext>>>,
|
||||
agent: Arc<Mutex<Option<CLIAgent>>>,
|
||||
agent_session: Arc<Mutex<Option<AgentSessionState>>>,
|
||||
agent_session: Arc<Mutex<AgentSlot>>,
|
||||
/// 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<AtomicBool>,
|
||||
child_exited: Arc<AtomicBool>,
|
||||
zle_reading: Arc<AtomicBool>,
|
||||
@@ -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<AtomicBool>,
|
||||
local_conpty: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
auto_supplied_password: bool,
|
||||
agent: Arc<Mutex<Option<CLIAgent>>>,
|
||||
agent_session: Arc<Mutex<Option<AgentSessionState>>>,
|
||||
agent_session: Arc<Mutex<AgentSlot>>,
|
||||
/// 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<AtomicBool>,
|
||||
/// 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<AtomicBool>,
|
||||
local_conpty: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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<Self> {
|
||||
@@ -936,6 +975,22 @@ impl RemoteTerminal {
|
||||
size: TermSize,
|
||||
buffered: Vec<u8>,
|
||||
pty: PtySource,
|
||||
) -> anyhow::Result<Self> {
|
||||
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<u8>,
|
||||
pty: PtySource,
|
||||
awaiting_replay: bool,
|
||||
) -> anyhow::Result<Self> {
|
||||
let read_half = stream.try_clone()?;
|
||||
let write_half = stream;
|
||||
@@ -955,7 +1010,7 @@ impl RemoteTerminal {
|
||||
let shell_state: Arc<Mutex<ShellState>> = Arc::new(Mutex::new(ShellState::default()));
|
||||
let remote_context: Arc<Mutex<Option<RemoteContext>>> = Arc::new(Mutex::new(None));
|
||||
let agent: Arc<Mutex<Option<CLIAgent>>> = Arc::new(Mutex::new(None));
|
||||
let agent_session: Arc<Mutex<Option<AgentSessionState>>> = Arc::new(Mutex::new(None));
|
||||
let agent_session: Arc<Mutex<AgentSlot>> = 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<u8> = buffered;
|
||||
@@ -1158,6 +1227,25 @@ impl RemoteTerminal {
|
||||
|
||||
let mut out_batch: Vec<u8> = 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<AgentSessionState> {
|
||||
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<Option<AgentStatus>> {
|
||||
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();
|
||||
|
||||
+104
-14
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1124
-40
File diff suppressed because it is too large
Load Diff
+295
-28
@@ -802,6 +802,7 @@ pub struct Tty7App {
|
||||
pub(crate) font_family: String,
|
||||
pub(crate) font_family_bold: Option<String>,
|
||||
pub(crate) font_family_italic: Option<String>,
|
||||
pub(crate) font_fallbacks: Vec<String>,
|
||||
pub(crate) font_features: Option<gpui::FontFeatures>,
|
||||
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<WorkspaceId>,
|
||||
@@ -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::<FullscreenHint>(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::<FullscreenHint>()
|
||||
.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::<Config>();
|
||||
(
|
||||
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<String>)> = 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>) {
|
||||
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>) {
|
||||
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::<Config>().keybindings.get(action).cloned());
|
||||
if got.as_deref() == Some(expected) {
|
||||
let got = vcx.update(|_, cx| {
|
||||
serde_json::to_value(cx.global::<Config>().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::<Config>().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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
+22
-10
@@ -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(),
|
||||
|
||||
+30
-6
@@ -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",
|
||||
|
||||
+34
-6
@@ -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 => "閉じたタブをもう一度開く",
|
||||
|
||||
+21
-3
@@ -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
|
||||
|
||||
+22
-6
@@ -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 => "重新打开已关闭的标签页",
|
||||
|
||||
+336
-38
@@ -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::<Config>();
|
||||
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::<Config>();
|
||||
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<String>,
|
||||
configured: Vec<String>,
|
||||
}
|
||||
|
||||
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<String, KeybindingOverride>,
|
||||
) -> Vec<ActionChords> {
|
||||
let mut resolved: Vec<ActionChords> = 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<String>| {
|
||||
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::<Config>();
|
||||
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<String>)> {
|
||||
let cfg = cx.global::<Config>();
|
||||
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::<Config>();
|
||||
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::<Vec<_>>()
|
||||
};
|
||||
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::<Config>().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::<Config>()
|
||||
.keybindings
|
||||
.insert("RenameTab".to_string(), "ctrl-shift-r".to_string());
|
||||
cx.global_mut::<Config>().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<Keystroke> = 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::<Config>()).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| {
|
||||
|
||||
+30
-1
@@ -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<u32> {
|
||||
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
|
||||
}
|
||||
|
||||
+103
-4
@@ -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<std::time::Duration>,
|
||||
/// 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<crate::ui::host_ops::HostId>,
|
||||
/// 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
|
||||
|
||||
@@ -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};
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+939
-95
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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());
|
||||
|
||||
+7
-6
@@ -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
|
||||
|
||||
@@ -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<WorkspaceId>, Option<std::path::PathBuf>),
|
||||
) {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user