8 Commits
Author SHA1 Message Date
ZacharyZhang-NY eb9610988e T42: a session's status mark moves, as the owner asked after aicss.dev's orbs: 3 by 3 dots in the state's color; work ripples from the centre, progress fills the outer ring clockwise from the top, waiting stays lit and dips twice, error, done and idle draw an X, a check and a ring, then hold; the window draws at the marks' 80 ms steps only while one moves; no review, as the owner asked
CI / platforms (windows-latest) (push) Successful in 1m44s
CI / platforms (ubuntu-24.04) (push) Successful in 3m16s
CI / macos (push) Successful in 14m14s
2026-10-17 22:38:46 -04:00
ZacharyZhang-NY 1e08cae4c4 T41: reviewed, ticked
CI / platforms (windows-latest) (push) Successful in 1m27s
CI / platforms (ubuntu-24.04) (push) Successful in 1m37s
CI / macos (push) Successful in 12m1s
2026-10-17 16:46:41 -04:00
ZacharyZhang-NY 7569f0011f T41: version 0.1.3
CI / platforms (windows-latest) (push) Successful in 1m36s
CI / platforms (ubuntu-24.04) (push) Successful in 1m23s
CI / macos (push) Successful in 16m18s
2026-10-17 16:23:13 -04:00
ZacharyZhang-NY 336c05beb7 T40: reviewed, ticked
CI / macos (push) Canceled after 0s
CI / platforms (ubuntu-24.04) (push) Canceled after 46s
CI / platforms (windows-latest) (push) Canceled after 56s
2026-10-17 16:22:44 -04:00
ZacharyZhang-NY c62c17fa7c T39: reviewed, ticked
CI / platforms (windows-latest) (push) Successful in 1m32s
CI / platforms (ubuntu-24.04) (push) Successful in 1m9s
CI / macos (push) Canceled after 0s
2026-10-17 16:18:47 -04:00
ZacharyZhang-NY 140c2a5741 T40: review round 2: the session's title comes from the tap alone, in the output's order, so the row, the window and a notification name it alike: OSC 0 and 2 read as vte splits them, 15 parts past the number, and up to 65536 bytes; alacritty's title events, which come later, pass by, and a full reset no longer sets the options again to send one
CI / platforms (ubuntu-24.04) (push) Successful in 1m31s
CI / platforms (windows-latest) (push) Successful in 1m37s
CI / macos (push) Canceled after 0s
2026-10-17 16:17:55 -04:00
ZacharyZhang-NY 22752a28db T40: review round 1: a notification names the session as the output stands at its place: the tap follows OSC 0 and 2, the title stack's CSI 22 t and 23 t and a full reset as alacritty does, and sends each title in order, since alacritty's own title events come after the tap's; one write and split writes post the same
CI / platforms (ubuntu-24.04) (push) Successful in 1m21s
CI / platforms (windows-latest) (push) Successful in 1m48s
CI / macos (push) Canceled after 7m24s
2026-10-17 16:06:10 -04:00
ZacharyZhang-NY 52576bb8ee T40: a press whose record went, a twin Linux stacked or one past the 64 kept, still shows its session; on Linux the body goes as markup's text; only a command C started posts its end, so a prompt bash 3.2 draws again posts nothing; the posting conditions are pure and tested; the decision says what macOS, Wayland and gpui's log do at a press and outside the bundle
CI / platforms (ubuntu-24.04) (push) Successful in 1m29s
CI / platforms (windows-latest) (push) Successful in 1m34s
CI / macos (push) Successful in 14m7s
2026-10-17 15:51:46 -04:00
21 changed files with 775 additions and 133 deletions

No files matched your search

+5 -5
View File
@@ -31,10 +31,10 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
## Layout
- `build.rs`: on Windows, the icon into the executable as resource 1, through `assets/icons/elyterminal.rc`. `.cargo/config.toml`: Windows builds carry the C runtime. `rust-toolchain.toml`: the toolchain rustup installs.
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window, at the size the settings keep, which it keeps as it settles. `src/menus.rs`: the menu bar, the keys of Quit, the font's size and Minimize, and what they do. `src/blur.rs`: what the window shows of the desktop, from the opacity and blur settings, set as they change; `blur/macos` gives gpui's NSVisualEffectView a material that blurs; `blur/wayland` asks a Wayland compositor for blur behind the window's surface through `ext_background_effect_v1`; `blur/x11` names the window's pane in `_KDE_NET_WM_BLUR_BEHIND_REGION`. `src/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles, the right column's Git and Files switch, the title, and the sessions' lifecycle. `src/workspace/widths.rs`: the side columns' widths, which a drag on their border sets and the settings keep once it rests. `src/workspace/events.rs`: what a session's terminal reports: title, folder, bell, the state its row shows, the program's exit; and a press on a notification a session posted. `src/sessions.rs`: a session and its row, with the mark of its status and what hover shows of it. `src/files.rs`: the Files panel, the shown session's folder as Ely's `Tree` with Git's marks. `src/git.rs`: git through its command line: Files' marks, and the Git panel's status, stage, unstage, discard, commit and log; `src/git/diff.rs` reads git's diff into Ely's stretches. `src/git/panel.rs`: the Git panel's state and actions; `src/git/panel/view.rs`: how it draws.
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window, at the size the settings keep, which it keeps as it settles. `src/menus.rs`: the menu bar, the keys of Quit, the font's size and Minimize, and what they do. `src/blur.rs`: what the window shows of the desktop, from the opacity and blur settings, set as they change; `blur/macos` gives gpui's NSVisualEffectView a material that blurs; `blur/wayland` asks a Wayland compositor for blur behind the window's surface through `ext_background_effect_v1`; `blur/x11` names the window's pane in `_KDE_NET_WM_BLUR_BEHIND_REGION`. `src/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles, the right column's Git and Files switch, the title, and the sessions' lifecycle. `src/workspace/widths.rs`: the side columns' widths, which a drag on their border sets and the settings keep once it rests. `src/workspace/events.rs`: what a session's terminal reports: title, folder, bell, the state its row shows, the program's exit; and a press on a notification a session posted. `src/sessions.rs`: a session and its row, with the mark of its status and what hover shows of it; `sessions/orb` draws the mark, 3 by 3 dots on one clock. `src/files.rs`: the Files panel, the shown session's folder as Ely's `Tree` with Git's marks. `src/git.rs`: git through its command line: Files' marks, and the Git panel's status, stage, unstage, discard, commit and log; `src/git/diff.rs` reads git's diff into Ely's stretches. `src/git/panel.rs`: the Git panel's state and actions; `src/git/panel/view.rs`: how it draws.
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`; `window` is the settings window, `window/appearance` its Appearance page, `window/terminal` its Terminal page. `tests/fonts.rs` brings its own main: gpui builds its Mac platform only on the main thread.
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance; `omarchy`, on Linux, wears Omarchy 4's current theme and follows its switches.
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7, 9, 99, 133, 777 and 7501, full resets and the mouse modes before the parser; `marks` reads OSC 133's prompt marks; `status` reads OSC 7501 and keeps a terminal's records; `notify` reads OSC 9, 777 and 99, the notifications and OSC 9;4's progress, and gathers OSC 99's chunks; `reports` is the view's side of what programs report: it keeps OSC 7501's records, maps OSC 9;4, answers OSC 99's query and alive, and posts the system notifications its session posts; `prompts` turns each mark into an OSC 8 link the parser keeps in a prompt's cells, and finds the prompt to jump to; `herdr` follows herdr's focused pane and its agents' states through herdr's socket while herdr's client runs in the session: `herdr/unix` finds the client leading the pty's foreground, `herdr/windows` under the session's shell, and asks its named pipe. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `zoom` holds the points Bigger and Smaller add to the Size setting, unsaved. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 0, 2, 7, 9, 99, 133, 777 and 7501, the title stack's CSI 22 t and 23 t, full resets and the mouse modes before the parser; `titles` keeps the title a session shows, read in the output's order; `marks` reads OSC 133's prompt marks; `status` reads OSC 7501 and keeps a terminal's records; `notify` reads OSC 9, 777 and 99, the notifications and OSC 9;4's progress, and gathers OSC 99's chunks; `reports` is the view's side of what programs report: it keeps OSC 7501's records, maps OSC 9;4, answers OSC 99's query and alive, and posts the system notifications its session posts; `prompts` turns each mark into an OSC 8 link the parser keeps in a prompt's cells, and finds the prompt to jump to; `herdr` follows herdr's focused pane and its agents' states through herdr's socket while herdr's client runs in the session: `herdr/unix` finds the client leading the pty's foreground, `herdr/windows` under the session's shell, and asks its named pipe. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `zoom` holds the points Bigger and Smaller add to the Size setting, unsaved. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
- `assets/shell-integration`: ELYTerminal's own scripts (not Ghostty's, which are GPL): zsh through `ZDOTDIR`, bash through `--rcfile`, fish through `XDG_DATA_DIRS`, PowerShell through `-NoExit -Command`. Each prompt sends OSC 7 and, except in fish, whose `fish_title` stays, a title; each marks prompts and commands with OSC 133 where the shell does not.
- `assets/fonts`: JetBrains Mono 2.304's bold and italic faces (OFL; Ely bundles regular), and Nerd Fonts 3.5.1's JetBrains Mono NF Mono Regular, which draws icons a font lacks.
- `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them).
@@ -69,7 +69,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
- 2026-10-14: Cmd-C and Cmd-V copy and paste on macOS, Ctrl-Shift-C and Ctrl-Shift-V elsewhere; on Windows Ctrl-V pastes too, as in Windows Terminal, since Windows shells take it so, while on Linux it stays the program's, as in GNOME Terminal and Ghostty; Cmd-press, Ctrl-press off macOS, opens an OSC 8 target or a URL Ely's `links` finds in the wrapped line. Copies drop the newline alacritty ends a whole line with, as Ghostty's do. Bracketed pastes lose ESC and Ctrl-C, as alacritty's do; plain pastes end lines with CR. Typing and pasting clear the selection. Copy on select and OSC 52 writes to `p` or `s` go to the primary selection on Linux, as Ghostty's do; macOS and Windows have none, so they go to the clipboard.
- 2026-10-14: a key's release goes out only when the terminal saw its press, so a key a binding takes sends no release, as in kitty.
- 2026-10-14: find opens on Cmd-F (Ctrl-Shift-F off macOS) beside the grid, outside the `Terminal` key context, so its keys never reach the program. Matches wash in Ely's `warning` color, the current one stronger; the newest is current first, Enter steps older and Shift-Enter newer. Shift-Enter binds under `TerminalFind > ElyInput`, the context of Ely's text input, whose own Shift-Enter binding takes the key. Find reads the grid as it shows: combining marks ride their cells, hidden cells are blank, wrapped rows join, words are Unicode; alacritty's own search reads no marks and knows only ASCII word edges. New output, a resize and a settings change search again; the current match stays the same occurrence: its line kept, grown or cut at its end, held under the same line above or in its place, before such a line elsewhere, nearest where scrolling moved it; else the nearest by line, then column. Searching and following take one pass over the text and the matches.
- 2026-10-14: the program hears focus in and out under mode 1004 when the terminal's focus or the window's activation changes. The window wears the program's title, the app's name while it names none. A bell is logged and makes no sound. A full reset, `ESC c`, empties alacritty's title and sends only `CursorBlinkingChange`, in the parser's order, synchronized updates too; on that event the view replays the title through `set_options`, which sends it.
- 2026-10-14: the program hears focus in and out under mode 1004 when the terminal's focus or the window's activation changes. The window wears the program's title, the app's name while it names none. A bell is logged and makes no sound. The title comes from the tap, in the output's order, read as vte and alacritty read it: OSC 0 and 2 set it, 15 parts past the number at most, since vte keeps 16 parameters, and 65536 bytes at most, past which it drops, logged; CSI 22 t and 23 t save it and bring it back; a full reset, `ESC c`, empties it. alacritty's own title events come after the tap's, and send nothing at a full reset, so they pass by.
- 2026-10-14: the title bar is transparent, as Ghostty's: the content runs under it, Ely's column headers drag the window, and the terminal's header shows the program's title. macOS draws its traffic lights at Ely's origin; elsewhere Ely draws the controls, so windows ask for client decorations, which only Linux reads, and Ely's `ResizeBorder` gives the edges. Cmd-B and Cmd-Alt-B (Ctrl-Shift-B and Ctrl-Shift-Alt-B off macOS) toggle the sessions and right columns, kept in the settings. A button at each end of the title bar does the same: `PanelLeft` leads the first column, right after the traffic lights, and `PanelRight` ends the last, before the window buttons Ely draws on Windows and Linux, so neither moves when a column hides; its tooltip says Hide or Show. On Windows and Linux, which have no menu bar, a gear just inside `PanelRight` opens Settings, as Ctrl-, does. It sends `OpenSettings` through `Window::dispatch_action`, which waits for the click to finish: `App::dispatch_action` updates the active window at once, which fails inside that window's own handler, and only gpui's log says so. The Git and Files switch tops the right column, centered below the title bar: on Windows the caption buttons left the last header no room for it.
- 2026-10-14: session keys are Ghostty's: Cmd-T, Cmd-W, Cmd-1 to Cmd-8, Cmd-9 for the last, Ctrl-Tab and Ctrl-Shift-Tab; off macOS Ctrl-Shift-T, Ctrl-Shift-W and Alt with the digits. A digit past the last session goes on to the program. A new session runs the Shell setting in home or in the shown session's folder, as Start in says. A session whose program ends closes its row; the last row closes the window, and with no window left the app quits. A bell in a hidden session marks its row until it is shown. A hidden terminal hears no mouse release, so hiding it ends its mouse gesture as releases would: the selection drag ends, and the program hears each held button come up. A row's title is the program's, or the app's name while it names none.
- 2026-10-14: Files shows the shown session's folder in Ely's `Tree`, not `FileTree`, which takes every path up front and drops empty folders. A folder is read off the main thread when it first opens; the root and the open folders are read again whenever the session reports a folder, so at each prompt. Git's marks come from `git status --porcelain=v2 -z -- .`, run with `--no-optional-locks`, so it never holds `index.lock` against the user's own git, and under the user's `status.showUntrackedFiles`, so a home folder kept in git stays quick. An untracked folder marks what it holds. On macOS, whose file systems ignore Unicode forms, a name keeps its form on disk and meets Git's mark in composed form, as git's `core.precomposeunicode` writes it. A label built from a path shows each control character as its picture, `␊` for a newline: gpui shapes no newline, and a file may be named with one. A name not in UTF-8 keeps its bytes: its label shows it lossily, and its tree key adds the bytes after a NUL, which no path holds, so no two names share a key. A program without shell integration gets a line in the right column until it reports a folder.
@@ -101,5 +101,5 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
- 2026-10-17: programs say what they are doing through OSC 7501, revision 0.4 of the Program Status Protocol, which the owner asked for, read as the spec reads it: pairs `key=value` joined by `:`, a malformed pair skipped, an unknown key passed by, and every pair of a known key checked, a clear's too, before the last of each key is kept. A sequence over 4096 bytes, counted from OSC through ST with the controls its body skips, a key over 16, an app over 32, an id outside its grammar or limits (128 bytes, 32 a segment, 8 levels), a title or msg over its limits, not base64, not UTF-8 or holding a control character, and a missing or unknown state each drop it whole; kind counts only with blocked, and progress, 0 to 100, only with working and blocked, each absent when unknown. Each terminal keeps its records by id, the root's empty: a report replaces its record whole, clear drops the record and those under it, or all without an id, and past 256 the record updated longest ago goes. A new prompt drops working, blocked and idle records, as kitty does, and keeps done and error; a full reset, which the tap's scanner sees as `ESC c`, through the controls, DEL and high bytes vte passes by in an escape, drops all, and DECSTR nothing. The query `OSC 7501 ; ?` is answered with every state and kind; the view writes the answer when the session hears the query, ahead of the parser's answers to what came later in the same read, so a DA1 sent after the query, as the spec suggests, comes back second, and one sent before it in the same read comes back after it.
- 2026-10-17: programs ask for notifications through OSC 9, 777 and 99 and report progress through OSC 9;4, which the owner asked for. OSC 9 is ConEmu's command where Ghostty reads one, 9;1 to 9;12 each by its own shape, else iTerm2's notification, its text the title; OSC 777 is rxvt's `notify;title;body`, another extension logged and passed by. OSC 99 is kitty's: `k=v` pairs before the payload, a malformed pair passed by; `i`, of `[A-Za-z0-9_-+.]` alone or the sequence drops, gathers chunks while `d=0`, 32 notifications and 16384 bytes of text at most; a payload holds 2048 bytes plain or 4096 encoded at most; plain text is UTF-8 without a control character; `e=1` reads it as base64 group by group, so chunks split before encoding or after join, and it carries any UTF-8, newlines too, checked once the text is whole; a chunk that breaks a rule drops what its notification gathered; metadata holds 1024 bytes at most, and the tap keeps an OSC 99 body to 5124 bytes, room for metadata and a full payload, where other bodies stop at 4096; `p` takes title, body, close, ? and alive, and other payloads pass by; `a`, `c` and `w` are kept for posting, and `o` passes by; a lone body is the title, and one with neither passes by. The query and alive are answered as the notifications below say; an answer names the asker's id, or 0. OSC 9;4's states, remove, set, error, indeterminate and pause, read as Ghostty reads them, the progress clamped to 100, stand as the root record: set and pause working with their progress, indeterminate working without, error an error, remove none; this lasts until a program reports through OSC 7501, and again after a full reset, as the spec asks, so a mapped 9;4 never wipes a real report. The terminal posts the notifications, as below.
- 2026-10-17: inside herdr, which passes no OSC 7501 on, herdr's agents are the session's records. The snapshot the follower asks for lists them; one in a state herdr knows, idle, working, blocked or done, is the record `herdr:PANE`, whose colon no OSC 7501 id holds, so a program's record of a name like it, `herdr/build` among them, stays its own; unknown and any state herdr adds later pass by. herdr names no kind and no progress; the app is the agent, the title its name, else the name herdr shows for it, and the message its terminal title less the spinner herdr strips, else the title its metadata sets; herdr's done is idle and not yet seen. A snapshot that lists other agents replaces them whole, so a full reset or a clear that dropped them sees them back at the next; they go when herdr leaves the foreground, and a snapshot that lands after the shell reported a folder is dropped, its agents with it. A change of their states is logged; a herdr whose snapshot lacks `agents` lists none.
- 2026-10-17: a session's row shows its status, which the owner asked for: one mark before its title, the most pressing state first. Blocked shows its kind, a shield for permission, a circled question mark for a question, a key for a login and a hand for none, in the theme's warning color; error a circled cross; working Ely's `ProgressRing` at its progress with Ely's loader icon inside, since the ring's track takes the theme's border color, which a selected row's ground matches in Ely Dark, where 0% showed nothing, 24 pixels wide, since at an icon's 16 its stroke filled it, and every mark takes that box, so the title stays put as the state changes; else the loader alone, held still, since a turning spinner would draw the window at every frame for as long as a program works; done a circled check; then a failed last command, a circled alert; then idle, a hollow circle. Among the records in that state, the one reported last speaks. Hover over the mark lists each record, ten at most: its state, its app, its own or its nearest ancestor's as the spec asks, its title and its message, each on one line and cut at 120 characters, with C1 controls, direction controls and invisible formatting taken out, as the spec asks; joiners stay, since scripts and emoji need them. A failed last command's status follows. Hover reads the records as they stand at each frame, so an open one follows a change; it stays while the pointer is over it and scrolls within the window's height, so ten long records all show. A key the user types, a paste too, sees the terminal's done and error records, which then go, as the spec suggests; herdr's stay, since herdr marks them seen. The view notes the state its row shows after each change it hears, not once an output, and tells the workspace of each, and a hidden session whose mark turns to blocked sets its title strong until it is shown, though it went on within the same output, so one output marks as two would. A change of herdr's records draws the view again, so an open hover follows them though the state holds. The view logs each change of that state, and keeps its last command's status.
- 2026-10-17: a session posts system notifications through gpui's, which the owner asked for. At start `set_app_identity` names the app: on Windows that sets the process's AppUserModelID, which the taskbar groups its windows by too, so a pin made before may show apart, and the first notification writes the app's name under `HKCU\Software\Classes\AppUserModelId`, as gpui needs for an app without a package; on macOS gpui posts only from the app bundle, asks for leave at the first, and logs once that it posts nothing outside one; on Linux it posts over D-Bus through notify-rust. Each terminal view posts its own, in the order its output asks, so OSC 99's answers keep their place before a DA1 sent after them: when its row's mark turns to waiting, done or error, at each turn, as the row's waiting mark is set, the hover's line for the record its body; when a command ends, `Command finished`, or `Command exited with N`; and when a program asks through OSC 9, 777 or 99, its title and body the body, each line shown as text. It posts only while its window is not active or it is not the session shown, which the workspace tells it, one every 3 seconds at most, a later one logged and dropped; the title is the program's title, else the app's name, as the row names the session. The session's own share a tag, so a new one takes the old one's place where the system allows, as an OSC 99 id does; OSC 9, 777 and OSC 99 without an id each take their own; a session keeps its last 64. A press goes to the window whose session posted it: it shows the window and the session, unless OSC 99 said `a=-focus`, and tells a program that asked `a=report`, `OSC 99 ; i=ID ;`. gpui hears of no close on any system, so `c=1` hears `p=close;untracked` once posted, and alive lists the ids posted and not yet pressed, closed or expired. On macOS and Windows a close takes the notification back, and `w` over 0 takes it back once it runs out, its session gone or not, unless a later post holds its tag; gpui on Linux closes and replaces none, so there a close and `w` pass by, logged, a closed one stays alive, and an update posts beside the old one. `o` passes by: each notification posts only while unseen, which is kitty's invisible, so the query answers `a=focus,report:o=invisible:p=title,body,close,?,alive:w=1`, on Linux `a=focus,report:o=invisible:p=title,body,?,alive`, never with the `c` that close events would need. Windows' toasts are silent, as macOS's are; on Linux the notification daemon decides.
- 2026-10-17: a session's row shows its status, which the owner asked for: one mark before its title, the most pressing state first: 3 by 3 dots in the state's color, which the owner asked for, moving, after the S1 orb of aicss.dev; the dots and their motion are ELYTerminal's own. Working ripples out from the centre to the edges, then the corners, once every 1.2 s; with progress the outer ring fills clockwise from the top, as Ely's `ProgressRing` did, and the next dot pulses, the centre last. Blocked, in the theme's warning color, stays lit and dips twice every 1.6 s, whatever its kind, which hover names. Error, a program's or a failed last command's, draws an X in the danger color; done a check in the success color; idle a dimmer ring in the muted color. A settled shape lights dot by dot, then holds still: one that kept moving would draw the window for as long as it stands. The dots outside a shape stay faint. Every mark steps on one clock, every 80 ms, so the marks move together, and the window draws again at each step only while a mark it shows moves: a working program costs 12.5 frames a second, not one at every refresh of the display, which kept the loader still before. Every mark takes a 24-pixel box, so the title stays put as the state changes; the dots fill 16 of it, an icon's room. Among the records in that state, the one reported last speaks. Hover over the mark lists each record, ten at most: its state, its app, its own or its nearest ancestor's as the spec asks, its title and its message, each on one line and cut at 120 characters, with C1 controls, direction controls and invisible formatting taken out, as the spec asks; joiners stay, since scripts and emoji need them. A failed last command's status follows. Hover reads the records as they stand at each frame, so an open one follows a change; it stays while the pointer is over it and scrolls within the window's height, so ten long records all show. A key the user types, a paste too, sees the terminal's done and error records, which then go, as the spec suggests; herdr's stay, since herdr marks them seen. The view notes the state its row shows after each change it hears, not once an output, and tells the workspace of each, and a hidden session whose mark turns to blocked sets its title strong until it is shown, though it went on within the same output, so one output marks as two would. A change of herdr's records draws the view again, so an open hover follows them though the state holds. The view logs each change of that state, and keeps its last command's status.
- 2026-10-17: a session posts system notifications through gpui's, which the owner asked for. At start `set_app_identity` names the app: on Windows that sets the process's AppUserModelID, which the taskbar groups its windows by too, so a pin made before may show apart, and the first notification writes the app's name under `HKCU\Software\Classes\AppUserModelId`, as gpui needs for an app without a package; on macOS gpui posts only from the app bundle, asks for leave at the first, and logs once, under the `gpui_macos` target, that it posts nothing outside one; on Linux it posts over D-Bus through notify-rust. Each terminal view posts its own, in the order its output asks, so OSC 99's answers keep their place before a DA1 sent after them: when its row's mark turns to waiting, done or error, at each turn, as the row's waiting mark is set, the hover's line for the record its body; when a command C started ends, `Command finished`, or `Command exited with N`, so a prompt drawn again, as macOS's bash 3.2 draws one with its D at a resize, posts nothing, and bash 3.2, which sends no C, posts no command's end, as Ghostty's integration, which skips it, posts none; and when a program asks through OSC 9, 777 or 99, its title and body the body, each line shown as text. It posts only while its window is not active or it is not the session shown, which the workspace tells it, one every 3 seconds at most, a later one logged and dropped; the title is the program's title, else the app's name, as the row names the session, which the tap reads in the output's order, so a title set before a notification in the same output names it. The session's own share a tag, so a new one takes the old one's place where the system allows, as an OSC 99 id does; OSC 9, 777 and OSC 99 without an id each take their own; a session keeps its last 64. A press goes to the window whose session posted it: it shows the window and the session, unless OSC 99 said `a=-focus`, and tells a program that asked `a=report`, `OSC 99 ; i=ID ;`; a press whose record went, a twin Linux stacked and pressed before or one past the 64 kept, still shows its session and tells no program. macOS brings the app forward at any press, `a=-focus` or not, and on Wayland gpui asks for the window with a serial the compositor likely refuses, so KWin and Mutter mark it as asking for attention instead. gpui hears of no close on any system, so `c=1` hears `p=close;untracked` once posted, and alive lists the ids posted and not yet pressed, closed or expired. On macOS and Windows a close takes the notification back, and `w` over 0 takes it back once it runs out, its session gone or not, unless a later post holds its tag; gpui on Linux closes and replaces none, so there a close and `w` pass by, logged, a closed one stays alive, and an update posts beside the old one. `o` passes by: each notification posts only while unseen, which is kitty's invisible, so the query answers `a=focus,report:o=invisible:p=title,body,close,?,alive:w=1`, on Linux `a=focus,report:o=invisible:p=title,body,?,alive`, never with the `c` that close events would need. Windows' toasts are silent, as macOS's are; on Linux the notification daemon decides, and reads the body as markup, so its `&`, `<` and `>` go as entities.
Generated
+1 -1
View File
@@ -1747,7 +1747,7 @@ dependencies = [
[[package]]
name = "elyterminal"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"alacritty_terminal",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "elyterminal"
version = "0.1.2"
version = "0.1.3"
edition = "2024"
rust-version = "1.95"
license = "MIT"
+1
View File
@@ -80,6 +80,7 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
- A session whose program ends closes its row. The last row closing closes the window.
- A bell in a hidden session marks its row.
- A row shows its session's status: working, with its progress where known; waiting on the user, for a permission, a question or a login; done; error; and a mark when the last command failed. Hover shows each record's program, title and message. A hidden session that starts waiting marks its row until it is shown.
- The mark is 3 by 3 dots that move: work ripples, or fills by its progress; waiting dips; a settled state draws its shape, then holds still.
- System notifications on macOS, Windows and Linux: a session that starts waiting, finishes or fails, and a program's OSC 9, 777 or 99 notification, post one while the window is not active or the session is hidden, one a session every 3 seconds at most; it names the session, and a press on it shows the window and the session. On macOS only the app bundle posts them.
## 8. Right panel: Files
+1 -1
View File
@@ -43,7 +43,7 @@ The app writes its log to stderr. `RUST_LOG=elyterminal=debug` writes more; `tra
- The terminal runs on `alacritty_terminal`: 256 colors and truecolor, bold, dim, italic, five underline styles, wide characters and combining marks. Box drawing, block elements and powerline shapes are drawn as geometry, so lines join across cells. Keys follow xterm and the kitty keyboard protocol; input methods compose at the cursor; programs can track the mouse; pastes are bracketed when a program asks; OSC 8 and plain links open with the link press below; find searches the scrollback.
- Shell integration for zsh, bash, fish and PowerShell: each prompt reports its folder and title, and OSC 133 marks each prompt, each command and its exit status.
- Sessions: new, close and switch. A bell marks a hidden session's row and makes no sound. A row's mark shows what its programs report through OSC 7501, OSC 9;4 or herdr: waiting on you, an error, work with its progress, done or idle, else a failed last command; hover over the mark lists each report.
- Sessions: new, close and switch. A bell marks a hidden session's row and makes no sound. A row's mark shows what its programs report through OSC 7501, OSC 9;4 or herdr: waiting on you, an error, work with its progress, done or idle, else a failed last command. The mark is 3 by 3 dots: work ripples and waiting dips while they last. Hover over the mark lists each report.
- Notifications: while the window is in the background or the session hidden, a session that starts waiting, finishes or fails, and a program's OSC 9, 777 or 99, post a system notification, one a session every 3 seconds at most; a press shows the session. On macOS only the app bundle posts them, not `cargo run`.
- Cmd-Up and Cmd-Down, Ctrl-Shift-Up and Ctrl-Shift-Down off macOS, jump to the prompt before or after the view's top.
- A button at each end of the title bar hides or shows its side column, and a drag on the border between a side column and the terminal resizes it; the window opens as it was left, at the size and widths it was left at. On Windows and Linux a gear beside the right one opens Settings.
+11 -3
View File
@@ -96,9 +96,17 @@ Progress truth. Top to bottom, one at a time. A task is ticked only after its co
- [x] T36 OSC 7501 program status, revision 0.4: reports checked as the spec checks them; records per terminal, by id, with clear and a cap of 256; a new prompt drops working, blocked and idle records, a full reset all; the support query answered with the states and kinds.
- [x] T37 OSC 9;4 progress as the root record until an OSC 7501 report; OSC 9, 777 and 99 notifications read, OSC 99's chunks, base64, ids, occasions, actions, close and query included; ConEmu's other OSC 9 commands are no notifications.
- [x] T38 Inside herdr, herdr's agent states from its snapshot are the session's records.
- [ ] T39 Session rows show the status: working with its progress, waiting by kind, done, error, a failed last command; hover shows each record's program, title and message; a hidden session that starts waiting marks its row.
- [ ] T40 System notifications on macOS, Windows and Linux through gpui's: a session that starts waiting, finishes or fails, and OSC 9, 777 and 99 requests, while the window is inactive or the session hidden, rate-limited; a press shows the window and the session.
- [x] T39 Session rows show the status: working with its progress, waiting by kind, done, error, a failed last command; hover shows each record's program, title and message; a hidden session that starts waiting marks its row.
- [x] T40 System notifications on macOS, Windows and Linux through gpui's: a session that starts waiting, finishes or fails, and OSC 9, 777 and 99 requests, while the window is inactive or the session hidden, rate-limited; a press shows the window and the session.
## 21. Release
- [ ] T41 Release v0.1.3 with T32 to T40: version 0.1.3; the macOS disk image, the Windows zip and the Linux tarball, each from its script; the notes list what changed.
- [x] T41 Release v0.1.3 with T32 to T40: version 0.1.3; the macOS disk image, the Windows zip and the Linux tarball, each from its script; the notes list what changed.
## 22. Status marks that move
- [x] T42 The status marks move, which the owner asked for after aicss.dev's orbs: 3 by 3 dots in the state's color; work ripples, or fills by its progress, waiting dips, a settled state draws its shape, then holds; the window draws again only at the marks' steps while one moves.
## 23. Release
- [ ] T43 v0.1.3 rebuilt with T42, which the owner asked for in place of a v0.1.4: the version stays 0.1.3; the macOS disk image, the Windows zip and the Linux tarball, each from its script, replace the old ones on the release; the tag moves to their commit; the notes say what changed.
+91 -75
View File
@@ -1,19 +1,25 @@
use std::path::{MAIN_SEPARATOR, Path, PathBuf};
use std::{
path::{MAIN_SEPARATOR, Path, PathBuf},
time::Instant,
};
use ely_gpui_component::{
buttons::{ButtonVariant, IconButton},
lists::ListItem,
motion::ProgressRing,
primitives::{Icon, IconName, Tooltip},
theme::{ActiveTheme, ControlSize, IconSize},
};
use gpui::{AnyElement, App, Entity, Hsla, SharedString, Subscription, div, prelude::*, rems};
use gpui::{AnyElement, App, Entity, SharedString, Subscription, div, prelude::*, rems};
use crate::{
term::{Kind, Record, Records, State, TerminalView},
workspace::{TITLE, Workspace},
};
pub mod orb;
use orb::Look;
/// A program on its own terminal, as the sessions column lists it.
pub struct Session {
pub terminal: Entity<TerminalView>,
@@ -27,9 +33,25 @@ pub struct Session {
pub bell: bool,
/// It started waiting on the user while hidden.
pub waiting: bool,
/// The look its mark shows, and since when.
pub look: Option<(Look, Instant)>,
pub _events: Subscription,
}
impl Session {
/// Notes the look its mark takes `now`; true while the mark moves.
pub fn note(&mut self, now: Instant, cx: &App) -> bool {
let view = self.terminal.read(cx);
let failed = view.ended().is_some_and(|code| code != 0);
let shows = look(view.records().shown().map(|(_, record)| record), failed);
if self.look.map(|(shown, _)| shown) != shows {
self.look = shows.map(|shows| (shows, now));
}
self.look
.is_some_and(|(look, since)| orb::moves(look, now.saturating_duration_since(since)))
}
}
/// The most records hover lists, and the most characters it shows of each part.
const LISTED: usize = 10;
const SHORT: usize = 120;
@@ -71,6 +93,7 @@ pub fn row(
session: &Session,
active: bool,
home: Option<&Path>,
now: Instant,
cx: &App,
) -> ListItem {
let terminal = session.terminal.read(cx);
@@ -96,7 +119,7 @@ pub fn row(
close.update(cx, |this, cx| this.close(index, window, cx))
}),
);
let mark = mark(&session.terminal, cx);
let mark = mark(session, now, cx);
let bell = session
.bell
.then(|| Icon::new(IconName::Bell).color(cx.theme().colors.warning));
@@ -113,79 +136,36 @@ pub fn row(
)
}
/// The mark of what the session's records say, else of its last command's failure; hover lists them.
fn mark(terminal: &Entity<TerminalView>, cx: &App) -> Option<AnyElement> {
/// What a mark shows: the most pressing record's state, else a failed last command.
pub fn look(record: Option<&Record>, failed: bool) -> Option<Look> {
Some(
match (record.map(|record| (record.state, record.progress)), failed) {
(Some((State::Blocked, _)), _) => Look::Waiting,
(Some((State::Error, _)), _) => Look::Error,
(Some((State::Working, progress)), _) => Look::Working(progress),
(Some((State::Done, _)), _) => Look::Done,
(_, true) => Look::Error,
(Some((State::Idle, _)), false) => Look::Idle,
(None, false) => return None,
},
)
}
/// The session's mark: its look's dots in its state's color; hover lists the records.
fn mark(session: &Session, now: Instant, cx: &App) -> Option<AnyElement> {
let (look, since) = session.look?;
let theme = cx.theme();
let colors = &theme.colors;
let view = terminal.read(cx);
let (pid, records) = (view.pid() as usize, view.records());
let icon = |name: IconName, color: Hsla| Icon::new(name).color(color).into_any_element();
let failed = view.ended().filter(|&code| code != 0);
let mark = match (records.shown().map(|(_, record)| record), failed) {
(
Some(Record {
state: State::Blocked,
kind,
..
}),
_,
) => {
let name = match kind {
Some(Kind::Permission) => IconName::Shield,
Some(Kind::Question) => IconName::CircleHelp,
Some(Kind::Auth) => IconName::Key,
None => IconName::Hand,
};
icon(name, colors.warning)
}
(
Some(Record {
state: State::Error,
..
}),
_,
) => icon(IconName::CircleX, colors.danger),
(
Some(Record {
state: State::Working,
progress: Some(value),
..
}),
_,
) => ProgressRing::new(("session-progress", pid), f32::from(*value) / 100.0)
.size(theme.icon_size(IconSize::Xl))
// The track takes the border's color, which a selected row's ground may match.
.inside(
Icon::new(IconName::LoaderCircle)
.size(IconSize::Xs)
.color(colors.info),
)
.into_any_element(),
// A turning spinner would draw the window at every frame while the program works.
(
Some(Record {
state: State::Working,
..
}),
_,
) => icon(IconName::LoaderCircle, colors.info),
(
Some(Record {
state: State::Done, ..
}),
_,
) => icon(IconName::CircleCheck, colors.success),
(_, Some(_)) => icon(IconName::CircleAlert, colors.danger),
(
Some(Record {
state: State::Idle, ..
}),
None,
) => icon(IconName::Circle, colors.fg_muted),
(None, None) => return None,
let color = match look {
Look::Working(_) => colors.info,
Look::Waiting => colors.warning,
Look::Error => colors.danger,
Look::Done => colors.success,
Look::Idle => colors.fg_muted,
};
let pid = session.terminal.read(cx).pid() as usize;
// Hover reads the records as they stand, so an open one follows them, and scrolls within the window.
let watched = terminal.downgrade();
let watched = session.terminal.downgrade();
let tip = Tooltip::rich(move |window, cx| {
let lines = watched.upgrade().map_or_else(Vec::new, |terminal| {
let view = terminal.read(cx);
@@ -201,7 +181,7 @@ fn mark(terminal: &Entity<TerminalView>, cx: &App) -> Option<AnyElement> {
.children(lines)
.into_any_element()
});
// Every mark takes the ring's box, so the title stays put as the state changes.
// Every mark takes a 24-pixel box, so the title stays put as the state changes.
Some(
div()
.id(("session-status", pid))
@@ -210,7 +190,7 @@ fn mark(terminal: &Entity<TerminalView>, cx: &App) -> Option<AnyElement> {
.items_center()
.justify_center()
.hoverable_tooltip(tip)
.child(mark)
.child(orb::lattice(look, color, now, since))
.into_any_element(),
)
}
@@ -332,6 +312,42 @@ mod tests {
assert_eq!(after_close(1, 2, 3), 1, "one after it went");
}
#[test]
fn the_mark_shows_the_most_pressing_look() {
let record = |state, progress| Record {
state,
kind: None,
progress,
app: None,
title: None,
msg: None,
};
let waiting = look(Some(&record(State::Blocked, None)), true);
assert_eq!(
waiting,
Some(Look::Waiting),
"waiting outranks a failed command"
);
let working = look(Some(&record(State::Working, Some(40))), false);
assert_eq!(working, Some(Look::Working(Some(40))));
let idle = record(State::Idle, None);
assert_eq!(
look(Some(&idle), true),
Some(Look::Error),
"a failed command outranks idle"
);
assert_eq!(look(Some(&idle), false), Some(Look::Idle));
assert_eq!(
look(Some(&record(State::Done, None)), true),
Some(Look::Done)
);
assert_eq!(
look(Some(&record(State::Error, None)), false),
Some(Look::Error)
);
assert_eq!(look(None, false), None);
}
#[test]
fn hover_shows_a_program_s_text_as_text() {
assert_eq!(plain("evil\u{202e}txt.exe"), "eviltxt.exe");
+206
View File
@@ -0,0 +1,206 @@
//! A session's status mark: 3 by 3 dots in its state's color, every mark stepping on one clock.
use std::{
cmp::Ordering,
f64::consts::TAU,
sync::OnceLock,
time::{Duration, Instant},
};
use gpui::{Div, Hsla, ParentElement, Styled, div};
/// The dots' beat: while a mark moves, the window draws at each step, not at every frame.
pub const STEP: Duration = Duration::from_millis(80);
/// What a mark shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Look {
/// A ripple from the centre; with progress, the outer ring fills clockwise from the top.
Working(Option<u8>),
/// Lit, with two quick dips.
Waiting,
/// A program's error, or a failed last command.
Error,
Done,
Idle,
}
/// One ripple, in seconds, and how far a dot one dot out from the centre lags it.
const RIPPLE: f64 = 1.2;
const LAG: f64 = 0.3;
/// Steps in one round of a waiting mark's dips.
const BLINK: u64 = 20;
/// A working dot at its lowest, a waiting mark in a dip, and a dot outside a settled shape.
const DIM: f32 = 0.35;
const DIP: f32 = 0.25;
const FAINT: f32 = 0.12;
/// Steps a settled shape's dot takes to light, and between one dot and the next.
const FADE: u64 = 2;
const STAGGER: u64 = 1;
/// The outer ring clockwise from the top, then the centre: the order progress fills.
const FILL: [usize; 9] = [1, 2, 5, 8, 7, 6, 3, 0, 4];
/// A settled look's dots in the order they light, and how bright they end; none for a look that keeps moving.
fn shape(look: Look) -> Option<(&'static [usize], f32)> {
match look {
Look::Working(_) | Look::Waiting => None,
Look::Error => Some((&[0, 4, 8, 2, 6], 1.0)),
Look::Done => Some((&[3, 7, 5, 2], 1.0)),
Look::Idle => Some((&FILL[..8], 0.6)),
}
}
fn steps(time: Duration) -> u64 {
(time.as_millis() / STEP.as_millis()) as u64
}
/// A dot's brightness `t` seconds into its ripple.
fn crest(t: f64) -> f32 {
let wave = 0.5 + 0.5 * (TAU * t / RIPPLE).cos();
DIM + (1.0 - DIM) * wave as f32
}
/// Each dot's opacity, row by row, `clock` on the marks' clock and `held` after the look began.
pub fn dots(look: Look, clock: Duration, held: Duration) -> [f32; 9] {
let t = steps(clock) as f64 * STEP.as_secs_f64();
let mut levels = [DIM; 9];
match look {
Look::Working(None) => {
for (dot, level) in levels.iter_mut().enumerate() {
let (x, y) = ((dot % 3) as f64 - 1.0, (dot / 3) as f64 - 1.0);
*level = crest(t - x.hypot(y) * LAG);
}
}
Look::Working(Some(progress)) => {
let filled = (usize::from(progress) * FILL.len() / 100).min(FILL.len() - 1);
for (rank, &dot) in FILL.iter().enumerate() {
levels[dot] = match rank.cmp(&filled) {
Ordering::Less => 1.0,
Ordering::Equal => crest(t),
Ordering::Greater => DIM,
};
}
}
Look::Waiting => {
let dip = matches!(steps(clock) % BLINK, 0 | 1 | 4 | 5);
levels = [if dip { DIP } else { 1.0 }; 9];
}
settled => {
let (shape, bright) = shape(settled).expect("a settled look has a shape");
levels = [FAINT; 9];
let held = steps(held);
for (rank, &dot) in shape.iter().enumerate() {
let lit = held.saturating_sub(rank as u64 * STAGGER).min(FADE) as f32 / FADE as f32;
levels[dot] = bright * lit + FAINT * (1.0 - lit);
}
}
}
levels
}
/// Whether a mark still changes `held` after its look began: work and waiting always, a settled shape until it is drawn.
pub fn moves(look: Look, held: Duration) -> bool {
shape(look).is_none_or(|(shape, _)| steps(held) < (shape.len() as u64 - 1) * STAGGER + FADE)
}
/// Time on the clock every mark steps to, so marks move together.
fn clock(now: Instant) -> Duration {
static START: OnceLock<Instant> = OnceLock::new();
now.saturating_duration_since(*START.get_or_init(|| now))
}
/// How long until the clock's next step, a little past it.
pub fn wait(now: Instant) -> Duration {
let into = clock(now).as_nanos() % STEP.as_nanos();
STEP - Duration::from_nanos(into as u64) + Duration::from_millis(2)
}
/// The mark's dots in `color`, `now`, its look shown since `since`.
pub fn lattice(look: Look, color: Hsla, now: Instant, since: Instant) -> Div {
let levels = dots(look, clock(now), now.saturating_duration_since(since));
div()
.grid()
.grid_cols(3)
.gap_0p5()
.children(levels.map(|level| div().size_1().rounded_full().bg(color.opacity(level))))
}
#[cfg(test)]
mod tests {
use super::*;
fn at(steps: u32) -> Duration {
STEP * steps
}
#[test]
fn work_ripples_from_the_centre_to_the_edges_then_the_corners() {
let levels = |steps| dots(Look::Working(None), at(steps), Duration::ZERO);
let peak = |dot: usize| {
(0..15)
.max_by(|&a, &b| levels(a)[dot].total_cmp(&levels(b)[dot]))
.expect("steps")
};
let (centre, edge, corner) = (peak(4), peak(1), peak(0));
assert_eq!(centre, 0);
assert!(centre < edge && edge < corner, "{centre} {edge} {corner}");
assert_eq!(peak(3), edge, "every edge together");
assert_eq!(peak(8), corner, "every corner together");
let again = levels(15);
assert!(
levels(0)
.iter()
.zip(again)
.all(|(a, b)| (a - b).abs() < 1e-4),
"one ripple every 15 steps"
);
}
#[test]
fn progress_fills_the_ring_clockwise_from_the_top_then_the_centre() {
let lit = |progress, steps| dots(Look::Working(Some(progress)), at(steps), Duration::ZERO);
let half = lit(50, 9);
assert!([1, 2, 5, 8].iter().all(|&dot| half[dot] == 1.0), "{half:?}");
assert!([6, 3, 0, 4].iter().all(|&dot| half[dot] == DIM), "{half:?}");
assert!(lit(50, 0)[7] > half[7], "the next dot pulses");
let full = lit(100, 9);
assert!(FILL[..8].iter().all(|&dot| full[dot] == 1.0), "{full:?}");
assert!(lit(100, 0)[4] > full[4], "then the centre pulses");
assert!(lit(0, 0)[1] > lit(0, 9)[1], "at 0% the top pulses");
}
#[test]
fn waiting_stays_lit_and_dips_twice() {
let blink = |steps| dots(Look::Waiting, at(steps), Duration::ZERO);
let dips: Vec<u32> = (0..20).filter(|&steps| blink(steps)[0] == DIP).collect();
assert_eq!(dips, [0, 1, 4, 5]);
assert!((0..20).all(|steps| blink(steps).iter().all(|&level| level == blink(steps)[0])));
assert_eq!(blink(2), [1.0; 9]);
}
#[test]
fn a_settled_look_draws_its_shape_dot_by_dot_then_holds() {
let done = |steps| dots(Look::Done, at(1000), at(steps));
assert_eq!(done(0), [FAINT; 9], "nothing lit at first");
assert!(
done(1)[3] > FAINT && done(1)[2] == FAINT,
"the check draws from its left end"
);
let check = [3, 7, 5, 2];
let drawn = done(5);
assert!((0..9).all(|dot| drawn[dot] == if check.contains(&dot) { 1.0 } else { FAINT }));
assert_eq!(done(500), drawn, "then it holds still");
assert!(moves(Look::Done, at(4)) && !moves(Look::Done, at(5)));
let idle = dots(Look::Idle, Duration::ZERO, at(9));
assert_eq!(idle[4], FAINT, "idle is a ring");
assert!(FILL[..8].iter().all(|&dot| idle[dot] == 0.6));
assert!(!moves(Look::Idle, at(9)) && moves(Look::Idle, at(8)));
}
#[test]
fn work_and_waiting_never_stop() {
for look in [Look::Working(None), Look::Working(Some(40)), Look::Waiting] {
assert!(moves(look, at(1_000_000)), "{look:?}");
}
}
}
+1
View File
@@ -19,6 +19,7 @@ mod reports;
pub mod shell;
mod status;
mod tap;
mod titles;
#[cfg(unix)]
mod unix;
mod view;
+2
View File
@@ -25,6 +25,8 @@ pub enum PtyEvent {
Status(Status),
/// A notification or progress, OSC 9, 777 or 99.
Ask(Ask),
/// A title a program set, in the output's order: OSC 0 or 2, or the title stack's pop.
Title(String),
/// A full reset, `ESC c`.
Reset,
}
+64 -6
View File
@@ -17,6 +17,8 @@ struct Ran {
statuses: Vec<Option<Status>>,
/// Notifications and progress programs asked for.
asks: Vec<Ask>,
/// Titles, notifications and full resets, in the output's order.
said: Vec<String>,
text: String,
/// The first line's cells that carry a prompt's link.
prompt: Vec<usize>,
@@ -35,22 +37,37 @@ fn run(script: &str, size: (usize, usize)) -> Ran {
};
let (pty, mut events) =
spawn(&launch, size, (8, 16), config(&Settings::default())).expect("sh starts");
let (folders, marks, statuses, asks, success) = futures::executor::block_on(async {
let (mut folders, mut marks, mut statuses, mut asks, mut success) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new(), false);
let (folders, marks, statuses, asks, said, success) = futures::executor::block_on(async {
let (mut folders, mut marks, mut statuses, mut asks, mut said, mut success) = (
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
false,
);
while let Some(event) = events.next().await {
match event {
PtyEvent::Folder(folder) => folders.push(folder),
PtyEvent::Mark(mark) => marks.push(mark),
PtyEvent::Status(status) => statuses.push(Some(status)),
PtyEvent::Reset => statuses.push(None),
PtyEvent::Ask(ask) => asks.push(ask),
PtyEvent::Reset => {
said.push("reset".to_string());
statuses.push(None);
}
PtyEvent::Title(title) => said.push(format!("title {title}")),
PtyEvent::Ask(ask) => {
if let Ask::Show(notice) = &ask {
said.push(format!("show {}", notice.title));
}
asks.push(ask);
}
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
PtyEvent::Term(Event::Exit) => break,
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
}
}
(folders, marks, statuses, asks, success)
(folders, marks, statuses, asks, said, success)
});
let term = pty.term.lock();
let row = &term.grid()[Line(0)];
@@ -65,6 +82,7 @@ fn run(script: &str, size: (usize, usize)) -> Ran {
marks,
statuses,
asks,
said,
text: text.trim_end().to_string(),
prompt: prompt.collect(),
success,
@@ -161,6 +179,46 @@ fn osc_99_gathers_a_full_encoded_chunk_and_the_lines_base64_carries() {
assert_eq!(ran.text, "x");
}
#[test]
fn titles_reach_the_session_in_their_place_among_notifications() {
let long = "y".repeat(5000);
let many: Vec<String> = (1..=17).map(|at| at.to_string()).collect();
let parts = [
"\\033]2;Old\\007".to_string(),
"\\033[22t".into(),
"\\033]2;New\\007".into(),
"\\033]9;a\\007".into(),
"\\033[23t".into(),
"\\033]9;b\\007".into(),
"\\033c".into(),
"\\033]9;c\\007".into(),
format!("\\033]2;{long}\\007"),
format!("\\033]2;{}\\007", many.join(";")),
"\\033]9;d\\007".into(),
];
let whole = run(&format!("printf '{}x'", parts.concat()), (40, 4));
let split: Vec<String> = parts
.iter()
.map(|part| format!("printf '{part}'; sleep 0.1"))
.collect();
let split = run(&format!("{}; printf x", split.join("; ")), (40, 4));
let said = [
"title Old".to_string(),
"title New".into(),
"show a".into(),
"title Old".into(),
"show b".into(),
"reset".into(),
"show c".into(),
format!("title {long}"),
format!("title {}", many[..15].join(";")),
"show d".into(),
];
assert_eq!(whole.said, said, "one write");
assert_eq!(split.said, said, "a write a sequence");
assert_eq!((whole.text.as_str(), split.text.as_str()), ("x", "x"));
}
#[test]
fn a_long_escape_of_controls_keeps_the_pty_reading() {
let ran = run(
+52 -9
View File
@@ -143,11 +143,7 @@ impl TerminalView {
/// Posts the state its row shows when it turned to waiting, done or error.
fn tell_state(&mut self, cx: &mut Context<Self>) {
let body = match self.records.shown() {
Some((id, record))
if matches!(record.state, State::Blocked | State::Done | State::Error) =>
{
line(&self.records, id, record)
}
Some((id, record)) if tells(record.state) => line(&self.records, id, record),
_ => return,
};
self.post(body, None, cx);
@@ -160,13 +156,21 @@ impl TerminalView {
log::info!("terminal {}: its last command ended {ended}", self.pty.pid);
}
self.ended = status;
self.post(finished(status), None, cx);
// Only a command C started posts its end, so a prompt bash 3.2 draws again posts nothing.
if std::mem::take(&mut self.ran) {
self.post(finished(status), None, cx);
} else {
log::debug!(
"terminal {}: an end without its start posts nothing",
self.pty.pid
);
}
}
/// Posts `body` while the window is not active or the session hidden, one every 3 seconds at most.
fn post(&mut self, body: String, asked: Option<&Notice>, cx: &mut Context<Self>) {
let pid = self.pty.pid;
if self.front && self.active {
if !unseen(self.front, self.active) {
log::debug!("terminal {pid}: in view, so it posts no {body:?}");
return;
}
@@ -181,6 +185,12 @@ impl TerminalView {
&self.title
});
log::info!("terminal {pid}: posts {}: {title:?}, {body:?}", posted.tag);
// Linux's notification daemons read the body as markup.
let body = if cfg!(target_os = "linux") {
escape(&body)
} else {
body
};
cx.show_system_notification(SystemNotification {
tag: posted.tag.clone(),
title: title.into(),
@@ -246,8 +256,18 @@ impl TerminalView {
/// A press on notification `tag`, if this session posted it: a program that asked hears of it; whether the session shows.
pub fn pressed(&mut self, tag: &str) -> Option<bool> {
let posted = self.notices.take(tag, None)?;
log::info!("terminal {}: notification {tag} pressed", self.pty.pid);
let pid = self.pty.pid;
let Some(posted) = self.notices.take(tag, None) else {
// A twin Linux stacked, pressed before, or one past the 64 kept, still shows its session.
let own = owns(pid, tag);
if own {
log::info!(
"terminal {pid}: notification {tag} pressed, its record gone, so it tells no program"
);
}
return own.then_some(true);
};
log::info!("terminal {pid}: notification {tag} pressed");
if posted.report {
let id = posted.id.as_deref().unwrap_or("0");
self.pty.write(notify::activated(id).into_bytes());
@@ -256,6 +276,29 @@ impl TerminalView {
}
}
/// Whether a session posts: not while it is the session shown in an active window.
fn unseen(front: bool, active: bool) -> bool {
!(front && active)
}
/// Whether the row's turn to `state` posts: waiting, done and error do.
fn tells(state: State) -> bool {
matches!(state, State::Blocked | State::Done | State::Error)
}
/// Whether program `pid`'s session posted notification `tag`: its own, `PID`, else `PID:ID` or `PID#N`.
fn owns(pid: u32, tag: &str) -> bool {
tag.strip_prefix(&pid.to_string())
.is_some_and(|rest| rest.is_empty() || rest.starts_with([':', '#']))
}
/// `text` as markup reads it as text.
fn escape(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// What a command's end says: it finished, or the status it failed with.
fn finished(status: Option<i32>) -> String {
match status {
+40
View File
@@ -81,3 +81,43 @@ fn a_notification_says_its_text_as_text() {
assert_eq!(finished(None), "Command finished");
assert_eq!(finished(Some(2)), "Command exited with 2");
}
#[test]
fn a_session_posts_unless_it_is_the_one_shown_in_an_active_window() {
assert!(!unseen(true, true));
assert!(unseen(true, false), "the window inactive");
assert!(unseen(false, true), "the session hidden");
assert!(unseen(false, false));
}
#[test]
fn waiting_done_and_error_post_and_work_and_rest_do_not() {
for (state, posts) in [
(State::Blocked, true),
(State::Done, true),
(State::Error, true),
(State::Working, false),
(State::Idle, false),
] {
assert_eq!(tells(state), posts, "{state:?}");
}
}
#[test]
fn a_tag_names_the_session_that_posted_it() {
for tag in ["12", "12:build", "12#3"] {
assert!(owns(12, tag), "{tag}");
}
for tag in ["123", "1", "123:build", "13#3", ""] {
assert!(!owns(12, tag), "{tag}");
}
}
#[test]
fn a_body_goes_as_markup_s_text() {
assert_eq!(
escape("<b>a</b> & <a href=x>"),
"&lt;b&gt;a&lt;/b&gt; &amp; &lt;a href=x&gt;"
);
assert_eq!(escape("plain"), "plain");
}
+1 -1
View File
@@ -108,7 +108,7 @@ impl Run {
);
match self.events.try_recv() {
Ok(PtyEvent::Folder(folder)) => self.folders.push(folder),
Ok(PtyEvent::Term(Event::Title(title))) => self.titles.push(title),
Ok(PtyEvent::Title(title)) => self.titles.push(title),
Ok(PtyEvent::Mark(mark)) => self.marks.push(mark),
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => self.pty.write(reply.into_bytes()),
Ok(PtyEvent::Term(Event::ChildExit(status))) => {
+72 -7
View File
@@ -14,6 +14,7 @@ use super::{
prompts::{self, Prompts},
pty::PtyEvent,
status,
titles::{self, Titles},
};
/// The longest OSC body kept; longer ones are dropped whole.
@@ -26,6 +27,7 @@ pub(crate) struct Tapped<P> {
mouse: MouseModes,
prompts: Prompts,
kitty: Kitty,
titles: Titles,
events: UnboundedSender<PtyEvent>,
pid: u32,
last: Option<PathBuf>,
@@ -39,6 +41,7 @@ impl<P> Tapped<P> {
mouse: MouseModes::default(),
prompts: Prompts::default(),
kitty: Kitty::default(),
titles: Titles::default(),
events,
pid,
last: None,
@@ -58,9 +61,11 @@ impl<P: EventedReadWrite> io::Read for Tapped<P> {
}
let count = self.inner.reader().read(&mut buf[..take])?;
let read = &buf[..count];
let (events, pid, last, kitty) = (&self.events, self.pid, &mut self.last, &mut self.kitty);
self.scanner
.feed(read, &mut |found| report(found, events, pid, last, kitty));
let (events, pid, last) = (&self.events, self.pid, &mut self.last);
let (kitty, titles) = (&mut self.kitty, &mut self.titles);
self.scanner.feed(read, &mut |found| {
report(found, events, pid, last, kitty, titles)
});
if let Some(mode) = self.mouse.feed(read) {
log::info!("pty {pid}: mouse {mode:?}");
if events.unbounded_send(PtyEvent::Mouse(mode)).is_err() {
@@ -73,19 +78,29 @@ impl<P: EventedReadWrite> io::Read for Tapped<P> {
}
}
/// Sends the session what the scanner found: a full reset, a mark, a program's status or ask, or a folder.
/// Sends the session what the scanner found: a full reset, a title, a mark, a program's status or ask, or a folder.
fn report(
found: Found,
events: &UnboundedSender<PtyEvent>,
pid: u32,
last: &mut Option<PathBuf>,
kitty: &mut Kitty,
titles: &mut Titles,
) {
let event = match found {
Found::Reset => {
log::debug!("pty {pid}: a full reset");
titles.reset();
PtyEvent::Reset
}
Found::Push => {
titles.push();
return;
}
Found::Pop => match titles.pop() {
Some(title) => PtyEvent::Title(title),
None => return,
},
Found::Osc(body, length) => {
if let Some(mark) = body.strip_prefix(b"133;").and_then(marks::read) {
log::debug!("pty {pid}: {mark:?}");
@@ -107,6 +122,11 @@ fn report(
*last = Some(folder.clone());
}
PtyEvent::Folder(folder)
} else if let Some(rest) = body
.strip_prefix(b"0;")
.or_else(|| body.strip_prefix(b"2;"))
{
PtyEvent::Title(titles.set(rest))
} else {
return;
}
@@ -176,15 +196,18 @@ enum State {
Ground,
Escape,
Osc,
Csi,
}
/// What the scanner finds: an OSC's body with the sequence's length from OSC through ST, or a full reset.
/// What the scanner finds: an OSC's body with the sequence's length from OSC through ST, a full reset, or the title stack's push and pop.
pub(crate) enum Found<'a> {
Osc(&'a [u8], usize),
Reset,
Push,
Pop,
}
/// Finds each OSC body across reads, ended as vte ends it: at BEL, ESC, CAN or SUB, and each full reset.
/// Finds each OSC body across reads, ended as vte ends it: at BEL, ESC, CAN or SUB, each full reset, and CSI 22 t and 23 t.
#[derive(Default)]
pub(crate) struct Osc {
state: State,
@@ -192,6 +215,9 @@ pub(crate) struct Osc {
/// The sequence's bytes so far, the controls the body leaves out too.
length: usize,
overflow: bool,
/// A CSI's first parameter while it reads as a plain number, and its fields so far.
first: Option<u32>,
fields: usize,
}
impl Osc {
@@ -221,6 +247,11 @@ impl Osc {
found(Found::Reset);
State::Ground
}
(State::Escape, b'[') => {
self.first = Some(0);
self.fields = 1;
State::Csi
}
// vte runs controls in an escape, passes DEL and high bytes by, and stays there.
(State::Escape, 0x00..=0x17 | 0x19 | 0x1b..=0x1f | 0x7f..=0xff) => State::Escape,
(State::Escape, _) => State::Ground,
@@ -246,15 +277,49 @@ impl Osc {
}
State::Osc
}
// In a CSI, as in vte: CAN and SUB cancel, ESC restarts, controls run, DEL and high bytes pass by.
(State::Csi, 0x18 | 0x1a) => State::Ground,
(State::Csi, 0x1b) => State::Escape,
(State::Csi, 0x00..=0x1f | 0x7f..=0xff) => State::Csi,
(State::Csi, b'0'..=b'9') => {
if self.fields == 1 {
self.first = self.first.map(|first| {
first
.saturating_mul(10)
.saturating_add(u32::from(byte - b'0'))
});
}
State::Csi
}
(State::Csi, b';' | b':') => {
self.fields += 1;
State::Csi
}
(State::Csi, b't') => {
match self.first.filter(|_| self.fields <= PARAMS) {
Some(22) => found(Found::Push),
Some(23) => found(Found::Pop),
_ => {}
}
State::Ground
}
(State::Csi, 0x40..=0x7e) => State::Ground,
// A private marker or an intermediate makes another sequence.
(State::Csi, _) => {
self.first = None;
State::Csi
}
(State::Ground, _) => unreachable!("ground scans by position"),
};
}
}
/// The longest body kept: an OSC 99 holds a payload of 4096 bytes past its metadata.
/// The longest body kept: an OSC 99's holds its payload past its metadata, a title's more.
fn most(&self) -> usize {
if self.body.starts_with(b"99;") {
notify::BODY
} else if self.body.starts_with(b"0;") || self.body.starts_with(b"2;") {
titles::LONGEST
} else {
MOST
}
+53
View File
@@ -9,6 +9,8 @@ fn scan(chunks: &[&[u8]]) -> Vec<String> {
found.push(match thing {
Found::Osc(body, _) => String::from_utf8(body.to_vec()).expect("utf-8"),
Found::Reset => "RESET".into(),
Found::Push => "PUSH".into(),
Found::Pop => "POP".into(),
})
});
}
@@ -225,3 +227,54 @@ fn the_tap_reads_mouse_modes_as_vte_does_byte_by_byte() {
}
}
}
#[test]
fn finds_the_title_stack_s_push_and_pop_as_vte_reads_them() {
assert_eq!(scan(&[b"\x1b[22;0t\x1b[23;2t"]), ["PUSH", "POP"]);
assert_eq!(
scan(&[b"\x1b[2", b"2:1\x07t\x1b[23t"]),
["PUSH", "POP"],
"across reads, a control run"
);
for other in [
&b"\x1b[?22t"[..],
b"\x1b[22$t",
b"\x1b[1;22t",
b"\x1b[t",
b"\x1b[22\x18t",
b"\x1b[24t",
] {
assert!(
scan(&[other]).is_empty(),
"{}",
String::from_utf8_lossy(other)
);
}
let mut many = b"\x1b[22".to_vec();
many.extend(std::iter::repeat_n(&b";1"[..], 32).flatten());
many.push(b't');
assert!(scan(&[&many]).is_empty(), "vte drops one past 32 fields");
assert_eq!(
scan(&[b"\x1b[22t\x1b]2;t\x07\x1b[23t"]),
["PUSH", "2;t", "POP"]
);
}
#[test]
fn a_title_s_body_holds_65536_bytes_and_another_s_4096() {
let osc = |number: &str, length: usize| {
[
format!("\x1b]{number};").as_bytes(),
&vec![b'a'; length],
b"\x07",
]
.concat()
};
let longest = scan(&[&osc("2", titles::LONGEST - 2)]);
assert_eq!(longest.concat().len(), titles::LONGEST);
assert!(scan(&[&osc("0", titles::LONGEST - 1)]).is_empty());
assert!(
scan(&[&osc("7", MOST - 1)]).is_empty(),
"another stays at 4096"
);
}
+93
View File
@@ -0,0 +1,93 @@
/// The titles alacritty saves before it drops the oldest.
const SAVED: usize = 4096;
/// vte keeps 16 parameters of an OSC, its number among them.
const PARTS: usize = 15;
/// The longest OSC 0 or 2 body the tap keeps; a longer one drops, logged.
pub(super) const LONGEST: usize = 65536;
/// The title a session shows, read in the output's order as vte and alacritty read it.
#[derive(Default)]
pub(crate) struct Titles {
/// Empty while the program names none.
title: String,
saved: Vec<String>,
}
impl Titles {
/// Sets the title from an OSC 0 or 2 body past its number, as vte splits it and alacritty joins it.
pub fn set(&mut self, rest: &[u8]) -> String {
let parts: Vec<&str> = rest
.split(|&byte| byte == b';')
.take(PARTS)
.filter_map(|part| std::str::from_utf8(part).ok())
.collect();
self.title = parts.join(";").trim().to_owned();
self.title.clone()
}
/// Saves the title: CSI 22 t.
pub fn push(&mut self) {
if self.saved.len() == SAVED {
self.saved.remove(0);
}
self.saved.push(self.title.clone());
}
/// Brings the last title saved back: CSI 23 t; none when none was saved.
pub fn pop(&mut self) -> Option<String> {
let title = self.saved.pop()?;
self.title.clone_from(&title);
Some(title)
}
/// A full reset empties the title and those saved.
pub fn reset(&mut self) {
self.title.clear();
self.saved.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_title_reads_as_alacritty_joins_it() {
let mut titles = Titles::default();
assert_eq!(titles.set(b" a;b "), "a;b");
assert_eq!(
titles.set(b"a;\xff;b"),
"a;b",
"a part not in UTF-8 passes by"
);
assert_eq!(titles.set(b""), "");
let parts: Vec<String> = (1..=17).map(|at| at.to_string()).collect();
assert_eq!(
titles.set(parts.join(";").as_bytes()),
parts[..PARTS].join(";"),
"vte keeps 16 parameters, the number among them"
);
}
#[test]
fn a_title_saved_comes_back_and_a_reset_empties_both() {
let mut titles = Titles::default();
assert_eq!(titles.pop(), None, "none saved");
titles.set(b"shell");
titles.push();
titles.set(b"vim");
assert_eq!(titles.pop().as_deref(), Some("shell"));
assert_eq!(titles.pop(), None);
titles.push();
titles.reset();
assert_eq!(titles.pop(), None, "a reset empties those saved");
for at in 0..=SAVED {
titles.set(at.to_string().as_bytes());
titles.push();
}
assert_eq!(titles.saved.len(), SAVED);
assert_eq!(titles.saved[0], "1", "the oldest went");
}
}
+15 -14
View File
@@ -77,6 +77,8 @@ pub struct TerminalView {
pub(super) records: Records,
/// The status its shell's last command ended with; none before one, or when it gave none.
pub(super) ended: Option<i32>,
/// A command started, as OSC 133 C says, since the last one ended.
pub(super) ran: bool,
/// The system notifications it posted.
pub(super) notices: Notices,
/// The program's title; empty while it names none.
@@ -134,6 +136,7 @@ impl TerminalView {
reports: 0,
records: Records::default(),
ended: None,
ran: false,
notices: Notices::default(),
title: String::new(),
front: true,
@@ -263,6 +266,7 @@ impl TerminalView {
self.records.prompt();
self.restate(cx);
}
PtyEvent::Mark(Mark::Output) => self.ran = true,
PtyEvent::Mark(_) => {}
PtyEvent::Status(status) => {
self.status(status);
@@ -272,8 +276,14 @@ impl TerminalView {
self.ask(ask, cx);
self.restate(cx);
}
PtyEvent::Title(title) => {
self.title.clone_from(&title);
cx.emit(TerminalEvent::Title(title));
}
PtyEvent::Reset => {
self.records.reset();
self.title.clear();
cx.emit(TerminalEvent::Title(String::new()));
self.restate(cx);
}
}
@@ -288,20 +298,11 @@ impl TerminalView {
fn term_event(&mut self, event: Event, cx: &mut Context<Self>) {
match event {
Event::Wakeup => self.shown = true,
Event::MouseCursorDirty => {}
// A reset empties the title and sends only this; setting the options sends the title.
Event::CursorBlinkingChange => {
let settings = config(SettingsStore::get(cx));
self.pty.term.lock().set_options(settings);
}
Event::Title(title) => {
self.title.clone_from(&title);
cx.emit(TerminalEvent::Title(title));
}
Event::ResetTitle => {
self.title.clear();
cx.emit(TerminalEvent::Title(String::new()));
}
// The tap reads the title in the output's order; alacritty's title events come after it.
Event::MouseCursorDirty
| Event::CursorBlinkingChange
| Event::Title(_)
| Event::ResetTitle => {}
Event::Bell => {
log::debug!("terminal {}: a bell, kept silent", self.pty.pid);
cx.emit(TerminalEvent::Bell);
+36 -3
View File
@@ -1,3 +1,5 @@
use std::time::Instant;
use ely_gpui_component::{
buttons::{ButtonVariant, IconButton, SegmentedControl},
lists::List,
@@ -9,7 +11,7 @@ use ely_gpui_component::{
use gpui::{
Action, App, AppContext, Context, Entity, FocusHandle, Focusable, InteractiveElement,
IntoElement, KeyBinding, ParentElement, Render, SharedString, StatefulInteractiveElement,
StyleRefinement, Styled, Subscription, Window, actions, div, prelude::FluentBuilder,
StyleRefinement, Styled, Subscription, Task, Window, actions, div, prelude::FluentBuilder,
};
use crate::{
@@ -17,7 +19,7 @@ use crate::{
files::Files,
git::panel::{Acted, GitPanel},
menus,
sessions::{self, Session},
sessions::{self, Session, orb},
settings::{PanelTab, Settings, SettingsStore, StartIn, window::OpenSettings},
term::{
Events, Launch, Pty, TerminalView, config,
@@ -95,6 +97,8 @@ pub struct Workspace {
background: Blur,
/// Column widths a drag shows before the settings hold them.
dragged: Dragged,
/// Draws the window at each step of the status marks while one shown moves.
ticking: Option<Task<()>>,
_settings: Subscription,
_acted: Subscription,
}
@@ -122,6 +126,7 @@ impl Workspace {
git,
background: Blur::new(window, SettingsStore::get(cx)),
dragged: Dragged::default(),
ticking: None,
_settings: cx.observe_global_in::<SettingsStore>(window, |this, window, cx| {
this.background.follow(window, SettingsStore::get(cx));
cx.notify();
@@ -155,10 +160,36 @@ impl Workspace {
reports: launch.reports,
bell: false,
waiting: false,
look: None,
_events: events,
});
}
/// Notes each session's mark `now`, and draws the window at each step while one shown moves.
fn tick(&mut self, now: Instant, cx: &mut Context<Self>) {
let mut moving = false;
for session in &mut self.sessions {
moving |= session.note(now, cx);
}
let moving = moving && SettingsStore::get(cx).show_sessions;
if moving == self.ticking.is_some() {
return;
}
log::debug!("app: status marks {}", if moving { "move" } else { "rest" });
self.ticking = moving.then(|| {
cx.spawn(async move |this, cx| {
loop {
cx.background_executor()
.timer(orb::wait(Instant::now()))
.await;
if this.update(cx, |_, cx| cx.notify()).is_err() {
break;
}
}
})
});
}
/// A session of the shell the settings name, in home or the shown session's folder.
fn new_session(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(home) = dirs::home_dir() else {
@@ -307,6 +338,8 @@ fn column_toggle(
impl Render for Workspace {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.background.fit(window);
let now = Instant::now();
self.tick(now, cx);
let settings = SettingsStore::get(cx);
let workspace = cx.entity();
let tip =
@@ -350,7 +383,7 @@ impl Render for Workspace {
let home = dirs::home_dir();
let rows = self.sessions.iter().enumerate().map(|(index, session)| {
let active = index == self.active;
sessions::row(&workspace, index, session, active, home.as_deref(), cx)
sessions::row(&workspace, index, session, active, home.as_deref(), now, cx)
});
let list = List::new().children(rows);
let new = IconButton::new("new-session", IconName::Plus)
+8 -7
View File
@@ -58,7 +58,8 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp
| Inside herdr, the folder of herdr's focused pane, read from its socket, on macOS, Linux and Windows | T29, T32 | `term::herdr` tests: the socket found as herdr 0.9 finds it, only the terminal client followed and a remote one refused, the snapshot read, a socket round trip, a process's path, arguments and variables; `the_foreground_is_the_program_s_own_group`; on X11 with herdr 0.9.5 run in the session: its start showed the focused pane's folder, a cd in the pane moved the panel, the focus moved to a pane in `/etc` and back moved it each time, and the client's end gave the panel back to bash's report, then `exec herdr` from that bash, with `HERDR_SOCKET_PATH` relative to its folder, was followed through the socket there; on macOS under `shot`, herdr 0.9.5 as the program and again by `exec` from `sh` with a relative socket, its pane's cd moved the panel; on Windows, `term::herdr` tests on the CI runner: the folder under `APPDATA` and `USERPROFILE`, the `herdr.exe` nearest under the shell, this test's name, arguments and variables read through the live process list and `sysinfo`, a round trip over a named pipe; no real herdr has run on Windows yet | Partial |
| Inside herdr, herdr's agent states are the session's records | T38 | `term::herdr` tests: each agent in a state herdr knows is its pane's record, its app, title and message from herdr's fields, unknown and new states passed by, and a snapshot without agents lists none. `term::status`: herdr's agents replace their own records alone, a program's `herdr/build` kept through an empty snapshot, a full one and herdr's leaving, since a program's id ends at a colon; the same agents change nothing, and the next snapshot puts them back after a full reset | Pass |
| A row shows its session's status, hover each record; a hidden session that starts waiting marks its row | T39 | `term::status` tests: blocked, then error, working, done and idle show, the record reported last in its state speaks; an app comes from the nearest ancestor that names one; typing sees done and error, herdr's aside; each change of the state a row shows is noted, a wait after work in one output among them. `sessions` tests: hover lists each record's state, app, title and message, ten at most, then a failed command's status; a program's text shows on one line without direction controls or invisible formatting, joiners kept, cut at 120 characters. In the app on macOS, `shot` showed each mark: the ring at 40%, the still loader, a shield over three records whose hover listed each, the root's app inherited, a check, a cross, a hollow circle, an alert after `133;D;1`, a hidden session's question mark with its title strong, and a done record gone after a typed key; after review, a hidden session that waited, worked and waited again in one output set its title strong, an open hover followed a record replaced under it and work from 20% to 80%, ten long records scrolled to the last within the window, and a selected row at 0% in Ely Dark showed its loader; after round 2, herdr's records replaced in the same state showed at once in an open hover, and a hidden session that waited and worked again in one output set its title strong | Pass |
| System notifications on macOS, Windows and Linux: a session that starts waiting, finishes or fails, and OSC 9, 777 and 99, while the window is inactive or the session hidden, one a session every 3 s; a press shows the window and session | T40 | `term::reports` tests: a session posts once in 3 s, its own under one tag; a program's notifications go by their id and live until pressed, closed or expired, an expiry taking only its own post and none a later post holds; a session keeps its last 64; the text shows as text. `term::notify`: the query, on Linux without close and `w`, alive, untracked and activation answers. In the app on macOS, outside a bundle, where gpui posts nothing and the log names each post: an OSC 9 posted and an OSC 777 within 3 s was dropped; an OSC 99 with `c=1` and `w=800` posted, its program heard `p=close;untracked`, alive listed it, and once it expired alive listed none; a closed one left alive empty; the query's answer came back; a command that failed posted; a notice, alive and DA1 in one write came back as alive naming it, then DA1's answer; a notice set to expire whose session ended expired after it; a hidden session that waited and worked again in one output posted its wait; and a notice sent while the window was active and the session shown posted nothing, the next, once the window lost focus, posted. No system showed one yet: on macOS gpui posts only from the app bundle, which asks the owner's leave first, and Windows and Linux did not run it; a press needs the system's notification center, so the owner posts and presses one on each | Partial |
| A row's mark moves: work ripples from the centre, progress fills the outer ring clockwise from the top, waiting stays lit and dips, a settled state draws its shape and holds; the window draws at the marks' steps only while one moves | T42 | `sessions::orb` tests: the ripple peaks at the centre, then every edge, then every corner, once every 15 steps; at 50% the first four ring dots are lit, the next pulses and the rest stay dim, and at 100% the centre pulses; waiting dips on the 1st, 2nd, 5th and 6th of 20 steps, every dot alike; a done check draws from its left end and holds after 5 steps, idle is a ring; work and waiting never stop. `sessions` test: the mark takes the most pressing look, a failed command as an error. In the app on macOS, at 70% glass in Ely Dark, the recorder's stills showed each look in one column: the ripple, 40% filled, waiting lit, a check, two crosses and a ring; two stills 0.4 s apart showed the ripple at two steps | Pass |
| System notifications on macOS, Windows and Linux: a session that starts waiting, finishes or fails, and OSC 9, 777 and 99, while the window is inactive or the session hidden, one a session every 3 s; a press shows the window and session | T40 | `term::reports` tests: a session posts once in 3 s, its own under one tag; a program's notifications go by their id and live until pressed, closed or expired, an expiry taking only its own post and none a later post holds; a session keeps its last 64; the text shows as text, and as markup's text on Linux; a session posts unless it is the one shown in an active window; waiting, done and error post, work and rest do not; a tag names the session that posted it, by itself, an id or a count. `term::titles`: OSC 0 and 2 set the title as vte splits it, 15 parts past the number at most, and as alacritty joins it; CSI 22 t and 23 t save it and bring it back; a full reset empties both. `term::tap`: the scanner finds CSI 22 t and 23 t as vte reads them; a title's body holds 65536 bytes, another's 4096. `term::pty`: titles, notifications and full resets reach the session in the output's order, one write and a write a sequence alike, a title of 5000 bytes and one of 17 parts among them. `term::notify`: the query, on Linux without close and `w`, alive, untracked and activation answers. In the app on macOS, outside a bundle, where gpui posts nothing and the log names each post: an OSC 9 posted and an OSC 777 within 3 s was dropped; an OSC 99 with `c=1` and `w=800` posted, its program heard `p=close;untracked`, alive listed it, and once it expired alive listed none; a closed one left alive empty; the query's answer came back; a command's end without its C posted nothing, and a failed one after its C posted; a notice after a title set, or after a full reset, in the same write named the session as split writes do, as did one after a full reset and 2 KB to 32 KB more of output, and a title of 17 parts or of 4100 bytes named the notice as it named the row, one write or two; a notice, alive and DA1 in one write came back as alive naming it, then DA1's answer; a notice set to expire whose session ended expired after it; a hidden session that waited and worked again in one output posted its wait; and a notice sent while the window was active and the session shown posted nothing, the next, once the window lost focus, posted. No system showed one yet: on macOS gpui posts only from the app bundle, which asks the owner's leave first, and Windows and Linux did not run it; a press needs the system's notification center, so the owner posts and presses one on each | Partial |
| Colors, styles, five underlines, wide characters, combining marks | T06 | `term::frame`, `term::ink`, `term::paint` tests | Pass |
| Box drawing, blocks and powerline shapes as geometry | T07 | `term::boxes` tests | Pass |
| JetBrains Mono, any installed font, Nerd Font icons | T06, T17 | `tests/fonts.rs` | Pass |
@@ -79,7 +80,7 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp
| Columns toggle and keep their state | T11, T23 | a click on each title bar button hid its column and saved it, and a restart opened the window with both hidden (147 columns against 87); Cmd-B with the app active; Ctrl-Shift-B on a real X11 window | Pass |
| The window opens at the size it was left at; maximized or full screen, the size before stays; a kept size past the screen shrinks to it | T30 | `a_value_out_of_range_names_its_field`; on X11 under xfwm4: the first start opened at 1200 by 760 and wrote nothing; a resize to 900 by 600 was kept with one write; maximized by xfwm4's Alt+F10 the window spanned the 1400 by 900 screen while the file kept 900 by 600, and restored it was 900 by 600 again; maximized, minimized and restored, the file kept 900 by 600; resized to 950 by 650 and minimized at once, the file kept 900 by 600 while minimized and 950 by 650 once restored; the next start opened at 950 by 650 and wrote nothing; `WM_NORMAL_HINTS` names a 100 by 100 minimum, and a resize to 900 by 50 held at 900 by 100, kept so; a kept 3000 by 2000 opened at 1400 by 900, logged; on macOS, through gpui's own window: a kept 900 by 600 opened at 900 by 600, where a centered edge on a half point had grown it to 601, and a move wrote nothing; a resize to 923 by 617, covered by an opaque window within 150 ms and uncovered, was kept once shown, and the next start opened at 923 by 617; zoomed, minimized, restored, full screen and back, the file kept 923 by 617; a 100 by 100 window was kept; a kept 5000 by 4000 opened at the visible 1920 by 1113, logged; on Windows the owner dragged v0.1.2's window to a new size, closed it, and it opened again at that size; a mouse drag on macOS not yet shown | Pass |
| The sessions and right columns resize by a drag on their border toward the terminal, 160 to 960 pixels, kept in settings | T33 | Ely `5c2822c` and `005faef`: `shell::tests::border`: a border toward the filling column drags its width, before it by its right and after it by its left; a drag stops at each limit; a drag in a header moves no window in Windows', macOS' and Linux's style (the test window panics on a move); a drag sets off from the width drawn, which holds Windows' caption buttons inside the window; a short shell squeezes both columns, and a drag moves each border the pointer's way from the width kept, a drag with no sideways move changing none (Ely `0b4848b`): on macOS 900 and 900 kept in a 1200 window drew 572 each, and a drag of 20 from the left border saved 920 and moved it right, where review round 1 saw it jump to 435; on X11 900 and 900 in a 1380 window, a drag of 40 saved 940 and moved both borders right, and a drag with no sideways move kept them; a drag out and back to where it set off gives the width it had (Ely `da3fdca`): on macOS each border dragged 50 out and back kept 240 and 240 and came back to its pixel, where review round 2 saw 290 kept, and on X11 both settled at 240 with nothing written; a resizing column with no border toward a filling one fails; `a_value_out_of_range_names_its_field` (`panel_width` 1000); on macOS under `shot`, the sessions border dragged in the header and the right border in the body moved 50 points each, saved 290 and 290 once, and the next start opened at them; on X11 under xfwm4: a drag in the body set 300, one on the right border in the header set 300 and left the window at 0,0, drags stopped at 960 and 160, the next start opened at 160, and both borders paint the theme's border color, #37384A, as before; the line had painted half a pixel on macOS and none on X11 until Ely `005faef`; no real drag on Windows yet | Partial |
| The title shows the session's title, nothing else | T11 | log: "the title is", per session shown | Pass |
| The title shows the session's title, nothing else | T11, T40 | log: "the title is", per session shown; since T40 the title comes from the tap in the output's order, and the shell tests read each shell's title from it | Pass |
| The pointer over the terminal is the arrow | T26 | the terminal sets no pointer, so gpui's arrow shows; on X11 with the pointer over the grid, gpui asked for no I-beam ("text"), which it had asked for, and failed to load under Xvfb, before | Pass |
| The title bar is transparent | T11, T19 | T19 review | Pass |
| Every title bar control takes its press, Ely's window buttons on Windows and Linux too | T26 | Ely `cdc5ce8`: `window_controls_occlude_what_lies_under` (each style; before, the box under Windows' caption buttons took the press), `a_wobbling_press_on_a_slot_moves_no_window` (before, the window moved), `a_wobbling_press_on_a_column_title_moves_the_window`; on the owner's PC, window messages asked the app for Windows' hit test along the title bar: v0.1.0 answered caption from end to end, T26's build answers client over the toggles and +, caption over the titles, min, max and close over Ely's buttons, and a press on close closed the app; the owner, given v0.1.1's build and these controls to click, reported it works on Windows | Pass |
@@ -157,16 +158,16 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp
|---|---|---|---|
| The icon: Ely, a girl with long pink hair, holding a terminal | T24 | `assets/icons`, drawn at 16 to 1024 px and legible at 32; the owner saw it | Pass |
| macOS: the rounded square; Windows and Linux: the figure alone, flat | T24 | `elyterminal-macos.svg` on Apple's grid; `elyterminal.svg`, which the Linux tarball carries and the Windows `.ico` renders | Pass |
| `scripts/mac-app.sh` builds the app, signed ad hoc, or for a release with Developer ID and notarized, and its disk image | T24, T26, T31 | its run: `codesign --verify --strict` passes on the app and on its copy in the image; the `.icns` holds 16 to 1024 px; the image verifies and mounts with the app beside Applications; LaunchServices opens the app as `com.zacharyzhang.elyterminal`; for v0.1.1 the Release workflow (run 959) ran it on `macos-signing`: notarization Accepted, the ticket stapled, and on this Mac the downloaded image and its app pass `codesign --verify --strict` under the Developer ID and Gatekeeper's `accepted, source=Notarized Developer ID`; the owner opened it; for v0.1.2 the Release workflow (run 1001) ran it again: the downloaded image's stapled ticket validates and Gatekeeper accepts it as `Notarized Developer ID`, and its app, version 0.1.2, passes `codesign --verify --strict --deep` under the Developer ID with the hardened runtime and is accepted the same way; without the key the script stops before building | Pass |
| `scripts/mac-app.sh` builds the app, signed ad hoc, or for a release with Developer ID and notarized, and its disk image | T24, T26, T31, T41 | its run: `codesign --verify --strict` passes on the app and on its copy in the image; the `.icns` holds 16 to 1024 px; the image verifies and mounts with the app beside Applications; LaunchServices opens the app as `com.zacharyzhang.elyterminal`; for v0.1.1 the Release workflow (run 959) ran it on `macos-signing`: notarization Accepted, the ticket stapled, and on this Mac the downloaded image and its app pass `codesign --verify --strict` under the Developer ID and Gatekeeper's `accepted, source=Notarized Developer ID`; the owner opened it; for v0.1.2 the Release workflow (run 1001) ran it again: the downloaded image's stapled ticket validates and Gatekeeper accepts it as `Notarized Developer ID`, and its app, version 0.1.2, passes `codesign --verify --strict --deep` under the Developer ID with the hardened runtime and is accepted the same way; for v0.1.3 the Release workflow (run 1157) ran it again, and its image and app, version 0.1.3, check the same way; without the key the script stops before building | Pass |
## 15. Release
| PRD line | Task | Evidence | Status |
|---|---|---|---|
| A Gitea release, `vVERSION`, with the macOS disk image, a Windows zip and a Linux tarball | T25, T26, T27, T31 | `v0.1.2` at `4364802`: the zip and the tarball from their scripts on the owner's PC and its WSL, the disk image from the Release workflow (run 1001), signed and notarized; published once CI run 1000 on that commit passed on macOS, Ubuntu and Windows; each file downloaded back anonymously matches the SHA-256 in the release notes, and the tag names `4364802`. `v0.1.1` at `43ea5b1`: the zip from its script on the owner's PC, the disk image from the Release workflow, signed and notarized; the Linux tarball, rebuilt for T27 from `aae7fba` by its script on the owner's PC under Omarchy, took the first one's place, and the notes say so; each downloaded back anonymously, once the repository was public, matches the SHA-256 in the release notes. `v0.1.0` at `0075667`, its three files built from that commit by their scripts and checked again: the disk image verifies and its app passes `codesign --verify --strict`; the zip's executable has the icon and no C runtime DLL; the tarball opens its window on Ubuntu 22.04; each file downloaded back matches the SHA-256 in the release notes | Pass |
| The Windows executable carries the icon | T25, T31 | its resources: icon group 1 with the seven images of `elyterminal.ico`, 16 to 256 px, and gpui's manifest (PerMonitorV2); a GUI program, importing no C runtime DLL; not yet seen on a Windows screen; v0.1.2's executable the same | Pass |
| The Linux tarball carries the icon and a desktop entry; the window names itself `com.zacharyzhang.elyterminal` | T25, T27 | on Ubuntu 22.04: `desktop-file-validate` reports nothing, every library resolves, glibc 2.35; under Xvfb `WM_CLASS` reads `com.zacharyzhang.elyterminal` twice, and under headless sway the window's `app_id` reads the same; v0.1.2's passes the same checks on Ubuntu 22.04, run in the owner's WSL, `WM_CLASS` too; T27's rebuild passes the same checks on Ubuntu 22.04, whose llvmpipe (LLVM 15) leaves the window black in a capture, the first tarball's too, while Debian 13's paints it | Pass |
| Each build comes from its script | T25, T27, T31 | `scripts/mac-app.sh` on macOS, `scripts/windows-zip.ps1` on the owner's Windows PC, `scripts/linux-tar.sh` in the owner's WSL, and for T27 on the owner's PC under Omarchy; v0.1.2's three the same way, the disk image's on `macos-signing` | Pass |
| A Gitea release, `vVERSION`, with the macOS disk image, a Windows zip and a Linux tarball | T25, T26, T27, T31, T41 | `v0.1.3` at `7569f00`: the zip and the tarball from their scripts on the owner's PC and its WSL, the disk image from the Release workflow (run 1157), signed and notarized; published once CI run 1156 on that commit passed on macOS, Ubuntu and Windows; each file downloaded back anonymously matches the SHA-256 in the release notes, and the tag names `7569f00`. `v0.1.2` at `4364802`: the zip and the tarball from their scripts on the owner's PC and its WSL, the disk image from the Release workflow (run 1001), signed and notarized; published once CI run 1000 on that commit passed on macOS, Ubuntu and Windows; each file downloaded back anonymously matches the SHA-256 in the release notes, and the tag names `4364802`. `v0.1.1` at `43ea5b1`: the zip from its script on the owner's PC, the disk image from the Release workflow, signed and notarized; the Linux tarball, rebuilt for T27 from `aae7fba` by its script on the owner's PC under Omarchy, took the first one's place, and the notes say so; each downloaded back anonymously, once the repository was public, matches the SHA-256 in the release notes. `v0.1.0` at `0075667`, its three files built from that commit by their scripts and checked again: the disk image verifies and its app passes `codesign --verify --strict`; the zip's executable has the icon and no C runtime DLL; the tarball opens its window on Ubuntu 22.04; each file downloaded back matches the SHA-256 in the release notes | Pass |
| The Windows executable carries the icon | T25, T31, T41 | its resources: icon group 1 with the seven images of `elyterminal.ico`, 16 to 256 px, and gpui's manifest (PerMonitorV2); a GUI program, importing no C runtime DLL; not yet seen on a Windows screen; v0.1.2's and v0.1.3's executables the same | Pass |
| The Linux tarball carries the icon and a desktop entry; the window names itself `com.zacharyzhang.elyterminal` | T25, T27, T41 | on Ubuntu 22.04: `desktop-file-validate` reports nothing, every library resolves, glibc 2.35; under Xvfb `WM_CLASS` reads `com.zacharyzhang.elyterminal` twice, and under headless sway the window's `app_id` reads the same; v0.1.2's passes the same checks on Ubuntu 22.04, run in the owner's WSL, `WM_CLASS` too; T27's rebuild passes the same checks on Ubuntu 22.04, whose llvmpipe (LLVM 15) leaves the window black in a capture, the first tarball's too, while Debian 13's paints it; v0.1.3's holds the same three files, its desktop entry names `com.zacharyzhang.elyterminal`, and its binary asks for glibc 2.35 at most | Pass |
| Each build comes from its script | T25, T27, T31, T41 | `scripts/mac-app.sh` on macOS, `scripts/windows-zip.ps1` on the owner's Windows PC, `scripts/linux-tar.sh` in the owner's WSL, and for T27 on the owner's PC under Omarchy; v0.1.2's and v0.1.3's three the same way, the disk image's on `macos-signing` | Pass |
## CI notes
+21
View File
@@ -192,3 +192,24 @@ Each task gets at most three codex rounds. Each round: the commit, the verdict,
- Round 1, a175b18: FAIL. herdr's records took ids under `herdr/`, which a program may name through OSC 7501, so a program's `herdr/build` went at herdr's first snapshot, an empty one too, and at its leaving. Fixed in 7d56063: herdr's records take `herdr:` before the pane, a colon no OSC 7501 id holds, since a program's id ends at one; the test that keeps a program's `herdr/build` and `herdr` through an empty snapshot, a full one and herdr's leaving fails with the old prefix.
- Round 2, 7d56063: PASS. The reviewer's round 1 probes failed on a175b18 and passed on 7d56063; probes with a socket and JSON replies of the reviewer's own, under real GPUI scheduling and the project's pty, passed the states and fields, clear and reset, errors, a reply after the shell's folder, and herdr's leaving; no herdr server of the owner's was asked. CI runs 1127 on 72acbe4, 1128 on a175b18 and 1139 on 7d56063 passed on macOS, Ubuntu and Windows.
## T39
- Round 1, 85bcbf4: FAIL. A hidden session that waited, worked and waited again in one output lost its mark, since the workspace heard the records once an output; an open hover kept the text it opened with; ten long records ran past the window and could not be read; at 0% the ring vanished on a selected row in Ely Dark, its track the row's ground. Fixed in 53b8978: the view notes each change of the state its row shows and tells the workspace of each; hover reads the records and the last command's status, now the view's, at each frame, stays under the pointer and scrolls within the window; the ring holds Ely's loader.
- Round 2, 53b8978: FAIL. herdr's records replaced in the same state left an open hover as it was, since nothing drew the view again; a hidden session that waited and worked again in one output still lost its mark, since the workspace checked the state at the output's end. Fixed in 3aaeb36: a change of herdr's records draws the view again; a hidden session's turn to waiting marks its row though it went on within the same output.
- Round 3, 3aaeb36 at efda50f: PASS. In the app on macOS the reviewer's repros of all three rounds passed: herdr's new records showed in an open hover through a mock herdr of the reviewer's own, a hidden session that waited and worked, or waited and finished, in one output kept its mark until shown, and the hover, long list, scrolling and Ely Dark 0% repros held. CI runs 1142 on b504bfb, 1144 on 85bcbf4, 1146 on 53b8978 and 1149 on efda50f, which holds 3aaeb36, passed on macOS, Ubuntu and Windows.
## T40
- Pre-review, 4e029f1, a first version kept in a clone and never pushed: three Claude agents read it against kitty's spec, gpui's platform code, and the PRD and rules. They found the alive answer behind a later DA1, since the workspace answered it after the output; close and expiry claimed on Linux, where gpui closes nothing; an expiry lost with its session; a body read as markup on Linux; a press lost with its record; bash 3.2's redrawn D posting again; and no test of the posting conditions. 31de5e4 rewrote it so each session keeps its notices in its terminal view, where answers keep their order; 52576bb fixed the rest; efda50f posts at each turn of the row, as T39's round 2 set the waiting mark.
- Round 1, 31de5e4 to 52576bb: FAIL. The first prompt, sent at 31de5e4, was lost while Codex started, so the round ran at 52576bb. A notification took the session's title from before a title set or a full reset in the same output, since the tap reads a notice before alacritty's parser reads the title. Fixed in 22752a2: the tap follows OSC 0 and 2, the title stack and full resets, and sends each title in order.
- Round 2, 22752a2: FAIL. alacritty's later title events still overwrote the tap's, so a full reset, 2 KB more output and a notice named the old title; the tap kept 17 parts of a title where vte keeps 15, and dropped a title over 4096 bytes that vte keeps. Fixed in 140c2a5: the title comes from the tap alone, so the row, the window and a notification name the session alike; the tap splits a title as vte does and keeps 65536 bytes of one.
- Round 3, 140c2a5: PASS. In the app on macOS, outside the bundle, where the log names each post, the reviewer's probes of all three rounds passed: a title set in one write or split, after a reset with up to 32 KB more output, of 17 parts and of 4100 bytes; 21 title cases matched the pinned vte and alacritty; and the lifecycle, rate limit, untracked, alive before DA1, expiry, close, in-view and press probes held. No system has shown a notification yet; the owner posts and presses one on each. CI runs 1148 on 31de5e4, 1149 on efda50f and 1150 on 52576bb passed on macOS, Ubuntu and Windows; 140c2a5's code is CI'd on T41's version commit, which holds it.
## T41
- Round 1, 7569f00 with release v0.1.3: PASS. The reviewer compared `Cargo.lock` with its parent, where of 844 packages only elyterminal moved, to 0.1.3; matched the 127 files of the tag's source archive to 7569f00 byte for byte; downloaded the three files anonymously against their SHA-256s; checked the disk image's stapled notarization and its app's Developer ID signature, hardened runtime and version 0.1.3, the zip's icon and manifest against their sources byte for byte and its imports, no C runtime DLL among them, and the tarball's three files and glibc 2.35; and matched the notes to T32 to T40. The app was not run, since its first notification asks the owner's leave. CI run 1156 on 7569f00 passed on macOS, Ubuntu and Windows before the release was published; Release run 1157 built the notarized disk image.
## T42
- No review: the owner waived it for this change.