242 Commits
Author SHA1 Message Date
l0ng-ai d07850a98e Merge pull request #887 from l0ng-ai/fix/868-additive-keybindings
fix(keymap): a keybinding in config adds a chord instead of replacing the default (#868)
2026-09-15 23:26:50 +08:00
l0ng-ai 436ea04f33 fix(keymap): a keybinding in config adds a chord instead of replacing the default (#868)
effective_bindings kept one chord per action and set_binding overwrote that
slot, so "NextTab": "cmd-shift-]" silently took Ctrl+Tab away.

A string in keybindings now adds a chord beside the action's default (or
preset) chord; "" still unbinds, as configs and the docs already rely on; a
list is the exact chord set, [] unbinds. Configured chords are installed after
every shipped one, so a chord the user names wins a tie with another action's
default. The Settings page lists every chord of an action, and recording a
shortcut writes the list shape (it sets the binding) and takes only the stolen
chord from the action that had it.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-14 21:48:19 +08:00
l0ng-ai ef55505a6a fix(agents): don't re-badge a turn the reader already saw when a pane is rebuilt
Switching workspaces or reopening a window from the tray throws a pane's
TerminalView away and builds a new one over the same daemon pane. The new
view starts with no last status, so an agent that was already Done arrives
as None -> Done, which poll_agent_status cannot tell from a turn finishing
live, and every unfocused rebuilt pane got its unread badge back.

The daemon now counts finished turns per agent session (turns, bumped on
entering Done), and views leave an app-lifetime mark per (host, pane) of the
session, turn count and badge the reader was last shown. A rebuilt view's
first sight of Done takes the badge back from a matching mark instead of
raising a new one; a turn that finished while the view was gone has no mark
or a lower count, and still badges.

Fixes #870

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-14 21:45:15 +08:00
l0ng-ai 37c2ef2176 test(keymap): pin additive keybinding semantics (#868)
Regression tests for issue #868: a chord added in config.json must join the
action's default chord rather than replace it, an empty string or list must
still unbind, a list replaces the chord set, the tmux preset composes, a user
chord wins a tie with another action's default, and Settings recordings write
the exact-set shape.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-14 21:40:42 +08:00
l0ng-ai 611c6030a4 Merge pull request #881 from akhenakh/crush
added support for Crush agent
2026-09-14 18:56:50 +08:00
l0ng-ai 85b4450756 fix(agents): keep Crush's PreToolUse from opening a turn it can never close
Crush fires only PreToolUse, and it mapped to prompt-submit. With no Stop
behind it the pane stayed on Working until Crush exited: every close asked
whether to cut Crush's work short, the tray and dot stayed on working, and
fork warned mid-turn. Map it to tool-complete, which still records
session_id/cwd for resume and bumps activity without moving the status.
2026-09-14 18:31:59 +08:00
l0ng-ai 2de26bbf35 fix(pi): restore the pre-prompt status when a UI prompt closes
ui_prompt_end sent prompt-submit unconditionally, but Pi also opens
prompts while idle (/model, a command's select). Closing one left a
finished or fresh pane reading "working" with no agent_end to clear it.
Remember the last turn event and re-emit that instead.
2026-09-14 18:31:45 +08:00
Fabrice Aneche 3fefb647e1 test(agents): isolate Crush hook test from XDG_CONFIG_HOME 2026-09-13 20:59:42 -04:00
netcatty a8da265087 Add Pi UI-prompt events so the pane reports waiting-for-user
The shared Pi / Oh My Pi bridge only subscribes to four lifecycle events, so
a pane running Pi never shows "waiting for you" while a dialog is open —
`ask_user_question`, permission gates and any other extension prompt all run
under the hood with the status dot still reading "working".

tty7 already has the vocabulary for this (`question-asked`,
`permission-request`), and `AgentEventKind::QuestionAsked` /
`PermissionRequest` already map to `AgentStatus::Waiting` in
`cli_agent.rs`. The Pi extension seam to feed them is
`pi.on("ui_prompt_start")`, which fires around every blocking user-facing
prompt with `event.kind` telling select / confirm / input / editor / custom
apart.

Two handlers, each guarded on its own so an Oh My Pi fork that does not
expose the hook loses only that event rather than the whole bridge:

- `ui_prompt_start` → `permission-request` for `kind === "confirm"` (a
  permission or destructive-action gate), `question-asked` otherwise.
- `ui_prompt_end` → `prompt-submit` so the status returns to working once the
  dialog closes. Without it the pane would stay on "waiting" until the next
  stop, which is wrong for the model's continued work after an answer.

Deliberately not included: `tool-complete`. The Pi bridge emits with
`spawnSync`, so one event per tool call would block the extension host for
the duration of a process spawn on every read/grep/edit.

The test that asserts the bridge's subscriptions now covers both new event
names.

Verified: the `format!` template still compiles and renders both new
handlers, and the bridge contains every string the test asserts.
2026-09-13 15:47:07 +08:00
Fabrice Aneche c2d2db2cfc added support for Crush agent 2026-09-11 21:56:22 -04:00
l0ng-ai 66c42e48b7 Merge pull request #873 from WhiteDG/feat/qodercli
feat(agents): add Qoder CLI integration
2026-09-11 15:00:07 +08:00
l0ng-ai 63b3951ef3 fix(agents): detect Qoder through the binary its docs tell you to run
The npm package installs two binaries. `qoder` is a dispatcher that routes
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 — and it
is the one the documentation tells people to run. Both are
`#!/usr/bin/env node` scripts, so what the pty carries is node plus the path
to the shim; the dispatcher's child, where `qodercli` appears on the path,
is not the process group leader and is never read. Detecting `qodercli`
alone missed every session started the documented way.

An IDE launch now wears the CLI's avatar for as long as the launcher takes
to exit, which is the cost of covering the common case.

Also: `--session-id` restores a session rather than naming a new one, so say
that where the flag is stripped, and assert Qoder has no `Notification` seat
instead of putting a payload through a hook map that has none.

Claude-Session: https://claude.ai/code/session_01LAqfzqELnoDWU56LBXS1Nh
2026-09-11 14:38:40 +08:00
White 9e338b1d60 feat(agents): add Qoder CLI integration
Hook events map Qoder's lifecycle to tty7's state machine: session start,
prompt submit, permission requests, MCP tool elicitation (an authorized MCP
tool can still pause for user input mid-call), tool completion, stop, and
session end. Compaction events are filtered out—Qoder emits a session-start
after compacting the active turn, which would reset the status line to Idle
without this filter, even though the turn is still running.

Settings path resolution respects QODER_CONFIG_DIR for local installs,
falling back to ~/.qoder/settings.json. Remote targets ignore the override
(a local env var must not redirect remote hooks).

Session commands support --resume and --fork-session. The resume command
strips conflicting flags (--resume, -r, --continue, -c, --session-id,
--worktree, --fork-session) from the original launch argv before appending
the new session id. The -w/--cwd flags survive (Qoder's -w means --cwd,
not --worktree). Both commands require session persistence: when
--no-session-persistence is present, there is no saved conversation to
reopen, so the commands return None.

Tests cover compaction preservation, MCP elicitation state transitions,
QODER_CONFIG_DIR's effect on the hook lifecycle (multi-case isolation),
resume/fork command generation, worktree flag handling, and persistence
requirements.

Localization complete for en/ja/zh. Icon embedded, search keywords wired.
2026-09-11 10:30:19 +08:00
hhdebb 56238bf3bb fix(tabs): take the agent's status mark off the title it writes (#847)
Agents animate in the terminal title while they work, and they 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 at all. 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.

So the mark comes off, for everyone, with no setting. A switch would not
settle this: nobody opens settings to decide how a spinner is drawn, and
a default-off toggle buys two render paths to maintain forever in order
to answer a question that has one right answer per person and no way for
the app to know which.

**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 `🔥 build`, or `📁 ~/repo` written by somebody's shell
integration, fits it exactly and quietly loses its first character with no
way to ask for it back and no clue as to what took it. Matching marks we
have actually seen costs the same and cannot do that: the braille block,
the four quadrant circles, and Claude Code's resting asterisk. When an
agent invents a mark that is not on the list, 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.

Two things the rule insists on, both to keep it from reaching past what
it is for. A mark only counts with whitespace behind it, so `✳fixing` is
a word that starts with a character rather than a mark in front of one.
And a title that is *only* a mark keeps it: 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 — less than the mark was saying.

It happens in `TabView::label`, which is where a title becomes a label, so
the strip, the sidebar, the switcher and the rename box's prefill all
agree without being told separately — and, because `label` reaches a
given name before it reaches the title, a tab somebody deliberately
called `✳ release` keeps what they called it. That ordering is the only
thing standing between a user's name and a rename behind their back, so
there is a test on it rather than a comment. Three existing tests carried
`✳` in their fixtures and now expect it gone. The one in `switcher.rs` was
asserting that a tab in another window is named the way a local one would
be, which is still exactly what it asserts; the one in `tty7-cli` is the
table getting this for free, since `tab_label` reads `label` and so
`tty7 ls` says what the tab strip says without either being told about the
other. The daemon's fixtures keep their marks on purpose: a title is stored
as the terminal wrote it, and only what turns one into a label takes
anything off.

This leaves the row with nothing moving in it, which is a real loss and is
answered separately: `AgentStatus::dot_rgb` returns three flat colours,
and a `Working` dot that breathes says the same thing in the vocabulary
the row already speaks.
2026-09-11 01:10:21 +08:00
l0ng-ai ab26166f96 fix(review): keep pane_procs's doc on pane_procs, round rtt before the unit
The new `link_rtt` landed between `pane_procs`'s doc comment and
`pane_procs` itself, so the comment about walking pane process trees
documented the latency probe instead.

`format_rtt` also compared the unrounded milliseconds against 1000, so a
999.6 ms round trip printed as "1000 ms" — a millisecond reading past
the range the millisecond branch exists to cover. Round first, then pick
the unit.

Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER
2026-09-10 18:19:39 +08:00
l0ng-ai ac36d4a4fa feat(ui): say how far away a remote pane's shell is
The Session table named the machine a pane's shell was on but never the
distance to it. A remote workspace gone slow looked exactly like one that
had not, and the only way to tell them apart was to leave tty7 and ping
the box by hand — with nothing to say whether tty7's own link was the one
that was slow.

Time the control link's `Ping` and put the last measurement on a `latency`
row, drawn only where there is a network between here and the shell. Every
ping that comes back feeds it, the keepalive's included, so a link being
kept alive already carries a number before anyone asks for one. Nothing
else is timed: every other request does work on the far side, so its round
trip measures that work rather than the link, and a `ReadFile` of a large
file would read as a network seconds slow.

The poll rides the Info panel's existing process-and-port round, and only
while the panel is open — that round also runs with the panel shut,
watching for ports to forward, and a round trip for a row nobody can see
is the far end's time spent on nothing. A link that drops keeps its last
measurement rather than blanking: a dropped link is exactly when someone
is reading the row to work out why a pane stopped answering.

Claude-Session: https://claude.ai/code/session_01FG2s9mbZu6LbjjmU54X7kt
2026-09-10 18:06:21 +08:00
l0ng-ai a88c49904c Merge branch 'main' into fix/wait-free-remote-panes
#731's `PortProbe` and #774's `TerminalModes` both landed on main while this was
open, each adding a field beside one this branch adds — `PaneProcs.probe` next to
`context`, `PaneState.modes` next to `remote_prompt_seen` — so every struct
literal for the two conflicted without either side being wrong. Both fields are
kept everywhere. `procinfo::snapshot` is #731's rewrite, with `context: None`
moved onto the `finish` helper that now builds the reply; the comment saying why
only the pane can fill it comes along.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 15:01:01 +08:00
l0ng-ai 5c387e2ba4 Merge pull request #830 from l0ng-ai/fix/731-port-probe-honesty
fix(procinfo): stop the listening-port probe failing silently (#731)
2026-09-10 14:34:54 +08:00
l0ng-ai 104268ba81 Merge pull request #828 from l0ng-ai/fix/774-pty-source-and-replayed-modes
fix(terminal): a per-pty parked-cursor repair, and modes restored on re-attach (#774)
2026-09-10 14:34:00 +08:00
l0ng-ai 93e804b20e Merge pull request #824 from l0ng-ai/perf/ssh-prove-server-once-per-connection
perf(ssh): prove the remote server once per connection, not once per pane (#695)
2026-09-10 14:28:51 +08:00
l0ng-ai 76c607a8a9 Merge branch 'main' into fix/wait-free-remote-panes
#711 landed on main while this was open and added two `ShellState` literals to
pane.rs's tests, which `mark_at_prompt` makes incomplete — the merge did not
compile on any target even though both sides did. The marks those two tests
stand up say "at a prompt", which is exactly the reading the new field carries,
so both get `mark_at_prompt: true` and the replay gate they exercise is
untouched. `cli_e2e`'s test table took both sides' new entries.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:25:06 +08:00
l0ng-ai 2ec515c206 Merge pull request #831 from l0ng-ai/fix/716-remaining-three
fix: the three remaining items of #716 — the misdirected rename, the missing adopt verb, the absent tree backup
2026-09-10 14:19:07 +08:00
l0ng-ai 0381c0270c Merge remote-tracking branch 'origin/main' into review-828
`main` dropped the agent-turn cuts from the reader's `flush_batch!` and
added `foreground_command` to `replay_state`; both are kept, with the
per-pty `repair_cursor` flag replacing `REPAIR_PARKED_CURSOR` in the
simplified cut loop and the mode restore ahead of the new signature.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:13:53 +08:00
l0ng-ai 8c508a5282 fix(ssh): hold the note's lock across a restart or a replace
The memo made the connection's `proved_server` slot both the note and the
install gate, and `ensure_remote_server` holds it across the probe for a
reason the two operations that change the server share: a pane arriving
mid-install must wait rather than upload over the file the winner is
renaming into place. `restart_remote_daemon` and `replace_remote_server`
only cleared the note and then let go, so a pane opening during a replace
still probed against a half-moved binary — and now *kept* that answer for
the life of the connection, where before the memo it cost that one pane
and no other. The sharp end is the mismatch the note re-files on every
hit: probe the machine while the replace has uploaded but not yet
restarted, and the outgoing daemon is filed as a mismatch that every
later pane on that connection hears about, long after the replace the
user asked for fixed it. Both now run inside the guard, the note dropped
first, the way `wsl::replace_wsl_server` holds its distro's install lock.

That makes the hold as long as a replace, so the router's forget on a
silently closed link moves off the thread polling the route: it took the
same non-reentrant lock, and waiting there kept the client's half of a
link that was already gone open for as long as whatever held it.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:03:37 +08:00
l0ng-ai 8ee9474045 fix(daemon): leave the ring the modes it still carries
`replay_state` sends the mode fold ahead of the ring so the replayed
frames land in the buffer they were drawn for, and it sent every mode
that was on — including the ones the ring itself still switches on.
`?1049h` is a no-op in the emulator once the mode is already set, so the
ring's own copy then stopped clearing the alternate screen: everything
the ring holds *ahead* of that sequence, which is the shell scrollback
the user had behind the program, was painted into the alternate buffer
instead. That buffer keeps no history, so those lines were thrown away,
and the primary buffer the program's exit returns the client to was left
empty. Reconnecting a minute after opening `vim` is the ordinary case,
and it lost the prompt the user left behind.

Only the modes a replay of the ring cannot speak for go ahead of it now,
and they are read from a fold over the bytes the ring actually still
holds — so a sequence the front cut in half counts as lost, exactly as
it will for the emulator that reads the same bytes.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:02:13 +08:00
l0ng-ai 16b663a407 fix(procinfo): report a Windows table that would not answer, and stop crying wolf
Three things the new probe verdict still got wrong.

`GetExtendedTcpTable` refusing came back as an empty buffer, which read
exactly like a family with no listeners, so the Windows branch answered
`Ok` about ports it had never looked for — the silence #731 is about, on
the platform the panel was written on. `tcp_table` says `None` now, and
only "neither family answered" is `Unavailable`: a machine with IPv6 off
keeps its `Ok` and its IPv4 ports.

The `Unavailable` warning was written on every `QueryProcs`, and the
Info panel sends one every two seconds — thirty identical lines a minute
into a log that truncates itself at 4 MiB, which costs a reporter the
rest of the session they turned logging on to capture. Same reason, once
a minute.

And `Restricted` on Linux took `/proc/<pid>`'s owner for the process's
uid. The kernel hands that directory to root whenever it clears a
process's dumpable attribute, which is what executing a set-user-ID
binary or one carrying file capabilities does, so a plain `ping` in a
pane had the panel apologising for sockets it can read perfectly well —
the opposite mistake, and just as wrong. The `Uid:` line of
`/proc/<pid>/status` settles the few rows that look foreign and are in a
pane's tree, so an ordinary pane pays nothing for it.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:02:08 +08:00
l0ng-ai aa82dfade0 Merge pull request #827 from l0ng-ai/fix/ssh-repeating-password-prompt-820
fix(ssh): stop the password prompt coming back after a reconnect (#820)
2026-09-10 13:58:52 +08:00
l0ng-ai a249379bf8 fix(machine): format the three items and stop reading the tree per persist
`rustfmt` is a required check and was red on three hunks of this branch:
`owner_of`'s signature, the `--cwd` argv in `tab_new`'s adopt test, and the
two `tab_create` calls in `the_document_being_replaced_is_kept_beside_it`.

`keep_a_generation` also read the whole document before asking whether it
was going to keep anything. It runs on every persist — pane facts alone
flush every couple of seconds — and answers "too soon" on almost all of
them, so that was a full read of `machine.json` per write to produce one
copy every five minutes. The spacing check moves ahead of the read; the
`NotFound` arm still covers the machine that has never written a tree.

And a line continuation was missing from an assertion message in
`a_typed_name_waits_for_the_create_rather_than_racing_it`, so the failure
would have printed eighteen spaces mid-sentence.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 13:57:54 +08:00
l0ng-ai dcb3cd5eee fix(daemon): keep the newest mark's reading when suppression collapses a batch
`suppress_relayed_prompt_marks` compares everything but `mark_at_prompt`, so
that two marks the local editor can no longer tell apart still collapse into
one `Prompt` message. But `dedup_by` keeps the *earlier* of the pair, and a far
shell's whole turn can arrive in a single read: the previous command's `D`, the
prompt's `A`/`B`, then the next command's `C`. With `at_prompt` cleared across
the batch those two entries differ only in the mark's own reading, and the `C`
was the one being dropped — leaving `mark_at_prompt` standing at `true` while a
remote command was starting, which `PaneContext::at_prompt` reports and
`pane_freeness` answers `free` from.

The reverse order loses the prompt instead: a command that starts and finishes
inside one read collapses onto its own `C`, and the pane reads busy for as long
as it takes some unrelated output to arrive — which on an idle prompt is never.

Both need the same thing, so carry the reading onto the survivor before the
later entry goes. `command` only tells the pair apart when the far shell names
it; nushell's integration and several third-party ones emit a bare `133;C`.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 13:56:15 +08:00
l0ng-ai 2805478e44 Merge pull request #832 from l0ng-ai/fix/711-replayed-prompt-scrubs-the-alt-screen
fix(daemon): stop a replay claiming a prompt a running program contradicts (#711)
2026-09-10 13:55:31 +08:00
l0ng-ai 48d712143e fix(cli): answer wait --until free on remote and SSH panes (#840)
`pane_is_free` read the pane's local process tree and folded two very
different answers into one `false`. On a pane that is only the near end
of a connection that tree describes the tunnel: a pane routed to a
remote daemon has no local pty at all, so `DaemonPane::procs` returned
`Default::default()` and the tree came back empty; a pane whose shell is
running `ssh` has a tree whose depth-1 process is the `ssh` itself, busy
for exactly as long as you are logged in. Either way `free` was
unreachable structurally, `seen_busy` was set on every poll — which
suppressed the one hint that would have pointed at the gap — and the
wait rode the whole `--timeout` before recommending `--until free`, the
flag that had just failed.

Freeness is now three-valued: free, busy, or "nothing here can answer",
and the last one carries its reason.

On a remote pane freeness is the far shell's own OSC 133 prompt marks.
That is sound because the near shell cannot be at a prompt while the
connection owns its pty, so a prompt mark on such a pane can only have
come from the far side. Two things had to change for the daemon to be
able to say it. The reader suppresses relayed prompt marks so a
foreground program cannot engage the local line editor — right for the
editor, and precisely wrong here — so `ShellState` now keeps the mark's
own unsuppressed reading beside the editor's. And "not at a prompt" on a
remote pane means nothing until the far shell has proved it reports at
all, since the newest mark is otherwise the near shell's own "I started
`ssh`", which nothing will ever supersede; a latch records the first
prompt mark that arrives while the pane is remote, and is cleared on
every hop. `PaneProcs` carries all of this to the CLI in a new optional
`context` — remote target, whether this machine holds the pty, the
mark's reading, the latch — so the answer still costs one request per
poll and an older server, which omits the field, keeps today's
tree-only behaviour.

When the far host has no shell integration the honest answer is that
this machine cannot tell an idle remote prompt from a running remote
command. `wait` says so — exit 1, `status: unknown`, a `free_unknown`
string naming the host — after one poll of grace for a handshake still
in flight, and only when `free` was the only state that could still
answer, so `--until done,free` keeps waiting on `done`. Without that it
would hang forever on a wait with no `--timeout`. The `no-agent` timeout
hint now fires only for a caller who did not already pass `--until
free`, and the reason freeness never resolved is printed and put in the
JSON in its place.

Deliberately left alone: on a local pane the process tree still holds
the verdict. A prompt mark can only turn a busy tree into free — which
is what fixes a plain `ssh` pane on Windows, where the daemon has no way
to name the pane as remote — never the other way round, so no pane that
reads busy today can start reading free because an integration went
quiet. `free` therefore now means "will take input" rather than strictly
"back to the bare shell": a pane sitting at a nested shell's prompt is
free, and the reference says so. The handoff record is unchanged, so a
pane mid-`ssh` that survives an exec comes back reporting "cannot
determine" until the far side's next prompt rather than guessing.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-10 11:54:46 +08:00
l0ng-ai b559ae658d fix(daemon): clear the dead TUI's input modes on restore (#850)
A restored pane came back with the killed program's mouse reporting still on,
so every pointer move over it was typed into the new shell's line as an SGR
report and the line grew for as long as the pointer stayed there.

The chain is short and each link is deliberate. A snapshot is a raw byte
stream, not a rendered grid, and `scrollback.rs` says so in its own module doc.
`ReplayRing::seeded` puts those bytes into the restored pane's ring verbatim,
and the client hands them straight to its parser at `DaemonMsg::Snapshot`. So
the dead program's `?1002h` is not a description of a screen, it is an
instruction, and it is executed a second time against a shell that never asked
for it. There is nothing after it to undo it: `DaemonPane::kill` is a hangup
with no grace period, so the program never emitted its own `?1002l`, and the
snapshot was photographed before the kill anyway. `restore_preamble` already
exists to keep exactly this kind of leftover away from the incoming shell — it
resets the alternate screen, the cursor, autowrap and the graphic rendition —
and it simply stopped short of the modes that make the terminal talk back.

It now also turns off mouse reporting and its encodings, focus reporting,
bracketed paste, application cursor keys and the kitty keyboard flags.

Unconditionally, which was the choice worth making. The alternative was to fold
the snapshot's bytes through a mode tracker and clear only what the fold says is
still on, which is what #828 does for the neighbouring re-attach path. That
precision is essential there and buys nothing here: there the pane is alive and
re-sending a mode it had turned off would be wrong, whereas here the target
state is a constant. The incoming shell is brand new, it has not written a byte
when the preamble is appended, and it asked for none of these modes, so there is
nothing to preserve and no mode whose value has to be discovered. The risk is
not symmetric either. Switching off a mode that is already off is a no-op in
every emulator, so the blunt version cannot fail; a fold that misparses one
sequence leaves the mode on and the bug exactly as it is today, silently. The
blunt version also needs nothing from #828, so this can merge in either order.

Left alone on purpose: `?1007` alternate scroll, which a default terminal has
on, so clearing it would walk away from the default rather than back to it;
`?2026` synchronised update, which the client's processor already closes out
when a replayed frame ends inside one; and `stale_mode_resets` in the client,
whose conditional shape is right for the live pane at a prompt that it serves.
Scrolling regions and origin mode are a different failure with no report behind
them and are not touched.

The test that locked in the short reset list now locks in the long one, and
asserts the `?1007` omission so it stays deliberate. A second test runs the
real chain — a snapshot ending inside a full-screen program, `ReplayRing::seeded`,
the preamble — and asserts that the ring a client would replay has `l` as its
last word on every reporting mode. Both fail on the previous preamble.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-10 11:23:25 +08:00
l0ng-ai b90b903527 fix(daemon): stop a replay claiming a prompt a running program contradicts (#711)
A pane that came back from a workspace switch came back empty. All that
was left was the shell banner and the `claude --resume … --fork-session`
line the pane was born with; the agent above it was still running and
still painting, but only in fragments, into an otherwise blank screen.
Switching tabs did nothing. Resizing the window fixed it, and only for
the tab that had focus, which had to be repeated tab by tab.

Nothing is lost on the wire. The replay arrives whole — every segment,
every byte — and is applied. What happens is that the client throws the
result away three frames later.

`replay_state` closes an attach by reporting the pane's shell state, and
a client that hears `active && at_prompt` scrubs the TUI modes it finds
in its grid, the alternate screen first (`stale_mode_resets`). For a
live report that is sound: a shell that is prompting cannot have a
full-screen program underneath it, so a `?1049h` still set in the grid
is residue from one that died without its `?1049l` — an `ssh` dropped
mid-`vim` — and sending the `?1049l` is the repair.

For a replayed report it is not sound. The alternate screen the scrub
finds there is the one the ring has just rebuilt, and `?1049l` does not
undo a stale mode: it swaps a live screen away and puts the primary
screen back in its place, which for an agent pane is precisely the
banner and the launch command. The program never learns any of this. It
goes on sending differential updates — only the cells it believes
changed — into a grid that no longer holds what those updates are
differences from, which is why the status line came back as fragments
with the middle blank. And a resize is the one thing that repairs it,
because a resize is the one thing that reaches the child: new geometry,
`SIGWINCH`, a full repaint. A tab switch reports geometry the daemon
already has, so it repairs nothing.

The stale claim comes from `st.shell.at_prompt`, which is only ever as
fresh as the last OSC 133 mark the pane produced. A shell that printed
its prompt (`133;B`) and then handed the terminal to a program that
sends no `133;C` of its own leaves that flag set for as long as the
program runs. The live path already declines to believe such a mark: the
pane reader drops `at_prompt` from any prompt mark that arrives while a
foreground command owns the pty. The replay was the one place that
re-asserted the stored value with no check at all.

It now asks the pty the same question, once, before it takes the state
lock, and reports a prompt only when nothing but the shell owns the
terminal. That keeps the scrub's purpose intact — when the shell really
is prompting there is no foreground command, the report goes out
unchanged, and a genuinely stranded alternate screen still heals on
reattach — while removing the case where the report contradicts a
program that is still there.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:20:14 +08:00
l0ng-ai 29312d5bb8 fix(procinfo): stop the listening-port probe failing silently (#731)
The Ports section says nothing when a pane has no listeners, and it said
exactly the same thing when the code that looks for listeners never ran.
#731 is a report from inside that gap: a Go service started with `go run
main.go` on macOS serves requests, and tty7 shows no port.

The reported shape itself holds up. `snapshot()` walks the whole descendant
tree from the pane's shell, `go run`'s compiled binary sits at depth 2, and
the `lsof` invocation is the one that finds it. What did not hold up is
everything around that.

The walk stopped at 64 processes, and it is depth-first over children in
ascending pid order. A shell whose earlier children brought a crowd — a
build, a container runtime, an agent's worker pool — could spend the whole
budget before the traversal reached the newest child, and the newest child,
highest pid and visited last, is precisely the server someone started ten
seconds ago. The walk now runs to a far larger bound and the probe is asked
about all of it; only the list handed to the panel is cut back to 64 rows.

Every way the probe can fail arrived as the same empty vector. `lsof`
missing from the daemon's PATH read as "nothing is listening" — and the
daemon's PATH is not the shell's, while macOS keeps `lsof` in /usr/sbin,
the sort of entry a hand-written `export PATH=...` drops. So did a probe
that hung: `Command::output` has no deadline and this runs on the thread
answering `QueryProcs`, so one `lsof` wedged on a dead mount takes the whole
pane's process list with it, permanently. The probe now falls back to the
absolute paths, is bounded at three seconds, and says which of those
happened.

A server under `sudo` is visible as a process and invisible as a socket:
`lsof` running as this user cannot read another user's fds. The walk now
carries each process's effective uid, and a tree holding someone else's
process says so rather than claiming the pane is quiet.

`PaneProcs` grew a `probe` verdict, `serde(default)` so an older
`tty7-server` at the far end of a remote workspace still parses and its
silence still reads as a complete answer. The panel spends it on the one
muted line where it used to write "None", and `tty7 procs` — the command
the issue asks reporters to run — prints a note under the empty PORTS
table. No banner and no new colour: an honest empty state, not a warning.

Deliberately left alone: `lsof`'s exit status. It returns 1 for a pid it
could not locate, and a pane's tree loses processes between the walk and the
probe as a matter of course, so reading that as a broken probe would put a
doubt on screen every time a command finished. A non-zero exit that also
found nothing gets a debug log line and no more. The Windows path is
untouched beyond its new return type — `GetExtendedTcpTable` has no tool to
be missing and no subprocess to hang.

I could not reproduce #731, and none of these is proven to be the
reporter's bug. Each is a way the panel could be silently wrong, and the
verdict is what will make the next report say which one.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:13:51 +08:00
l0ng-ai cd1353fd98 fix(ssh): stop the password prompt coming back after a reconnect (#820)
Three things had to line up for a password sheet that keeps reappearing
after the connection has already succeeded, and cannot be closed. Two of
them are here; the third was the reason closing it never helped.

The connection cache keys one slot per host, and that slot's mutex is
what makes a reconnect ask for a password once rather than once per pane
— everyone dialling the same host queues on it, and all but the first
find the connection the winner left behind. Eviction removed the map
entry outright. A dropped TCP connection kills every pane riding it at
the same instant, so all of them reach the dead-reuse branch in
`run_session` together: the first removed the entry, its `open_connection`
inserted a fresh mutex, and the pane a moment behind it removed *that*
one — the mutex a handshake was already holding — and inserted another.
Each pane ended up alone on a mutex of its own, ran its own handshake and
raised its own prompt. Answer one and the link comes up; the rest are
still queued behind it. Eviction now empties the slot instead of
replacing it, and leaves a slot somebody is dialling on completely alone.
The entry outliving its connection is what the map already looked like
everywhere else, and `routes()` has always reported such a slot as
disconnected rather than omitting it.

The second is what happened on close. A declined prompt failed only the
method that raised it, and `authenticate` walked on to the next one — but
`password` and `keyboard-interactive` are one question asked two ways, and
a server offering both wants the same secret either way. Closing the
password sheet put a keyboard-interactive sheet up asking for the same
password. Nobody declines a *method*, so a declined prompt now ends the
attempt, with a reason distinct enough for a caller to recognise.

That caller is the workspace supervisor, which reconnects on a clock and
treated the refusal as a transient failure: it dialled again a second
later, and every thirty seconds after that, for the rest of the session.
It now suspends the machine instead. The strip already says why and
offers Retry, which is the user asking to be asked again.

A key passphrase is left alone deliberately. Closing that sheet declines
one key, and the methods still to come ask a different question — someone
who cannot remember a passphrase is usually closing it precisely to be
asked for a password instead. So is the fact that a password is only kept
across reconnects when "Remember" was ticked: that is a choice about
storing a secret, not a bug.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:09:32 +08:00
l0ng-ai 237b2a14c4 fix(daemon): restore a pane's terminal modes on re-attach (#774)
A pane's screen comes back on re-attach out of the replay ring, and the ring is
a window: eight megabytes wide, dropped from the front as it fills. That is the
right shape for text, which is only worth what is still on screen, and the
wrong one for modes. A full-screen program announces itself exactly once —
`btop` sends `?1049h` and its mouse-reporting modes when it starts and then
does nothing but refresh — so a long enough run of refreshes pushes the only
copy of that announcement out of the front of the ring. What the client
replays is then a screenful of alternate-buffer frames with nothing left to say
they belong on the alternate buffer: it paints them onto its primary screen
with reporting off, and `wheel_route`, which reads exactly those modes, sends
the wheel to the scrollback of a screen that has none. That is the "a screen
that should not scroll starts scrolling" in the report.

`replay_state` already refuses to rely on the ring for anything that matters —
cwd, prompt state, the remote context, the agent, the exit — because all of
those are facts about the pane rather than bytes on it. The modes are the same
kind of fact and were the exception, so the daemon now folds the bytes it hands
the ring into a small tracker (`core::term_modes`) and `replay_state` re-sends
what is still on. Tracked are the modes that decide input routing or which
buffer is on screen: the alternate screen in its three spellings, the mouse
reporting level and its encodings, alternate scroll, DECCKM, focus reporting
and bracketed paste. They are replayed in the order the application set them,
because the emulator treats the reporting modes as a level and not as
independent bits, so the last one set has to be last here too.

The frame goes *ahead* of the ring rather than after it. That way the replayed
frames are painted into the buffer they were drawn for, and re-entering an
alternate screen the ring turns out to still carry is a no-op in the emulator,
so a prefix and a ring that both carry the mode cannot fight. Where the ring
does still carry a toggle it wins on its own terms, since the fold runs over
every byte the pane ever wrote and therefore agrees with the ring's last word
on any mode the ring still mentions.

Cursor visibility (`?25`) and autowrap (`?7`) are deliberately not tracked. Any
frame of a running TUI repaints them within milliseconds, whereas restoring a
stale `?25l` would leave a shell with an invisible cursor — a worse failure
than the one being fixed, and one the existing `restore_preamble` already goes
out of its way to avoid. A daemon handoff starts the fold empty rather than
carrying it, so a pane adopted across a daemon restart is no worse off than it
is today; the ring it carries is all it ever had.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:09:15 +08:00
l0ng-ai dbef42a03d fix(machine): keep earlier generations of the machine tree (#716)
`MachineStore::persist` serialises the whole document and lands it with
`write_atomic_private`, so the file on disk is never torn — but the document it
replaces is simply gone. The tree is rewritten whole on every mutation, which
means the write that loses a layout is also the write that erases the only copy
of it. That is what turned #716 from an annoyance into a lost afternoon: a
client's arrival queued nineteen `TabClose`s, each one persisted, and by the
time anyone read `machine.json` there was nothing anywhere on the machine that
remembered what the workspace had looked like.

`persist` now rotates the document it is about to replace into a small ring of
backups beside it — `machine.json.bak`, then `.bak.1` and `.bak.2` behind it —
before the new one is written.

Two things decide whether such a ring is worth anything. The first is what
"previous good" means. It cannot mean "the last document that parsed": an
emptied tree parses perfectly and is exactly the state you want to recover
*from*, so validity is no signal at all. Age is the only signal available, so
the ring is spaced: a generation is taken only when the newest one is at least
five minutes old. Without that spacing the failure mode above — a burst of
writes seconds apart — would have rolled three copies of the damage through the
whole ring before a human noticed. With it, the oldest generation is a quarter
of an hour of history, and three generations is where the ring stops so a file
rewritten every few seconds does not grow a history without bound.

The second is the atomicity of the rotation itself, since a rotation that can
lose both copies is worse than none. The live file is never renamed, only read:
at every point in `keep_a_generation` the tree is still completely at its own
path, and the new generation lands through `write_atomic_private` — a sibling
temporary renamed into place, which also means the copies inherit the 0600 the
live tree is written under rather than widening anything. A crash mid-rotation
costs at most one backup generation and never the tree. A backup that cannot be
written is logged and the new document is persisted anyway; a machine with no
backup still has to work.

`load_machine` now also falls back to the newest generation that parses when the
live document is unreadable or corrupt, instead of starting from an empty tree.
Deliberately only for those two cases: a tree that parses always wins, however
empty it is, because that is the case a human has to judge — which is why the
files are plain JSON under obvious names, ready to be copied back by hand.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:00:28 +08:00
l0ng-ai 8adebdf24d perf(ssh): prove the remote server once per connection, not once per pane (#695)
Opening a second tab on a machine tty7 was already connected to and already
serving cost the same wait as the first one. The SSH connection is reused, so
none of that wait was handshake cost: every route called `ensure_remote_server`
unconditionally, and that runs the whole installer probe again — `uname -sm`,
an SFTP realpath for the home directory, an SFTP stat, a control probe that
spawns the server binary, and `check_running_build`, which walks `/proc/[0-9]*`
with a `readlink` per PID and shells out to `ps` on the machines that have no
`/proc`. Five serial round trips before the pane's own channel opened, to
re-learn what the pane before it had just learned.

WSL fixed exactly this in #479 by remembering where a distro's server was last
proved to be. SSH now does the same, with one difference that matters: a distro
name is the whole identity of a WSL target, but an SSH connection can die and
be replaced under the same key, so the note is kept on the `SshConnection`
rather than in a map beside its key. Keying by connection generation is then
not a discipline anyone has to keep — a reconnect is a new `SshConnection` with
an empty slot, and nothing has to remember to forget.

Memoizing must not quietly cancel the version check, which is the one thing
that could make this a bad trade. Three things keep it honest. The note carries
the build mismatch the probe found and re-files it on every hit, because the
warning is raised inside `Installer::run` and each route drains its own sink —
without that, only the first pane on a connection would ever hear that a
different build is serving the machine, and every window after it would attach
in silence. `replace_remote_server` and `restart_remote_daemon` forget before
they act, not after, so a restart that fails halfway leaves the next pane
looking rather than trusting a note written before the upheaval. And the router
forgets when a routed link closes without the remote sending a byte, the way it
already does for a WSL bridge: `exec` succeeds whatever the command turns out
to be, so a binary deleted or moved since the probe is discovered exactly
there.

A failed probe is deliberately not remembered. A host that was briefly
unreachable, or an install the user declined once, must not pin every later
pane on that connection into the same failure — the slot is written only when
the probe got all the way through. The note carries the binary path and the
mismatch and nothing else: `installed`, `launched` and `confirmed` describe an
event rather than a state, and serving them again to a later pane would only
make the log lie.

Left alone on purpose: the probe itself, which is unchanged and still the only
thing that decides what a pane runs; the WSL memo, which keeps its own shape;
and the macOS-server half of #695, which shipped in v26.9.1.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 17:54:45 +08:00
l0ng-ai ab5752e27b fix(daemon): notice a pane came home from ssh without waiting for output
The foreground probe that clears a pane's remote context only runs when
the reader thread has bytes in hand. The prompt a shell draws after a
command is the last output a pane produces until the user types again, so
an `ssh` that exited inside the poll interval left the pane reporting
itself as remote indefinitely — nothing came along to probe on.

Everything keyed off that context stayed on the far end. Most visibly the
history scope: ↑ read the remote list, which for a host with no history
of its own is empty, so ↑ appeared dead until some unrelated output
arrived. Pressing Enter looked like it unblocked the pane because an
empty command is the cheapest way to make output.

A prompt mark that survives the foreground suppression is the shell
saying the command it ran is over, so the foreground has just gone back
to being the shell itself. Probe right then instead of waiting out the
interval.

Switching history scopes also dropped the list it was leaving, and the
reload that refills it is a background task, so ↑ had a second window of
recalling nothing. Park each scope's list instead, capped at four, and
step back into one instantly.

Claude-Session: https://claude.ai/code/session_01Mnerr8RZ23Nd4cxyfeqxiu
2026-09-09 11:10:27 +08:00
l0ng-ai a077d47051 fix(daemon): let the reap wait for a seat that is coming back
`reap_stranded_clears_a_seat_holder_with_no_pidfile` failed on macOS CI
often enough to red a PR that touched nothing near it: after a confirmed
reap the lock file still named the dead holder.

A seat is not free the same instant its holder is confirmed dead. The
kernel releases the lock while tearing the process down, and any
descriptor a `fork` left behind holds it a moment longer — BSD `flock`
counts an inherited descriptor as another reference to the one lock
rather than a second lock, which this module already records on the
claim side (`a_reference_a_forking_neighbour_left_behind_does_not_lose_the_seat`,
and the retry loop `claim_within` exists for). `clear_record_if_free`
had no such patience: one `EWOULDBLOCK` ended the attempt, and nothing
ever revisits the file, so the dead pid stayed in it for good — where a
later pre-recording build holding the seat would make that number, by
then possibly reused, read as the holder.

It now retries for 500ms. The rule it enforces is unchanged: the record
is truncated only while the kernel says the seat is free, so a live
holder that outlasts the grace still keeps its record.

The guard was measured both ways — with the grace at zero
`clearing_the_record_waits_out_a_seat_that_is_about_to_come_back` fails
with exactly the assertion CI reported.

Claude-Session: https://claude.ai/code/session_01Mnerr8RZ23Nd4cxyfeqxiu
2026-09-08 23:12:10 +08:00
l0ng-ai ea7543490a Merge pull request #811 from l0ng-ai/feat/remote-ports
feat(ports): detect and forward what a remote pane is serving
2026-09-08 21:56:23 +08:00
l0ng-ai aafd26de72 fix(ports): restore three doc comments and name the right machine
Three items were inserted between an existing doc comment and the item it
described, so `bind_failed`, `action_strip` and one test each lost their
documentation to the newcomer above them — and the peer-procs test carried
two `#[test]` attributes as a result.

The Ports panel's fallback said "This machine's tty7-server is too old",
which reads as the local one; the server that cannot answer is the far
side's.

Claude-Session: https://claude.ai/code/session_01TPXrptp2rCGKjXaz4xE3Lq
2026-09-08 21:40:18 +08:00
l0ng-ai 03c3081a6e feat(ports): detect and forward what a remote pane is serving
A remote workspace's ports were never listed. The pane lives in the
peer's registry and QueryProcs asks this machine's daemon, which has
never heard of it, so the answer was an empty list — indistinguishable
on screen from a pane serving nothing. Add a control request so the peer
answers instead, gated on a feature so an older server says "I cannot
tell you" rather than "nothing is listening".

With the ports visible, the forward becomes something the user should
not have to think about: a port opens on a click, and a new one is
forwarded unasked, at the same number where that number is free here.
The watch runs with the panel shut, which is when a port appearing is
most worth saying something about.

Ports and Forwards were two sections that never mentioned each other; a
row is now a port, and the forward is where that row says it comes out.
Adding one by hand asks for one number instead of five fields, with the
rest of the ssh -L grammar one disclosure away.

Claude-Session: https://claude.ai/code/session_01TPXrptp2rCGKjXaz4xE3Lq
2026-09-08 21:01:50 +08:00
l0ng-ai 9842fddd4c Merge pull request #810 from l0ng-ai/feat/sidebar-custom-groups
feat(ui): custom sidebar groups a tab can be put in by hand
2026-09-08 17:44:41 +08:00
l0ng-ai b063f93431 Merge origin/main into feat/sidebar-custom-groups
main's #806 changed the fold test while this branch changed
toggle_sidebar_group to take Option<&GroupKey>; the textual merge left
one call site on the old signature. Fixed here.

Claude-Session: https://claude.ai/code/session_01MS7VnqvGRtNTrJG9zxtz51
2026-09-08 17:29:10 +08:00
l0ng-ai 5c2a02f064 fix(ui): give an agent's mark one colour, not one per draw site
Two places draw an agent's mark and each decided its colour on its own:
the tab strip took a `TraeCode` branch added in #807, and the tray icon
recoloured every glyph white regardless. So TraeCode came out green on a
tab and white in the tray — one agent with two faces.

The colour now lives on `CLIAgent::icon_rgb`, next to `accent_rgb`, and
both sites read it. White for every mark that is a silhouette on its
brand colour; TraeCode is the one whose mark carries the colour itself.

`the_tray_draws_each_mark_in_the_agents_own_colour` walks `CLIAgent::ALL`
and asserts the tray's solid pixels contain the agent's own mark colour,
so a future agent whose mark is not white cannot be added to one draw
site alone.
2026-09-08 17:03:41 +08:00
ayamir fc94022ed0 feat(agent): add TraeCode CLI support (#807)
* feat(agent): add TraeCode CLI support

* fix(settings): index TraeCode agent hooks
2026-09-08 16:57:04 +08:00
l0ng-ai e7e5520411 refactor(ui): give a sidebar group a stated kind, not just a path
A sidebar group was a bare `Option<PathBuf>` the sidebar recomputed from the
tab's cwd every frame. That is right for a group the sidebar derived and wrong
for one a user states by hand: the probe overwrote whatever it found, so a
hand-placed tab would be dragged back to its repo on the very next frame.

`GroupKey` names which of the two a key is. `Repo` stays the probe's to change;
`Custom` is never recomputed. One enum rather than a path plus a `pinned` flag,
because the flag and the path are then free to disagree — and because a custom
group's name is not a path at all, which is the second half of this: it must
skip `group_names`, whose job is to lengthen repo roots until they stop
colliding. Run a name through it and `work/urgent` prints as `urgent`.

The flat spelling the protocol, the session file and the fold list all need
marks a custom group with a `custom:` prefix. No absolute path can collide with
it, and every group written before this decodes as a `Repo` — so old sessions
and old daemons keep working untouched.

Under `SidebarGrouping::None` a custom group hides like everything else. It is
tempting to exempt it, but `sidebar_sections` gives Scratch a header as soon as
any keyed group exists, so exempting one would draw two headers for a user who
asked for none. The key stays on the tab; switching grouping back on brings it
straight back.

No custom group can be created yet — this is the data layer alone.

Claude-Session: https://claude.ai/code/session_01MS7VnqvGRtNTrJG9zxtz51
2026-09-08 15:52:16 +08:00
l0ng-ai 4c02ad3881 feat(ui): fold sidebar groups; stop the diff header clipping its controls (#804)
A sidebar group folds shut when its header is clicked, and stays shut across
launches. A search outranks the fold — a row a live query matches shows
whatever its group says — and so does the active tab's own row, which a fold
must never hide: `spawn_group` seeds a new tab with the group it came from, so
otherwise Cmd-T inside a folded group would draw nothing but a header count
going up by one. Folded rows register no rectangle, so a pane cannot be dropped
into a group that is shut, and the header still counts every row the group has.

Separately, the diff overlay's header stops clipping its own controls. Every
element on it but the commit subject was `flex_shrink_0`, and one of those was
a focused file's path, which is unbounded — in a docked column the two things
you can actually click were pushed off the end and clipped mid-word. The path
and the byline now yield first, ahead of the subject, because each has a second
home elsewhere in the overlay and the subject has none; the path yields
head-first so the filename is the last thing to go. `split_path_leaf` moves to
`path_display` with its tests, which is where a path's display spelling lives.
2026-09-08 12:13:03 +08:00
l0ng-ai 28530a476a fix(git): key a repository by one spelling of its root (#796) 2026-09-07 22:55:01 +08:00