mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
2e8a43a35e6ea5d166dde60e9d9c5e4eadb4fc79
46
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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 |
||
|
|
5c387e2ba4 |
Merge pull request #830 from l0ng-ai/fix/731-port-probe-honesty
fix(procinfo): stop the listening-port probe failing silently (#731) |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
c0366cd143 |
fix(cli): name the limit of the empty-segment fix, and settle the captures the e2e compares (#841)
CI found both new end-to-end cases red on Linux and macOS while green on Windows. One was a bad assumption in the test; the other was the test telling me the fix is narrower than the first commit claimed. The real finding is the resize case. On Unix a resize raises SIGWINCH and the shell repaints its prompt, so the segment the resize opens is *not* empty — it holds the repaint. Dropping byte-less segments therefore leaves the newest non-empty segment being a bare prompt, with the pane's output still stranded in the segment sealed behind it: on CI the default form came back as 174 bytes of prompt escapes and no marker. On Windows nothing answers the resize, the segment stays empty, and the fix reaches past it to the output — which is why the assertion passed there. It was asserting an accident of the platform. So the fix stands but is smaller than "a resize no longer costs you the pane's output": it makes the zero-byte answer impossible, and that is all. It cannot do more. Nothing in the byte stream distinguishes a prompt repaint from output the pane meant, so no client-side rule can tell which side of the boundary the answer is on. The boundary is the flaw — the default form's unit is the last resize, an event in the window rather than in the pane — and moving it means redefining what the default returns (the last screenful of the ring, say), which would shrink what every caller with a never-resized pane gets today. Left alone, and said plainly instead: in `what_was_asked_for`'s doc comment, and in a warning in the CLI reference telling anyone reading a pane under the GUI to ask for `--scrollback`. The test now asserts what the fix actually guarantees on every platform — the default form answers with bytes rather than with the resize's placeholder, and it is the end of what `--scrollback` returns, which is what would catch a fix reaching for the wrong segment. The marker is pinned against `--scrollback`, the form that promises to hold it. The `--tail` failure was a race in the test, not in `--tail`: the whole and the tail were separate calls and the pane advanced between them, so the tail carried a prompt line the whole capture had not caught up to. Both cases now read the whole answer on either side of the others and require the two readings to match before comparing anything, which is what makes the comparison a statement about the code rather than about the moment. Both also read `--scrollback` now, so neither depends on where a segment boundary happens to fall, and neither pins exact pane content — the macOS runner prints a zsh banner into the pane. Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU |
||
|
|
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 |
||
|
|
5ac26b5dd9 |
feat(cli): add capture --tail N (#841)
Every "how did the last command end?" query went through `| tail -n 5`, which ships the whole grid down a pipe to throw most of it away — and on Windows, where `capture` is just as useful, there is no `tail(1)` to pipe to. `--tail N` keeps the last N lines of the answer instead. It is a third independent choice beside `--scrollback` (how much of the ring) and `--plain` (in what form), and it composes with both. The trim runs last, after `--plain` has decided what a line is: a shell line the pane wrapped over three rows is one line to the grid and three to a byte counter, so `--plain --tail 1` hands back the whole of the last line rather than its final row. The raw form counts the same way `tail` does, splitting on the newline and leaving the CR of a CRLF attached to the line it ended, so the bytes stay the pane's own. A trailing newline terminates the last line rather than opening an empty one, which is the difference between `--tail 1` answering the last line and answering nothing. `N` must be at least 1: a tail of zero lines would print an empty result that reads exactly like the blank-pane ambiguity this issue is about, so it is a usage error (exit 2) instead. `--json` reports the tail in `text` but leaves `bytes` the size of the whole replay, so the pair still says "this was trimmed" rather than "this came back short". Left alone deliberately: the daemon still replays the entire ring on every observe, so the saving here is the pipe, not the wire. Bounding what crosses the wire means teaching `ClientMsg::Observe` a limit and versioning the protocol for it, which is a much larger change than the papercut warrants — and the default (newest-segment) form has always received the whole ring and discarded most of it, so this adds no new cost. The docs say so rather than implying otherwise. Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU |
||
|
|
0752110b54 |
fix(cli): stop a resize from emptying capture, and say when a replay renders blank (#841)
`tty7 capture %n` answered a live pane with zero bytes and exit 0, which is byte-identical to a pane that had genuinely never printed. The report guessed at the replay grid. It is not the grid — it is the segment the grid was handed. The daemon's replay ring splits on resize: `ReplayRing::resize` seals the segment holding the output and pushes an empty one at the new geometry, and `ReplayRing::replay` sends every segment it holds, empty tail included. `RealBackend::capture` then kept "the newest segment" for the default (non-`--scrollback`) form, and for any pane resized since it last printed, the newest segment is that empty placeholder. A pane restored from disk lands in the same state, because seeding the ring ends in a resize too. Measured end to end against a real daemon before the fix: a pane holding 954 bytes of scrollback answered `capture` and `capture --plain` with 0 bytes and exit 0 while `capture --scrollback` returned all 954. The fix drops byte-less segments before choosing the newest one — they carry nothing in either form, so `--scrollback` drops them too and both forms describe the same bytes. The daemon still sends them: its trailing `Size` is how an attaching client learns the pane's current geometry, and that is not the CLI's to take away. What this does not explain is the reporter's other half — that `--scrollback` came back empty in the same episode. Filtering empty segments cannot cause that, and neither could I reproduce it: 60 rounds of `capture` against a pane spewing 4000 escape-laden lines produced no empty result, and an 8 MiB ring (the cap) replayed whole in ~400 ms, well inside the 300 ms per-frame settle window that was the other suspect. So the silent-empty class is left distinguishable rather than declared closed: `--json` now carries `bytes`, the size of the replay counted before anything renders or trims it, and the one case where an empty answer is not an empty replay — bytes in, no text out — says so in a line on stderr. Zero bytes is a pane that printed nothing; bytes with no text is a screen whose content did not survive the grid. Deliberately no retry loop and no warning on a condition that has not been established. Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU |
||
|
|
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 |
||
|
|
97c1cb9b44 |
feat(cli): re-home an orphaned pane with tab new --pane (#716)
A pane can come out from under its tab with its shell still running — an interrupted `tty7 run`, a `ws rm` that could not hang everything up, or a client that closed nineteen tabs whose shells were all alive (#716). `pane ls --all` has been able to *show* those for a while, but everything the CLI offered to do about one was to kill it: `pane close %<id>`, or `pane close --orphans` for the lot. The shells were fine. There was simply no verb that put one back on screen, so recovering meant recreating tabs by hand and reaping the originals. `tab new` grows a `--pane` that builds the tab around a pane that is already running instead of spawning a shell for it. Nothing new had to be invented on the wire: `ControlRequest::TabCreate` has always taken a `PaneSeed` with a pane id in it, which is how `run --keep` files its pane into a tab. The part that needed designing is where the seed comes from. `tab_close` retains the orphaned panes out of `m.panes` at the same moment it drops the tab, so by the time anyone wants a pane back the tree has already forgotten its record — cwd, title, shell. Reading the seed off the tree would therefore work for an interrupted `run` and fail for exactly the case this verb exists for. It is rebuilt from the live pane registry instead, which still has the pane because the pane is still running, and which is the same list `pane ls --all` walks. That does mean `ssh_spec`, `agent` and `shell` are not recovered — the registry never carried them. They cost nothing while the shell lives, since the tab is a view onto a pty that is already there, and only matter if the pane later dies and something tries to restore it from the seed. Reconstructing them from a running pty is a different problem; a tab you can see beats a shell nobody can reach. Two refusals rather than one guess: a pane the server is not running cannot be re-homed, and neither can one a tab already holds — that is what `pane split` is for, and accepting it would put a single pane in two places in the tree. With no workspace named the pane goes back to the one it was spawned for, which is the `owner` that `pane ls --all` already prints; `$TTY7_WS` cannot help here, because a shell recovering from this is by definition not inside tty7. The `pane ls --all` footer now names the way back as well as the two ways to kill, since the listing is where an orphan is found and so is where the recovery has to be written down. Deliberately not done here: the switcher's orphan rows still carry only a Close button. Adopting from the GUI is not the same one-line change — the row lists orphans machine-wide while a window speaks for one workspace, and a pane may only be attached by the workspace that owns it, so the button has to decide where the tab goes before it can build one. That is its own piece of work. Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU |
||
|
|
31ce382226 |
revert: drop projects as a declared sidebar layer (#769)
Reverts
|
||
|
|
cebd871cb4 |
feat(sidebar): add projects as a declared layer beside the derived groups (#769)
* feat(sidebar): add projects as a declared layer beside the derived groups The sidebar's repo groups are derived: a group's identity is a path recomputed every frame from a leaf's cwd, it appears when a tab lands in it and vanishes with its last tab. That layer cannot carry a name of its own, cannot be created before a tab is opened in it, and orphans anything keyed to it when a directory is renamed or moved. Add a Project as a real entity on the workspace — an id, an optional name, a root — and an optional reference to one on each tab. Nothing probes it: a tab joins a project only by an explicit action, and a tab that leaves one lands back in the group the probe would have put it in, so declaration and inference never disagree and no third membership state is needed. The derived grouping, the cwd probe, the write-back and the SidebarGrouping config are untouched; the only difference is the tab list they are fed. A server that predates the projects feature ignores the new array and serves today's sidebar. Closes #756 * fix(sidebar): stop a searched row claiming a chord it does not own A live search deliberately ignores a folded heading — the query is asking about tabs — so the rail draws rows the chord order has taken out. The badge was read from a `Vec<usize>` that started at zero, so every one of those rows claimed ⌘1 while ⌘1 opened something else. It is `Option<usize>` now, built by `badge_positions` off the same order `activate_visual` walks, and a row the order left out wears no badge at all. Also in the rail: the block loop reads "declared" off the section key rather than the position it happens to sit at, and an unreachable `continue` for a folded empty derived block is gone — `sidebar_sections` never makes one. Projects: - `MAX_PROJECTS` is held on the window side too. The machine refuses past it and a refusal resynchronizes, which would re-push the project this window kept and be refused again. Checked before the folder panel opens, so a full workspace says so before asking for a folder rather than after. - `set_project_root` keeps the one-project-per-directory rule `declare_project` holds on the way in; pointing one project at another's folder reached the two-headers-that-mean-the-same-thing state by the back door. - Opening a rename box over one already on another project commits it instead of dropping it with its subscription, which threw the typing away. Sync: - Project reordering moves after `retire_projects`. `to` indexes the machine's whole list, so a project on its way out pushed the survivors along and spelled a move for one already in place. - `adopt_projects` reports whether it changed anything and the callers repaint when it did; it was mutating the window's list with nothing to notify. - `migrate_panes` gets its doc comment back — `reconcile_projects` had been inserted between it and the comment describing it. Dead `L10nKey::ProjectNew` removed: translated four times, used nowhere. * fix(control): move the dialect to v8 for the project verbs `CONTROL_VERSION`'s own doc says to move it whenever a variant is added to `ControlRequest`, `ReplyOk` or `ControlEvent`, and says why the feature strings are not a substitute: they cover what a peer can safely ignore — a field added to a message it already decodes — while a variant it has never heard of fails to decode and takes the whole link down with it. The project verbs shipped behind a `projects` feature string instead. That gates what a client *sends*, so a v7 server never saw a verb it could not read, but nothing gates what a server *pushes*: a v7 client meeting a v8 server that had grown a project would take the `ProjectCreated` delta, fail to decode the frame, and lose the link — `read_until_closed` calls `fail_all` on any decode error. Only the number can turn that pairing away at the handshake. So the number moves and the feature goes. It was redundant even for the direction it did cover: `MACHINE_TREE` and `PROJECTS` were pushed under the same `services.machine.is_some()`, so within one build they were always equal and only a cross-version pairing could tell them apart — which is exactly what v8 now refuses at the handshake. Keeping both would be two mechanisms for one job, and the weaker one silently covering half the problem. Removed with it: `is_project_op` and the `pump` filter it fed. Disk compatibility is a separate axis and is untouched — `Workspace::projects` and `Tab::project` keep their `serde(default)`, and the test that reads a tree written before either still passes. Remote workspaces need their `tty7-server` pushed before they will connect. That is the dialect-refusal path v7 was minted to make reachable: the parked strip and its Update Server button. Also: the two sidebar `+` buttons now fade in on their own heading's hover rather than the whole rail's, so a control appears where the pointer is. |
||
|
|
975e3edf9b |
Fix Windows path quoting, wire up Checkout to…, bound the Spawn reply (#705)
* fix(windows,scm,daemon): quote paths per shell, wire Checkout to, bound Spawn Five fixes from a whole-codebase audit, in one sweep because they share the paths they touch. Path quoting had two implementations. file_tree::shell_quote_for wrapped the path in quotes and picked the right ones per shell (#593); view::shell_escape_path escaped with backslashes, which is POSIX-only and collides head-on with the Windows path separator, so a dropped file, a pasted path, a staged image path and an accepted completion candidate all lost their separators there. completion::complete_path stripped the same backslashes back off before looking a path up, so inline path completion could never resolve a directory on Windows either. Both now go through one core::shell_quote module, and shell_word_start tracks quoting across the word so a second Tab still finds the word it just inserted. "Checkout to..." was registered, listed in the palette, bindable, and handled by an empty match arm — invoking it did nothing at all. It now opens an inline input row in the SCM panel, the twin of the existing "create branch" one. RemoteTerminal's Spawn read the daemon's reply with no deadline, while Attach in the same file and PaneSession::spawn_over in core both bound theirs. A daemon caught mid-restart accepts the connection and never serves it, and the local route spawns synchronously on the UI thread, so the silence froze the window on "new tab". Two Windows papercuts: client_hostname spawned a console program from a GUI process (a visible console flash) where COMPUTERNAME already has the answer, and completion generators were a silent no-op with no way to tell "produced nothing" from "never ran". Three duplicated implementations merged: proc_name existed twice in the daemon with a different fallback in each, the GUI's control link was the one client socket that skipped transport::tune, and fps.rs and perf.rs were the same windowed meter copied twice. * refactor(completion): stop declaring spec fields nothing reads The Fig spec structs mirrored seven keys the completer never looks at, each held up by its own #[allow(dead_code)]. Serde ignores unknown fields by default, so dropping the declarations parses the same specs and drops the attributes with them. * refactor(daemon): delete the loopback-forward management pipeline Two protocol messages, their kind codes, encode and decode arms, two daemon dispatch arms, two wire structs and two GUI client wrappers all existed to reach SshManager::list_loopback_forwards and close_loopback_forward, which were hardcoded to Vec::new() and false. Nothing called the client wrappers either. The kind codes are left as holes rather than renumbered, the way 13 already is, so the wire format is unchanged for every other message. known-hosts management looks like the same shape but is not: its backend parses the real file, fingerprints keys and rewrites through a 0600 temp file. That one keeps its client half and gains a comment saying it is an interface waiting for a screen. * test(ssh): cover the host-key policy table and both proxy handshakes The host-key decision is lifted out of check_server_key into host_key_action, so what to do about Known/Unknown/Changed/ ChangedAlgorithm/Revoked can be read and tested without a server, a broker or a known_hosts file. Eight tests pin it, including the two subtleties the comments already claimed: verify_host_keys=false still rejects a revoked key, and a new algorithm asks the unknown-host prompt rather than a new variant older peers cannot decode. socks5_connect and http_connect are split into connect + handshake, the handshake generic over the stream, so nine tests drive them from an in-memory duplex: length-prefix framing, the variable-length bound address, auth refusal, reply codes, and the header terminator. * test(cli,daemon): cover server binary resolution and the procargs parser server_exe is split into environment lookup and resolve_server_exe, the latter taking its three sources and an is_exe predicate so seven tests can pin the precedence without touching the filesystem. Holding the sibling to is_file rather than exists fixes a directory named tty7-server shadowing the real binary on PATH. parse_macos_procargs gets six tests over the KERN_PROCARGS2 layout: exec-path skipping, however many bytes of alignment padding follow it, argc bounding argv so the environment stays out, truncation, and a short buffer. * test(ui): cover the host-op pool decisions and the local reconnect schedule The pool's retire condition moves into should_retire with the reason named: a worker must not retire on the timeout alone, because submit counted it as idle and so did not spawn a replacement for the job that landed meanwhile. LocalLink::tick's schedule moves into due(), taking the clock and the link's state as arguments. The first attempt going out immediately, the backoff only applying from the second, and a pending deadline not being pushed further out by later ticks are now pinned. The identical scheduler in remote_workspace had TestAppContext coverage; this one, which every launch depends on, had none. * fix(completion): unquote across the whole word, not just its first character The round-trip test caught two things the first cut got wrong. A quote can open partway into a word — quote_for_shell emits ~/'My Documents' so the shell still expands the tilde — and a single-quoted body is literal all through, so unescaping backslashes inside one took the separators out of 'C:\Users\me'. Scanning with a quote state handles both, and makes the '\'' seam fall out of the state changes rather than needing a case of its own. The GPUI test for accepting a candidate follows the insertion from backslash escaping to quoting. * fix(windows): unbreak the Windows build and quote for PowerShell's own dialect `Instant` was moved behind `#[cfg(unix)]` while the generator cache still uses it unconditionally, so the Windows target stopped compiling. The quoting module treated every shell but cmd.exe as POSIX, including PowerShell. PowerShell does not join a quoted string to the bare word beside it, so the `'\''` seam is not a seam there — `C:\Users\O'Brien` came out as three tokens, and the completion un-quoter turned the apostrophe back into a backslash. Quoting is now a three-way dialect (cmd / PowerShell / POSIX) chosen once and threaded through completion in place of the escapes flag. * test(file-tree): name the shell where the quoting rule is the POSIX one `shell_quote_for(_, None)` answers from the platform, so an assertion about the `'\''` seam has to say which shell it means or it fails on Windows, where the unnamed shell is PowerShell. |
||
|
|
8131ac2f93 |
Address a tab by the bare id --json prints, and stop the skill sending workers in headless (#699)
* fix(cli): address a tab by the bare id --json hands back `parse_tab` required the `@` sigil, so the tab id from `tty7 tab new --json` — the one id a caller is certain of — was the one shape the CLI refused. `parse_pane` already made `%` optional for exactly this reason (#538); this aligns tabs with it, keeping the digits-only guard so a leading `+` cannot read as an ordinal now that the sigil is gone. * docs(skill): hand a pane worker its interactive mode The worked example passed the task with `-p`, which draws nothing: the pane stays blank until the turn ends, `capture --plain` reads back empty, and the user watching their tty7 window sees a worker that looks hung. Putting a piped worker in a pane discards the only reason it is in one. Also documents three things that cost real debugging time: a fresh pane can swallow the Enter while its shell is still running startup files, `tty7 procs` reports nothing running for a pane with a live agent in it, and the OSC 777 event stream in a raw `capture` is what actually answers "is it moving". |
||
|
|
f4c31222a4 |
feat(cli): restart the server in place by default, keeping sessions (#669)
* feat(cli): restart the server in place by default, keeping sessions `tty7 server restart` used to be stop + start, killing every pane, while the GUI's Restart Server hands the daemon off to a new image via execve and keeps everything running. Same verb, opposite side effects. The CLI now probes the daemon for the handoff feature and asks it to exec the tty7-server binary in place: same pid, same ptys, sessions survive. Success is judged by the version endpoint answering with a new per-process instance id, not by build strings, since the CLI and server binaries can be on different versions. A refused or stalled handoff leaves the daemon untouched and reports an error suggesting `--hard` instead of silently killing sessions. The stop + start path remains for `--hard` and for daemons that cannot exec themselves (Windows, pre-handoff builds). * fix(cli): leave a slow handoff's seat holder alive, and let a hard restart say sessions ended After a taken handoff, the poll timing out does not mean the daemon died: the singleton lock survives the exec, so a held seat is the new image still coming up with every session aboard. Falling back to start() there would grant it one second of grace and then reap it — bail with the seat still held instead, and only start over a genuinely free seat. The stop-and-start fallback (--hard, Windows, pre-handoff builds) now reports that sessions ended instead of relaying start()'s plain report, since the default restart's promise is sessions kept. |
||
|
|
3dc63e2d87 |
fix(daemon): find and reap a seat-holding daemon that lost both its names (#671)
A daemon can survive quit-and-stop with its endpoint unlinked and its pidfile gone while still holding the singleton seat (#667). Every later launch then spawns a daemon that stands down against the lock and times out red, and nothing on the machine can recover: stop answers "not running", ensure_running reaps only through the pidfile, and flock cannot say who the holder is. Two roads led there, and both are closed: - The reap identified a daemon by proc_pidpath alone, which fails outright for a live process whose binary was deleted — every nightly update replacing the installation. The identity check now falls back to the kernel's comm name (proc_name on macOS, /proc/pid/comm on Linux, both recorded at exec and immune to deletion), strips Linux's " (deleted)" marker, and — decisively — no longer deletes the pidfile of a live process it cannot identify: the record was the only handle left on the survivor. - When the pidfile is gone entirely, the pid the claimant now writes into daemon.lock at claim time is the handle of last resort. The lock file is never deleted and holding the flock is the definition of being the server, so while the seat is held its content names the holder; stop() and the reap fall back to it, and a confirmed reap clears the record (only under a momentarily-free seat) so a stale number cannot outlive its process. Unix-only: the Windows seat is share_mode(0), unreadable while held. Every road back now clears a stranded seat, not just the GUI's: ensure_running's stale cleanup is factored into spawn::reap_stranded, tty7 server start runs it too, and tty7 server stop no longer takes "nobody answered" for "nothing to stop" when the seat is still held. A short grace keeps the reap away from a daemon that is merely mid-handoff or mid-startup — where health is an answered handshake, never a bare connect: a wedged daemon's listener still completes connections out of the kernel's backlog. The startup-timeout errors name the seat-holding pid, with the kill advice identity-gated so a stale record never tells anyone to kill an innocent process. Two liveness corrections round it out: a zombie now reads as dead — it answers kill(pid, 0) like the living but holds no lock and no image, and no signal can end it, so counting it alive spent both reap timeouts on a corpse (the GUI never waits on the daemons it spawns, so crashed daemons are zombies as a rule) — and stop() only pays the process-exit wait for a shutdown it actually delivered, instead of watching an unreached survivor not move for five seconds. The guard tests were each verified to fail against the behavior they pin (fallbacks, the handshake criterion, the grace, and the wait gate removed by mutation) before being trusted green; the zombie probe semantics (proc_pidinfo failing for a zombie that still answers signal 0) were measured, not assumed. |
||
|
|
a2d53a9597 |
fix: 19 项低危 UX 问题(#584–#602) (#615)
* fix(scm): say what "discard all" actually discards (#594) The group-level Discard prompt asked to "discard every change in this repository", but discard_all_ops has only ever swept unstaged edits and untracked files — staged changes survive, as the function's own comment notes. Users confirmed under one belief and the code kept another. Narrow the prompt to the operation's real footprint, in all three languages. * fix(scm): keep the amend toggle when its confirmation is cancelled (#595) scm_commit cleared scm.amend when Commit was pressed, before the "rewrite the last commit?" prompt. Answering Cancel returned to a panel whose amend mode had silently been dropped, so the next Commit created a brand-new commit — exactly what the user had just declined to risk. The toggle now clears where scm.committing arms, at dispatch in run_git_op, extending the rule the armed flag already followed: a cancelled confirmation leaves nothing behind. * fix(cli): answer a wait timeout in the success path's JSON shape (#589) The 124 branch returned {pane,status,timed_out} while a finished wait returns {pane,status,matched,stale,activity,message,session_id} — so the one branch a consumer writes error handling for was the one missing its fields. The timeout now carries the full shape plus timed_out, and reference.mdx documents the schema and the flag. * fix(cli): report a failed wait on stderr, even under -q (#590) wait's failures are structured exits (124, or 1 when the pane died first), so they never passed through the anyhow path whose eprintln is the only thing quiet mode cannot silence — contradicting the documented "errors still go to stderr". Both exits now print their headline to stderr, the discipline pane close already established. * docs(cli): describe owner as the workspace that may attach (#591) commands.md still claimed the CLI stamps a literal "tty7-cli" owner on the panes it spawns — the behaviour the orphan-workspaces work removed, because an owner names the workspace allowed to attach and a stranger's stamp got the panes respawned. Every spawn path now writes the workspace id, or nothing while the pane is still unfiled. Bring commands.md in line with reference.mdx, and note the absent case in both. * docs(cli): close five contract drifts between the tables and the code (#592) - The key tables listed pgup/pgdn as aliases but not pgdown, which the parser has always taken; both references name it now. - "Case-insensitive" was flat wrong for Alt: M-x keeps its case because Alt is a prefixed ESC, unlike Ctrl. Both references note the exception. - procs' ports JSON has carried addr since the field exists; both schemas show it. - TTY7_WS is tab ls's default too; both environment tables say so. - split --ratio's clamp to [0.05, 0.95] was discoverable only in code; both split sections document it. * fix(cli): doctor exits 1 when the server is unreachable (#592) doctor is the verb people run when something is not working, so an unreachable server is *the* finding — not a row to exit 0 over while `tty7 doctor || alert` never fires. The table and JSON still go out (the context rows are the other half of what doctor is for), and stderr carries the headline under -q. MockBackend grows an `unreachable` flag so the branch is testable; no Status/Routes round-trips happen once hello has failed. * fix(settings): refuse a Start-in path that names no directory (#601) The custom path was stored unchecked, and the daemon's picker then skipped it — not a directory — so every new pane silently started in the fallback directory and the typo read as a tty7 bug. Settings now marks the field red and refuses to save, the proxy row's pattern (#551), with the red line and the commit gated on one shared predicate so they can never disagree; a hand-edited config.json holding such a path gets a log::warn! naming it at the moment the fallback engages. * fix(terminal): rescan search highlights when the pane's width changes (#586) A match point is an absolute (line, column) against the width it was scanned at, so a column change reflows the text out from under every highlight. Output rescans them (Wakeup → refresh), but a quiet local pane has no output coming and the drift outlasted the resize indefinitely. set_grid_size now rescans on a column change with the output path's discipline — selection and scroll untouched — and takes the Context it needs to do so; a rows-only change reflows nothing and stays cheap. * fix(terminal): keep the grid selection when the search bar opens and closes (#584) The selection that seeds the query is the thing being searched for, yet opening the bar ran recompute_matches' unconditional clear — right for its other callers, where the user *changed* the query and the old selection names nothing — and closing cleared it again, so select → Ctrl+F → Esc lost the selection every time. The seeded selection is now restored after the opening scan, and close_search no longer clears; a query the user actually changed still retires the stale selection, the discipline refresh_matches_after_output already stated. * fix(tabs): a zoomed pane stays zoomed across a tab switch (#599) Zoom was a window-level value that activate() cleared unconditionally, so looking at another tab and coming back restored the split layout — while a zoom is a tab's temporary view state, like its focused pane. It now rides with the Tab: activate stashes the outgoing tab's zoom and brings the incoming tab's back. The clears that genuinely reshape the layout (drag, split, close) still stand, and a stashed zoom whose pane exited while the tab was away is validated away rather than restored. * fix(tabs): track an open rename box by tree id, not index (#598) The rename box held only an index, which drifts the moment any other tab closes or the strip reorders — so close_tab_inner and apply_tab_order threw the half-typed name away on any unrelated tab event, and a reorder mid-rename still left a window where the commit landed on whichever tab had taken the index over. The box now names its tab by tree id end to end (start, render match, commit): only closing the renaming tab itself ends the rename, and the name lands on the tab the box was opened on wherever it has since moved. * fix(i18n): move seven hard-coded user-facing strings into the language tables (#602) Seven spots rendered English no matter which UI language was set: the shell-integration notice that explains why a wrapper was blocked or never engaged, the titles a pane wears once its process exits or the server loses it, the loopback forward's failure line, the tray tooltip that lists running agents (whose separator also wanted a CJK enumeration comma), the cursor-shape choices in settings, the command palette's empty-result hint, and the updater's install hint. Each is a L10nKey now with en/zh/ja entries, so the parity guard keeps them translated from here on. The palette's empty state was also wrong in content, not just language: every menu suggested connecting over SSH when nothing matched, including menus that have no hosts in them. The hint now only appears in the quick-connect menu; everywhere else the palette suggests a different search instead. Verified on Linux: the title/palette/tray suites (48 tests) and the i18n parity guard all pass. * fix(terminal): show remote path completion is listing, and say when it fails (#585) Tab-completing a path on a remote workspace had two silences. The whole network round-trip painted nothing, so a slow link read as a broken Tab key; and a listing that failed was unwrapped into an empty candidate list, so "the directory is empty" and "the listing never happened" ended in the same nothing. A pill over the pane's bottom-right corner — the style the integration notice already uses, factored out — now says the listing is running from the moment it starts, and a failed listing sets a notice with its error instead of the empty vector. The failure pill stays until the next keystroke dismisses it, and the trailing notify after an empty listing closes the menu brings the "listing…" pill down with it. Verified on Linux: the new gpui test covers the idle/listing/failed states, and the neighbouring completion tests still pass. * fix(files): quote cd Here / Insert Path for the shell the pane runs (#593) Both file-tree actions wrapped a path with spaces in POSIX single quotes whatever the focused pane's shell was. In cmd.exe a single quote is an ordinary character, so `cd 'C:\Users\me\My Documents'` split at the first space and cmd complained about 'C:\Users\me\My' — while the same action was fine in PowerShell and bash, which is why only cmd users ever saw it. shell_quote_for takes the pane's shell program (the pane already knows it — the settings page lists it) and picks double quotes for cmd.exe, single quotes for everything else; an unknown shell keeps the POSIX form, and a path that needs no quoting stays bare either way. Windows paths cannot contain a double quote, so the cmd form has nothing to escape. * fix(cli): pane close fails for a pane the registry does not hold (#588) `tty7 pane close %99` printed {"closed":[99]} and exited 0 for a pane that never existed. The workspace path cannot drift this way — PaneClose answers — but an orphan has no workspace to route through, so close hangs it up directly, and that kill is fire-and-forget: the daemon never says whether it knew the pane, so Ok(()) only ever meant the bytes reached the socket. A reaper script chasing the orphans `pane ls --all` points at would read the ghost success as cleanup done. The direct path now reads the running-pane registry once per batch and refuses ids it does not hold: the miss lands in `failed` with exit 1, next to the failures kill itself can report. A pane that exits between the listing and the kill is gone either way, which is what closing it wanted, so that race still reports closed. * fix(session): a launch that leaves workspaces running says so (#597) Quitting with several windows open and starting again restored only the most recent one; every other open window was marked detached — panes alive, nothing on screen, the only trace a "left N detached" log line. The workspaces were reachable from the sidebar, but nothing said they existed, so they were easy to forget entirely. restore_one now returns how many windows it detached, and both launch paths (normal startup and the CLI-driven open) push an in-app notification into the restored window naming the count and where to reopen them. The count rides the return value rather than firing the notification inside the store, because the store has no window to notify in — and a launch that detaches nothing, like the reattach-the-last- closed case, stays silent. * fix(switcher): list the local machine's orphan panes, with a way to close them (#596) A pane whose workspace went away — an interrupted `tty7 run`, a forgotten workspace that kept its shells — was invisible everywhere in the GUI: not in the sidebar, not in the switcher, not in the tray. It kept its process and its memory, and the only way to even learn it existed was the CLI's `tty7 pane ls --all`, which a GUI-only user never runs. The switcher's local machine group now carries a "Background panes" block under its workspace rows: one line per live pane the daemon's registry holds and no workspace does — id, owner, cwd — each with a Close button. The listing is the same PaneClient::list the CLI's reaper reads, fetched off the UI thread when the panel opens; closing kills and then re-lists, so a pane that survived simply stays on the list instead of pretending to be gone. The block steps out of the way while the search field holds a query, which narrows the panel to workspaces. Local on purpose: a remote machine's orphans belong to its own daemon, and routing a listing per host is what the CLI reaper is already for. The block joins no keyboard navigation — the panes are not workspaces and the arrows have no business landing on them. * fix(updater): keep Inno's progress window on screen during the install (#600) The Windows installer ran /VERYSILENT, so from the app quitting for the update to the watcher bringing the new build up — tens of seconds, longer under an antivirus scan — the screen held nothing at all: no window, no progress, no tray note. "Clicked update, the app vanished" reads as a crash, and double-clicking the icon does nothing while the files are being replaced. The installer now runs /SILENT instead. Nothing about the flow becomes interactive — /SP-, /SUPPRESSMSGBOXES, /NORESTART and /CLOSEAPPLICATIONS are untouched — but Inno's own progress window stays on screen for the gap, which is exactly the span the user had no word about. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
49901d7f8a |
fix(cli): let --enter press the key it is shorthand for (#581) (#606)
`--enter` is documented as sugar for `--key enter`, but the send dispatch counted only `args.keys`, so `tty7 send %42 --enter` answered "needs TEXT ... or a --key to press" and pressed nothing. The key list is now built before the dispatch and the dispatch counts it, so a marked address with `--enter` and nothing else runs what the pane already has typed, and a bare `send --enter` presses Enter where the caller sits. An unmarked id is deliberately left out of that promotion. #567 made the address slot take bare ids, and `send 83 --key C-c` addressing pane 83 is fine because `--key` says "press this" and nothing else. `--enter` does not: `send 2 --enter` reads at least as much like typing 2 into your own pane and running it, and turning it into a keystroke at pane 2 would be the silent retarget #567 spent its diff closing. It stays a loud error, now naming both spellings (`send %83 --enter`, `send %PANE 83 --enter`) rather than only the typing one. The reference, the bundled skill reference, `send --help` and the `--enter` help all said the old thing in slightly different words; they now say the same thing as each other and as the code. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
cb473daf27 |
fix(cli): refuse a broken send address instead of typing it into your pane (#538) (#567)
* fix(cli): refuse a broken send address instead of typing it into your pane (#538) A lone positional that starts with `%` but fails parse_pane (`%3x`) used to fall through to the text branch: the typo was typed into the caller's own pane and any --key followed it there, so one wrong character redirected an interrupt to whatever the caller was looking at. The guard now propagates the parse error when the `%` is followed by a digit — "clearly tried to write an address" — and leaves `%`-led text whose second character is not a digit (`%s/foo/bar/`, `%!sort`) on the text path it always was, per the review's narrowing. The explicit address slot also accepts bare ids now: `pane ls --json` prints `83`, not `%83`, and refusing the bare form made the workaround for the typo hole (`"%${TTY7_PANE#%}"`) uglier than the hole. This matches what pane_from_env already accepted and closes the missing-`%` variant of the same mistake. Tests cover the branch with a `Context { pane: Some("5") }` — every existing send test used `Context::default()`, where the fallback errors OUTSIDE_SHELL before the guard is reachable, which is why the hole had no test. Also correct the `ws rm` docs (#539): the reference claimed its panes become orphans found via `pane ls --all`, but the code has hung them up since #319; only a hang-up failure (reported by pane id) leaves orphans. The site reference, the skill reference, and `ws rm --help` now say so. * fix(cli): keep the send guard to what actually looks like an address The narrowing was described more widely than it works: a digit-led token that fails to parse (`3x`) still types, only `%` then a digit refuses, so the reference and the skill both promised an error that never comes. Say what the code does and point at the two-argument form as the way to type an address-shaped string anyway. Now that the `%` is optional, `parse_pane` also has to be stricter than `u64::from_str`, which accepts a leading `+`: a bare `+5` meant as text would otherwise address pane 5. An address is digits and nothing else, and `pane_from_env` delegates rather than repeating the read. The broken-address arm parsed twice and ended in an `unreachable!` that a future edit could walk into; one match on the parse result carries the error out directly. A lone bare id is the one behaviour this takes away, so it says how to type the number instead, and a test pins that it never quietly presses a key at the pane the id names. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
100904a313 |
fix(switcher): name another workspace's tabs from their terminal titles (#558)
A window names its own tabs from its live terminals' OSC titles; every other workspace it lists it reads out of the machine tree, which recorded each pane's foreground process name and never its title. So the naming fell through to the agent, and switching workspaces — which happens in place and drops the terminals the window was reading — turned the tabs of the workspace just left into a column of identical "Claude Code" rows. The daemon now sniffs OSC 0/2 and records the title beside the pane's cwd, capped at 256 characters, and `TabLabel` ranks it second only to a name someone gave the tab. The switcher puts it through the same abbreviation the tab strip uses, so a shell's `user@host:~/dir` reads `…/dir` in both places, and in a split the pane running an agent names the tab rather than whichever shell happens to be first. `tty7 tab ls` and workspaces on a remote machine were reading the same missing field and are named the same way now. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
b7196ae49a |
Give the side panel's Info tab rows that do what they show (#531)
* feat(right-panel): give the Info tab rows that do what they show The panel's Session table rendered every fact the same inert way, and its two actions sat in a strip of their own under the whole list — unlabelled, four rows below the path they acted on. Rows now carry their own shape: `changes` is the sidebar's green-and-red `+N −M` and opens the same diff overlay under the same setting, an agent wears the same status dot its tab does, and what a row can do appears at the end of it on hover, in the strip Source Control rows already use. A port row hands over its address instead of leaving it to be retyped, and the lit panel tile closes the panel the way every other activity bar does. * fix(right-panel): answer for the row the pointer is actually on Review of #531 found the new rows promising more than they could keep. Port rows keyed their element id on the port alone, but a port is only unique with its pid — a pre-forking server puts one row per worker on screen, and gpui handed them a single interactive state, so a click on one lit the tooltip and the pressed fill on all of them. The `changes` counts were read off `Tab::git_status`, which resolves a split tab to its *first* leaf, while the click target came from `detail_pane`, which resolves it to the *last focused* one. Inert text could disagree harmlessly; a button could not, and clicking `+2 −0` opened another pane's repository. Both now come from the pane the rest of the rows describe. A port is only `localhost` if localhost reaches it. `lsof`'s bind address was parsed and dropped, so a server on `172.17.0.1:8080` was offered as `localhost:8080` — a refused connection, or somebody else's service. `PortEntry` carries the address (`serde(default)`, so an older daemon still answers), and the wildcard and loopback binds keep the `localhost` spelling anyone would type. The browser tile hung off `remote_context()` — where the *shell* is — though the ports come from the pane's own process tree either way. It hid the tile on the one pane where it works, a `ssh -L` forward listening on this machine. It is about the host now. The action strip is opaque and pinned to the row's right edge, so on the working-directory row it covered the leaf that the head-first elision exists to preserve. The value holds that width back for good rather than on hover: taking it on hover would re-elide the path under the pointer, which is the pixel-shifting the strip is absolutely positioned to avoid. Also: the counts were `flex_1`, so the whole rest of the line was the button and empty space underlined numbers it was nowhere near; the agent pip was pinned in pixels inside rem-sized text and slid off its line at any interface scale but 100%, and drew Waiting as a thin ring where the tab strip punches a hole in a filled dot — one rule, two dialects; the panel-toggle chrome tile, which on macOS lives inside the panel it closes, still dropped focus into the destroyed element and left ⌘J dead; and `scm/detail.rs` kept a third copy of `ROW_INSET`. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
27880c0f14 |
feat(cli): wait on commands, press keys, and reap orphan panes (#505)
* feat(cli): wait on commands, press keys, and reap orphan panes
`tty7 wait` was the orchestration primitive for agents only. A pane with
nothing reporting agent status read as `idle`, so `--until idle` returned
success instantly about a shell that was midway through a build, and there
was no state that meant "the command finished" at all.
Panes with no agent now report `no-agent`, and `free` ends the wait when the
foreground command has exited — the process-tree question `procs` could
already answer but nothing could block on. `send --key` covers the keystrokes
text cannot express, which is what a worker stopped at `waiting` is usually
asking for. `pane close` takes several panes and `--orphans` clears what an
interrupted `run` leaves behind. `doctor` finally performs the hooks check
its own help has advertised.
The skill shipped in this repo predated `wait` entirely and taught a
hand-rolled `procs` polling loop with no notion of delegation; it now covers
the loop, and its agent statuses, `ws rm` orphan claim and not-implemented
list are corrected against the code.
* fix(cli): close the gaps review found in wait, --key and pane close
Five things the first pass got wrong, in the order they bite.
`--until free --changed` waited on a command it had already missed: the
"something ran" edge is only set by a poll that catches the pane busy, and a
command that starts and finishes inside one 500ms interval never is. That is
indistinguishable from a command that never ran, so the timeout now names both
doors instead of letting a finished build read as a hang.
`free` also outranked the agent ladder, which is backwards. A pane whose depth-0
process *is* the agent — the tree cannot tell that apart from a shell at its
prompt — reads free for its whole turn, so a `waiting` the caller explicitly
asked for could be overwritten by a process-tree fact and then withheld by the
`--changed` rule that comes with it. `free` is now consulted only when none of
the requested agent states answered, which is both cheaper and what the docs
already claimed. An empty process tree is "we could not see in" rather than
"free" for the same reason `no-agent` exists.
`--key M-X` sent `ESC x`: the whole spelling was folded to lowercase, which is
free for Ctrl (the C0 rule clears the case anyway) and wrong for Alt, where the
character rides through as itself.
`send --help` listed the key vocabulary by hand next to the table it is a list
of; it had already drifted by one alias. It is generated now.
And a `pane close` batch that could not close everything raised an error, which
left `--json` holding prose exactly when a cleanup script needs to know which
panes are still its problem. It exits 1 with `{"closed":[…],"failed":[…]}`, with
the complaint still on stderr so `-q` reports it.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
|
||
|
|
c138be687a |
fix(daemon): keep a pane's shell and its screen across a restart
Two things a pane lost when the background service stopped and started, both of them things the tree was the only possible place to keep. **The shell.** `PaneRecord` and `PaneSeed` carried a pane's cwd, its ssh spec and its agent, but never what it was running. A window rebuilding a dead pane from the tree therefore had nothing to pass and spawned on whatever the default shell is now — so a restart turned a bash pane into a PowerShell one, quietly and in place. The daemon resolves the override against the config at spawn time and is the only party that knows the answer, so it keeps it and reports it; the seed carries it too, for the panes a window spawned itself. A handoff carries it in the blob, because nothing on the far side of an `execve` can work out the command line of a child it never spawned. **The screen.** The startup sweep ran before the endpoint was listening, which is the one moment nothing can answer the question it asks: the registry is empty and the windows that know which screens are still wanted cannot say so yet. A tree that failed to parse made it worse — `read_machine` quarantines it and returns an empty `Machine`, so one bad file took every pane's stored screen with it. The sweep now happens only on the periodic pass, a tick later, with the registry filled in and the tree caught up; nothing is serving a request in between. Turning the setting *off* still clears the directory at once, because there the promptness is the whole promise. Two smaller ones alongside it: `restorable_pane_ids` now counts the tree's pane list and not only the panes some tab currently stands on — the two disagree while a window is between layouts, and being wrong costs a file swept a tick late in one direction and somebody's terminal in the other. And `restored_screen` drops the snapshot file *after* deciding it was not empty, so a snapshot holding nothing is no longer consumed by the request it could not answer. The restore path had no end-to-end test, which is how this shipped: the unit tests cover the file, not whether a window that reattaches is shown anything. The new one runs a real daemon, puts a marker on a real pane, stops the daemon, starts another, and reads the wire. |
||
|
|
9815f2d16f |
fix(windows): restore Ctrl+C in panes (#459)
The daemon was created with CREATE_NEW_PROCESS_GROUP, which disables Ctrl+C for the whole new group — and Windows hands that "ignore Ctrl+C" state down to every descendant. Every ConPTY shell a pane spawned inherited it, and so did everything those shells ran: the pane wrote 0x03 and conhost turned it into a keypress, but the CTRL_C_EVENT never came, so `go run` and `npm install` carried on. Git Bash looked fine only because MSYS synthesises SIGINT from the byte itself and never waits for a console event. DETACHED_PROCESS already leaves the daemon without a console for a control event to arrive on, so the group flag bought nothing to begin with. Both daemon spawn paths and tty7-cli's headless server — which spawns panes too — now share one constant without it, and DaemonPane::spawn clears any inherited ignore before it opens the pty, so a tty7 launched from a shell that already had the bit set is covered as well. The regression test has to inherit the state rather than switch it on in place, since that is the shape the daemon was in: an intermediate process created exactly as the daemon used to be runs both arms, and a pane must be interruptible only after the clear. Its observable is the shell rather than the interrupted command — after the ^C, cmd gets its prompt back and acts on the `exit` typed behind it — so it reads no message and holds on a non-English Windows. Fixes #451 Fixes #314 Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
8dbc7efa1a |
fix(cli): stamp panes with the workspace that holds them, not the client's name (#425)
* fix(cli): stamp panes with the workspace that holds them, not the client's name A pane's owner names the workspace allowed to attach to it. The CLI wrote a literal "tty7-cli" there for every pane it made, so a window opening on a CLI-built workspace found none of them attachable: it spawned a fresh shell for each tab, orphaned the live ones, and — because the tree still carried each pane's agent session — greeted the user with a failing `claude --resume <id>` in every one of them. Both spawn paths now pass the workspace id, and restore treats an owner that parses as no workspace as no claim at all, so panes already stamped by an older CLI attach instead of stranding. * fix(cli): let the OWNER column speak only when it disagrees with WS Now that a pane's owner is the id of the workspace holding it, printing both spells the same id twice on every row of `pane ls --all` — and buries the rows that matter. The column now shows a dash when the two agree, so what is left is exactly what is worth reading: a pane its holder may not attach to, and an orphan still naming where it belongs. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
09a653d10b |
fix(workspace): make the CLI and the GUI agree on what exists (#423)
Five places where a workspace, a tab or an attachment was real on one side of the socket and invisible on the other. They share a root: the GUI kept its own list of which workspaces exist (WindowViews on disk) and consulted the machine tree only for the ones already in that list, so anything created by another client was unreachable by construction. - The switcher lists workspaces the machine holds but this client has never opened, and opening one keeps its id instead of claiming a fresh one. - for_workspace_at hydrates whenever the machine holds tabs, so opening a workspace no longer saves an empty session over them. - finish_hydration writes a full window back over an empty tree, which is what puts a ws rm'd workspace back under the same id. - A deletion nothing has open is forgotten here too, instead of haunting the switcher until a restart. - Workspace::attachment travels over the wire (minus the token that proves the hold, which stays on the connection that owns it) and is stripped in persist, so tty7 ls can name the host holding a workspace. - tab ls / ws tree fall back through name -> agent -> cwd leaf -> process name, and tab ls grew a read-only GROUP column. - tty7 new --open raises a window on the workspace it just made. |
||
|
|
a89d9ea45b |
fix(cli): diagnose unavailable agent hooks (#321)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
7c9a2d20b6 |
fix(cli): terminate panes when closing tabs (#319)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
e8525e1b33 |
fix(cli): submit send enter outside paste bursts (#322)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
b7e08c7e11 |
feat(windows): add optional windows explorer context menus (#310)
* add CLI support for opening directories in new tabs f * feat(windows): add optional windows explorer context menus f * fix(gui): restore missing windows and reject lossy paths * fix(windows): harden explorer menu registration and native path handling * fix(cli): preserve native GUI paths on Windows --------- Co-authored-by: thomas <thomas@gmail.com> Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
9b62ef3f16 |
feat: add CLI support for opening directories in new tabs (#308)
* add CLI support for opening directories in new tabs f * fix(gui): restore missing windows and reject lossy paths |
||
|
|
71417782fb |
test(cli): close the raw/plain capture race in the e2e plain test (#299)
capture_plain_returns_text_not_escapes gated its byte-level asserts on the marker reaching the rendered capture, then asserted the raw capture already carried a CR. The two captures are separate snapshots taken in sequence, and on Windows ConPTY re-emits the echoed command in escape-laden bursts: the marker can render (from the typed input line) while the slightly earlier raw snapshot has yet to see a single CR — Enter's CRLF only arrives with the command's execution. CI hit exactly that window on x86_64-pc-windows-msvc. Make the CR part of the settle condition the loop polls for, alongside the marker, and name both in the timeout message so a genuine CR-stripping regression still reads as one. Co-authored-by: l0ng-ai <ysdpk123@gmail.com> |
||
|
|
b6c0d2636a |
fix(cli): make tty7 wait edge-aware and liveness-aware
Three holes in the wait primitive, all of which make a delegation loop answer with something other than what it asked for. The agent status the server keeps is a level, not an event: `done` stands until the next turn begins, `waiting` until the agent moves again. A wait issued right after a `send` therefore returned last turn's state before the worker had even read the input, and a second task in the same pane matched `--until done` instantly. `--changed` snapshots the position the wait arrived at — status plus the activity counter, which ticks even when the status letter does not — and refuses to match it; the JSON carries `stale` so a plain wait can tell whether the answer might be a leftover. `exit` was unreachable for any pane that had ever had an agent: the snapshot has no liveness in it, and the daemon keeps a dead pane registered until it is closed. A worker that crashed mid-turn reported `working` until the timeout. Liveness is now re-checked from the tree every few polls; the fast path where the first poll already answers still costs exactly one request. The "pane exited before reaching the awaited state" branch built its JSON and then threw it away on an anyhow error, leaving `--json` with nothing to read and the exit indistinguishable from an unreachable daemon. It now exits 1 with its report. Also: the wire spelling of a state is written out instead of derived from the variant name, the sleep no longer overshoots a near deadline, an absurd `--timeout` cannot overflow, and `--interval` is range-checked at parse time rather than silently clamped. |
||
|
|
e6bdf44f3c |
feat(cli): session CLI for scripting and agent orchestration (#248)
* feat(cli): `tty7 wait` + the agent-coordination note The two pieces of the original session-CLI PR that main's own CLI doesn't cover, rebuilt as a minimal delta against it. `tty7 wait %N --until waiting,done --timeout 600` blocks until a pane's agent reaches a requested state — the orchestration primitive that lets one agent sleep until its peer blocks on a permission prompt or finishes a turn, instead of screen-scraping. A poll of `AgentStates` rather than an `events` subscription on purpose: a one-shot stateless question composes into scripts, survives a server restart mid-wait, and needs no cursor management. Agentless-but-live panes read as idle via the machine tree; a dead or vanished pane reads as exit, which ends every wait (matched only when asked for). Timeout exits 124, the `timeout(1)` convention. The coordination note is discovery for the whole CLI: a marked, idempotent block describing the verbs, installed into ~/.claude/CLAUDE.md (always; CLAUDE_CONFIG_DIR honored) and ~/.codex/AGENTS.md (only when ~/.codex exists). A one-time "Let your agents coordinate?" prompt fires the first time a pane detects a coding agent; a Settings → Agents switch drives the same install/remove, with state read from the files themselves. Uninstall strips exactly the marked block; an unterminated block is left alone rather than truncated at a guess. * feat(agents): replace the global note with an orchestration skill Per review: global instructions tax every session's context and hand every agent — workers included — the ambient authority to orchestrate its neighbours. The common shape is primary → workers: one agent owns decomposition, dispatch, waiting and aggregation; workers just do bounded tasks. A Claude Code skill fits that exactly. `core::orchestration_skill` installs ~/.claude/skills/tty7-orchestration/SKILL.md — only its one-line description rides in context until the user or the primary agent explicitly invokes it, and workers never see it. The body can therefore afford the full delegation loop (tab new → send → wait → answer-or-capture → pane close) instead of a token-starved cheat sheet. The file is wholly tty7-owned: install is a plain overwrite (also the version-refresh path), and uninstall keys on an ownership marker so a user's hand-written skill under the same name is refused, not deleted. Gone with the global note: the first-agent-detected prompt, its config flag, and the CLAUDE.md/AGENTS.md writers — the Settings → Agents switch now drives the skill install instead. --------- Co-authored-by: l0ng-ai <ysdpk123@gmail.com> |
||
|
|
3847e4d015 | fix(ui): show process names in sidebar titles | ||
|
|
86eba1e2c2 |
feat(cli): make a captured pane readable, and stop panicking on a closed pipe
The CLI's own --help calls it "built for coding agents", but `capture` handed back the daemon's raw PTY bytes, which is the least readable thing it emits, and every verb panicked when its reader hung up. `capture --plain` replays those bytes through a terminal grid instead of stripping escapes from them, using the same alacritty_terminal rev the GUI renders panes with. The difference is not cosmetic: only the grid knows that a break at the pane's width was a wrap rather than a newline, that a CR meant "overwrite this line" rather than "end it", and which cell a wide char shares with its spacer. A regex gets the easy 90% and then invents the rest — on one real pane it turned 1193 lines into 2806. The size each segment needs comes for free: the daemon already sends DaemonMsg::Size right before every Snapshot, and the CLI was discarding it. Panes here measure 249 and 86 columns, so the hardcoded 120 would have wrapped both in the wrong places. Observing still resizes nothing. The pipe fix is two mechanisms with one contract. On Unix SIGPIPE goes back to its default disposition, which covers every write site at once and ends the process the way it ends `cat` (141). Windows has no such signal, so stdio::out recognizes the hung-up write and leaves quietly. Before this, 16 of 19 verbs printed a panic and a backtrace note for `tty7 ls | head -1`; `run` instead reported it as a failure with exit 1. Also adds skills/tty7, the Claude skill for driving this CLI. It shipped with a Python ANSI stripper, which is what prompted --plain; the script is gone. alacritty_terminal moves to [workspace.dependencies] so the GUI and the CLI cannot drift onto two revs of the fork. |
||
|
|
95ceed533d |
fix(cli): name a mistyped subcommand instead of offering it to the GUI
`tty7 tree` answered "launching the GUI is not wired up yet (would open tree)". Bare `tty7 [PATH]` opens the GUI, so any word clap has no subcommand for lands in that positional — every typo came back as an offer to open a directory named after it. A word with no separator, no leading ./~, and no matching file on disk is a mistyped verb, and is now reported as one. Real paths still reach the launcher and fail there for the honest reason. Also: `ws`'s help said "the named session trees", missed by the session -> shell pass, and long_about still advertised $TTY7_SOCKET, which no longer exists — it is $TTY7_CONFIG_DIR now. |
||
|
|
8000461706 |
fix(core): derive both endpoints from the config dir, publish the dir itself
Manual testing found `tty7 run`, `send`, `capture`, `procs` and `split` broken against any normally-installed server — the CLI's entire hot path. Only the control verbs worked. Two endpoints, two rules. The pane socket came from the config dir; the control socket ignored it and sat in $XDG_RUNTIME_DIR/tty7 or ~/.local/share/tty7 — under the same basename, `daemon.sock`. So they were told apart by directory alone, and the CLI, handed one path in TTY7_SOCKET, reconstructed the other with with_file_name: on the default layout that returns the input unchanged. Pane verbs dialed the control socket and the daemon hung up on them. A --config-dir server was worse: it published the *default* control socket to the shells it spawned, so a CLI inside an isolated instance drove a different server. The e2e suite passed throughout because its harness set TTY7_CONTROL_SOCK explicitly, placing both endpoints in one directory under different names — a layout production never produces. It had removed the bug's precondition. Now: the control socket is derived from the config dir like the pane socket (control.sock beside daemon.sock, mirroring Windows' control.port/daemon.port, with -control on the hashed fallback so the two cannot collide), and panes are handed TTY7_CONFIG_DIR instead of a socket path. A CLI inherits it, so ControlClient::connect and PaneClient::local resolve the same two sockets the server opened, through the same functions. No second derivation to disagree. remote_link's remote_control_socket was a third copy of the old rule, used to locate a remote server's endpoint before connecting; it follows the config dir too, and the env probe now reads $TTY7_CONFIG_DIR. Drops the CLI's server-lifecycle guard: stop/start already follow the config dir through transport::connect and --config-dir, so there is no longer a mismatch to refuse. The e2e case that covered only `status` over a lone variable now also runs a pane verb — the asymmetry it missed is exactly what broke. Note: this moves the control socket for existing installs. A running pre-change daemon will not be found at the new path, which is the honest outcome — its control dialect is v3 against this build's v4, so reaching it only produced a version error anyway. |
||
|
|
54f498aa6c |
fix(cli): surface orphan panes, answer --json everywhere, scope the server verbs
An interrupted `tty7 run` leaves its pane running with nothing referencing it: no workspace holds it, every listing walks the tree, and the orphan sweep only logs. `pane ls --all` reads the server's registry instead and marks what nothing holds; `pane close` falls back to hanging a pane up directly when no workspace can route a PaneClose, so an orphan is stoppable. --quiet silenced failures as well as successes, leaving a bare exit code and nothing to debug; it now suppresses only output on success, and covers --json too. `run` exited through a path that skipped the report entirely, so `run --json` printed nothing at all; it now carries its report, with exit_code_known distinguishing the command's own 1 from the stand-in 1. The server lifecycle verbs can only drive the default endpoint — spawn::stop dials transport::connect() — while every other verb follows $TTY7_SOCKET. They now refuse when that names a different endpoint rather than acting on a different server than `tty7 status` reports on. Also: tables pad by display width, so a CJK name or path no longer skews every column after it; --h/--v become --horizontal/--vertical with the short forms kept as aliases; the verbs that are not implemented say so in --help instead of only at runtime; capture's help admits it decodes as lossy UTF-8. |
||
|
|
794ae89d24 |
feat(cli)!: drop the attach verb and the design doc
The CLI's user is the coding agent; run/send/capture/events cover it. Workspace-level ws attach/detach stays. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv |
||
|
|
05dffdf1f4 |
fix(cli): send rides the one-shot SendInput instead of a preempting attach
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv |
||
|
|
b46183688e |
fix(cli): review findings — CI coverage, kept-pane filing, endpoint and lifecycle honesty
- workspace: tty7-cli joins default-members, so a bare root cargo test runs it - run --keep files the pane into its workspace via TabCreate (and refuses to keep a pane no workspace would list); --ws help says what it really does - server start|stop|restart|logs refuse -m instead of silently acting locally - server start kills the spawned process when it never opens its endpoints - -m over a down link is refused instead of redialing with auto auth - capture help tells the truth: raw ANSI bytes, last ring segment by default - a missed exit-code probe exits 1 with a stderr note, not a fabricated code - TTY7_SOCKET is honored: control dials it, the pane endpoint is its sibling - attach's success JSON says attached, not detached_from - e2e daemons ride a KILL_ON_JOB_CLOSE Job Object on Windows, so a hard-killed harness cannot leak servers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv |
||
|
|
fafb0019f4 |
feat(cli): real server backend — every non-interactive verb goes live
StubBackend is gone; RealBackend lazily opens a ControlClient for control ops and a PaneClient for pane ops. Backend::spawn_shell now returns the daemon-assigned pane id, so every creating verb (new, tab new, split) spawns first and seeds the tree op with the real id — client-side pane-id allocation is deleted. Live end to end: ls, ws/tab/pane verbs, new, send (attach-input-detach), capture (observer replay; --scrollback = whole ring, default = the ring's last segment), procs, run (streams output, passes the child's exit code through, --keep leaves the pane), events (human lines or NDJSON), agents, status, machine ls, and doctor's server half (reachability, dialect, status, links). tty7 server start|stop| restart|logs manage a sibling/PATH/TTY7_SERVER_EXE tty7-server. -m routes both channels over the local server's link, resolved against Routes by key or bare host; jump/proxy-chained keys are refused with the reason. Interactive attach stays stubbed for the next slice. A harness-free e2e suite drives the compiled tty7.exe against an isolated real server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv |
||
|
|
917d09f014 |
feat(cli): tty7-cli crate skeleton — grammar, addressing, backend seam, output
The thin console CLI from docs/cli-design.md, transport-free for now: - clap grammar for the full documented surface: ws/tab/pane/machine/server nouns, the hot-path top-level verbs (ls/attach/run/new/split/send/capture/ procs/agents/events/status/doctor), global -m/--json/-q, `run -- <cmd...>`, and bare `tty7 [path]` parsing as the GUI launcher stub. - tmux-style addressing (%42 pane, @7 machine-wide tab ordinal or @<uuid>, workspace by name / id / unique id prefix) with implicit-context fallback to TTY7_PANE / TTY7_WS and a "not inside a tty7 shell" error naming the fix. - Backend trait as the integration seam: control() speaks real tty7_core::daemon::control ControlRequest/ReplyOk values, plus declared pane-side entry points (spawn_shell/send_input/capture/procs/attach_pane/ run/events). StubBackend fails loudly until the transport client lands; MockBackend asserts the exact request shapes every structural verb builds. - Plain aligned tables and trees for ls/tree/pane ls, one JSON object per command under --json. Exit codes: 0 ok, 1 failed, 2 usage (clap default). agents/status/machine ls stay stubbed: they need ControlRequest::AgentStates/ Status/Routes, which another slice is adding; ws stop and server start/stop likewise wait on their mechanisms rather than inventing protocol. Build/test this package alone (cargo build -p tty7-cli): its bin is named tty7 and collides with the GUI bin until that one is renamed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv |