diff --git a/AGENTS.md b/AGENTS.md index bca619b..aae71d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, a command's end, the state its row shows, the program's exit. `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. `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; `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 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. - `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). @@ -99,6 +99,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-16: prompts and commands carry OSC 133 marks, which the owner asked for, read as Ghostty reads them: A or N starts a prompt, B or I ends it, C starts a command's output, and D ends the command with its status, D's first option, none when that is no number. The tap reads any program's marks before the parser, so it never sees the cursor: A's fresh line and L, which need it, are not done, and P adds nothing to A. Each session keeps the status its last command ended with. The integration marks what the shell leaves unmarked, and the user's prompt still sees the command's status. zsh: precmd reads `$?` and sends D after a command ran, preexec sends C, and a last precmd hook wraps PS1 in A and B under `prompt_percent`, which the first prompt says while it is off; it moves to the end of `precmd_functions` at each prompt, since a theme that sets PS1 in a hook of its own loads after it, and at the first prompt `sched +0` wraps it once more, since zsh runs due events after every precmd hook; that call returns the command's status, which the prompt shows, but it would reset `$pipestatus`, so it serves the first prompt alone. bash: PROMPT_COMMAND's first entry reads `$?`, which each entry resets, and its last wraps PS1. From bash 4.4 PS0 sends C and its subscript notes that a command ran, since bash prints no PS0 for an empty line, and the first entry sends D for it; both need `promptvars`, on by default. macOS's bash 3.2 has no PS0 and nothing else between reading a line and running it: its DEBUG trap runs for a key binding's command (`bind -x`) as for a typed one, with nothing to tell the two apart, runs for no line that is only a subshell, and under `extdebug` its status decides whether a command runs. So bash 3.2 sends no C, and Ghostty's automatic integration skips bash 3.2. Its D comes from PS1 once `\#`, the command number, has moved, which neither a key binding nor an empty line does; readline sends a prompt's escapes again at each redraw, so that D goes again with it, the same status. Every mark of a command rides an expansion, which `promptvars` turns on, so while it is off at start the script says so rather than mark nothing without a word. fish 4.3 marks its own prompts and commands, fish 4.0 all but B, older ones none: the script adds what the running fish lacks, around the prompt shown, and wraps it again when the user's changes. PowerShell: the prompt reads `$?` first and sets it back for the user's prompt through `Write-Error '' -ErrorAction Ignore`, which adds nothing to `$Error`. A failure ends with `$LASTEXITCODE` when a program set it during the line, since each run stores a new object, equal or not, which `[object]::ReferenceEquals` tells apart, unless the line surely ends in PowerShell: its syntax tree, read as PSReadLine hands the line over, ends in a cmdlet, as `Get-Command` resolves the name, aliases through, in a name not found, or in no command; such a line ends with 1, a cmdlet's failure under `-ErrorAction Ignore` too. A line that ends in a block, a function, a script or a call through a variable takes the code of a program it ran: only a hook on every command lookup would name the last command run, and it slowed a loop of commands by half. `$LASTEXITCODE` is read through `Get-Variable`, since strict mode refuses an unset variable and a prompt that fails falls back to `PS>`. C goes out when PSReadLine hands over a line that is not blank; without PSReadLine the script says it marks no command. herdr passes no OSC 133 on, so a session running herdr keeps the status from before it. - 2026-10-16: the view jumps between prompts, which the owner asked for, as Ghostty jumps: Cmd-Up and Cmd-Down, with Shift or without, on macOS, Ctrl-Shift-Up and Ctrl-Shift-Down elsewhere, scroll to the prompt that starts before or after the view's top; a prompt on the screen's last page means the bottom, which the view cannot pass, and with none there nothing moves. alacritty's rows hold no marks, and the tap, which reads before the parser, never sees the cursor, so the tap rewrites what goes to the parser: an OSC that may be a mark is held whole until it ends and read as `marks` reads it, and a prompt's start becomes an OSC 8 link of the terminal's own, its target `\x7f`, which no program names, its id `\x7f` and the prompt's number; the prompt's end closes it. The parser stamps each cell the prompt prints with the link, SGR 0 keeps it, and it rides the cells through scroll, reflow and history; a prompt starts on the first row that shows its number, blank rows within it passed. A program's own link keeps its target and takes the prompt's number in its id: one opened inside a prompt carries it, and a prompt that starts inside one opens it again with the number and gives back its own id at the prompt's end, its target kept whole as the scanner keeps it; its end inside a prompt takes the prompt's link up again. A cursor restore that brings back a link passes the rewrite by. An OSC held, a body of 4096 bytes at most as the scanner keeps it and twice that with the controls vte skips, shows nothing until it ends; controls vte runs inside an escape go on before an ESC that waits, and only an ESC from ground waits: one that ends an OSC goes on with it, so what follows runs where the parser stands. A rewrite turns a mark of 8 bytes inside a program's link into that link again, its target up to 4096 bytes, so the tap reads about 2 KB of the buffer's room at a time, which moved 3 MB through a pty as fast as larger reads. Links, Cmd-press among them, pass over the prompt's link. The tap's OSC scanner ends a body as vte does, at BEL, ESC, CAN or SUB, and skips the controls vte skips, so a mark and its link agree. - 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; `o`, `a`, `c` and `w` are kept for showing; a lone body is the title, and one with neither passes by. The query is answered with what is read so far, `p=title,body,?,alive:o=always`, and alive with no id, since none shows yet; 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. Notifications go to the workspace, which logs them. +- 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, so a session that waits, works and waits again in one output starts waiting again; a hidden session whose mark turns to blocked, and still is when the workspace hears it, sets its title strong until it is shown. 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 and still is at the end of the output that turned it, 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. diff --git a/PRD.md b/PRD.md index 4c325dc..3c02ef8 100644 --- a/PRD.md +++ b/PRD.md @@ -8,7 +8,7 @@ Chapters run in build order. `TASKS.md` follows them line by line. - One window. Sessions on the left, the terminal in the middle, Git or Files on the right. - Every setting has a control in the settings window. No setting lives only in a file. -- Silent: no sound, no bell, no notifications, no update checks, no telemetry, no tips, no onboarding. +- Silent: no sound, no bell, no notifications but a session's, below, no update checks, no telemetry, no tips, no onboarding. - Clean: no subtitle, no tagline, no logging options, no status bar, no badges that do not carry state. - Built-in themes, each shown as a small preview, Catppuccin among them. A theme colors the terminal and the chrome. - The window background blurs on macOS, Windows, Wayland and X11. @@ -47,7 +47,7 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a - Prompt marks, OSC 133: the shell integration marks where each prompt starts (A) and ends (B), where a command starts (C), and where it ends with its exit status (D), all but C on macOS's bash 3.2, which has no hook for it; the terminal reads any program's OSC 133. Cmd-Up and Cmd-Down, and Cmd-Shift-Up and Cmd-Shift-Down, on macOS, Ctrl-Shift-Up and Ctrl-Shift-Down elsewhere, scroll to the prompt before or after the view's top, as in Ghostty. - Program status, OSC 7501 revision 0.4: each terminal keeps the records programs report (state, id, kind, progress, app, title, msg), checked as the spec checks them; a new prompt drops working, blocked and idle records, a full reset drops them all; the terminal answers the support query with the states and kinds it takes. - Progress, OSC 9;4: set, error, indeterminate and pause stand as the root record until a program reports OSC 7501, until a full reset. -- Notifications: OSC 9 (iTerm2), OSC 777 notify (rxvt) and OSC 99 (kitty: title and body in chunks, base64, ids, occasions, focus and report on activation, close, and its query); ConEmu's other OSC 9 commands are no notifications. +- Notifications: OSC 9 (iTerm2), OSC 777 notify (rxvt) and OSC 99 (kitty: title and body in chunks, base64, ids, focus and report on activation, close, expiry, which ones live, and its query; `o` passes by, since a session posts only while unseen, below); ConEmu's other OSC 9 commands are no notifications. - Inside herdr, which keeps its panes' OSC 7, the session's folder is that of herdr's focused pane, read from herdr's socket while herdr's client runs in the session, on macOS, Linux and Windows. - Inside herdr, which passes no OSC 7501 on, herdr's agent states (idle, working, blocked, done) are the session's status records. - Grid: colors (named, 256, truecolor, OSC 4/10/11/12), bold, dim, italic, underline (single, double, curly, dotted, dashed), strikethrough, inverse, hidden, wide characters, combining marks. diff --git a/README.md b/README.md index 9d073f1..165e7ad 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,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. +- 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. - Files shows the session's folder as a tree with Git's marks. Inside herdr the folder is that of herdr's focused pane. diff --git a/src/app.rs b/src/app.rs index e255963..6376767 100644 --- a/src/app.rs +++ b/src/app.rs @@ -50,6 +50,23 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> { workspace::bind_keys(cx); settings::window::bind_keys(cx); menus::init(cx); + cx.set_app_identity(APP_ID, TITLE); + // A press on a notification goes to the window whose session posted it. + cx.on_system_notification_response(|response, cx| { + let tag = response.tag; + let pressed = cx + .windows() + .into_iter() + .filter_map(|window| window.downcast::()) + .any(|workspace| { + workspace + .update(cx, |this, window, cx| this.pressed(&tag, window, cx)) + .unwrap_or(false) + }); + if !pressed { + log::info!("app: notification {tag} pressed, but no session holds it"); + } + }); // Settings alone hold no terminal, so they go with the last one. cx.on_window_closed(|cx, _| { let windows = cx.windows(); diff --git a/src/sessions.rs b/src/sessions.rs index a1c67b2..abdb38e 100644 --- a/src/sessions.rs +++ b/src/sessions.rs @@ -121,7 +121,7 @@ fn mark(terminal: &Entity, cx: &App) -> Option { 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(), failed) { + let mark = match (records.shown().map(|(_, record)| record), failed) { ( Some(Record { state: State::Blocked, @@ -215,23 +215,13 @@ fn mark(terminal: &Entity, cx: &App) -> Option { ) } -/// What hover shows: a line for each record, ten at most, with its state, app, title and message, then a failed command's status. +/// What hover shows: a line for each record, ten at most, then a failed command's status. pub fn hover(records: &Records, failed: Option) -> Vec { - let line = |(id, record): (&str, &Record)| { - let parts = [ - records.app(id), - record.title.as_deref(), - record.msg.as_deref(), - ]; - let said: Vec = parts.into_iter().flatten().map(plain).collect(); - let state = label(record); - SharedString::from(if said.is_empty() { - state - } else { - format!("{state}: {}", said.join(" · ")) - }) - }; - let mut lines: Vec = records.iter().take(LISTED).map(line).collect(); + let mut lines: Vec = records + .iter() + .take(LISTED) + .map(|(id, record)| line(records, id, record).into()) + .collect(); let more = records.iter().count().saturating_sub(LISTED); if more > 0 { lines.push(format!("{more} more").into()); @@ -242,6 +232,22 @@ pub fn hover(records: &Records, failed: Option) -> Vec { lines } +/// A record as hover and notifications say it: its state, then its app, title and message. +pub fn line(records: &Records, id: &str, record: &Record) -> String { + let parts = [ + records.app(id), + record.title.as_deref(), + record.msg.as_deref(), + ]; + let said: Vec = parts.into_iter().flatten().map(plain).collect(); + let state = label(record); + if said.is_empty() { + state + } else { + format!("{state}: {}", said.join(" · ")) + } +} + /// A record's state, as hover names it. fn label(record: &Record) -> String { match (record.state, record.kind, record.progress) { diff --git a/src/term/herdr.rs b/src/term/herdr.rs index a7e508b..648aa05 100644 --- a/src/term/herdr.rs +++ b/src/term/herdr.rs @@ -77,8 +77,8 @@ pub fn follow(cx: &mut Context) -> Task<()> { ); states.clear(); let gone = view.update(cx, |view, cx| { - if view.records.herdr(&[]) { - view.restate(cx); + if view.records.herdr(&[]) && view.restate(cx) { + view.tell_state(cx); } }); if gone.is_err() { @@ -127,8 +127,8 @@ pub fn follow(cx: &mut Context) -> Task<()> { let Ok(still) = view.update(cx, |view, cx| { let still = view.reports == reports; if still { - if view.records.herdr(&agents) { - view.restate(cx); + if view.records.herdr(&agents) && view.restate(cx) { + view.tell_state(cx); } if let Some(folder) = report.clone() { cx.emit(TerminalEvent::Folder(folder)); diff --git a/src/term/input.rs b/src/term/input.rs index dc21f8e..b14bb9a 100644 --- a/src/term/input.rs +++ b/src/term/input.rs @@ -77,8 +77,8 @@ impl TerminalView { } drop(term); self.pty.write(bytes); - if self.records.seen() { - self.restate(cx); + if self.records.seen() && self.restate(cx) { + self.tell_state(cx); } cx.notify(); } diff --git a/src/term/mod.rs b/src/term/mod.rs index 4f8dd94..e457634 100644 --- a/src/term/mod.rs +++ b/src/term/mod.rs @@ -15,6 +15,7 @@ mod paint; mod pointer; mod prompts; mod pty; +mod reports; pub mod shell; mod status; mod tap; diff --git a/src/term/notify.rs b/src/term/notify.rs index 008a001..0b0152d 100644 --- a/src/term/notify.rs +++ b/src/term/notify.rs @@ -2,15 +2,6 @@ use base64::Engine; use super::status::BASE64; -/// When a notification may show: OSC 99's `o`. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum Occasion { - #[default] - Always, - Unfocused, - Invisible, -} - /// A notification a program asks for: OSC 9, 777 or 99. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Notice { @@ -18,7 +9,6 @@ pub struct Notice { pub id: Option, pub title: String, pub body: String, - pub occasion: Occasion, /// A press shows the window and session (`a=focus`, the default), and tells the program (`a=report`). pub focus: bool, pub report: bool, @@ -121,7 +111,6 @@ fn plain(title: String, body: String) -> Notice { id: None, title, body, - occasion: Occasion::Always, focus: true, report: false, close: false, @@ -141,14 +130,30 @@ fn safe(bytes: &[u8], what: &str) -> Option { Some(text) } -/// The answer to OSC 99's query, naming `id`: what this terminal reads of notifications. +/// gpui closes no notification on Linux, nor replaces one, so close and expiry hold elsewhere alone. +pub(super) const CLOSES: bool = !cfg!(target_os = "linux"); + +/// The answer to OSC 99's query, naming `id`: a press focuses and reports, a notification posts while unseen, its payloads, and close and expiry where gpui closes one. pub fn answer(id: &str) -> String { - format!("\x1b]99;i={id}:p=?;p=title,body,?,alive:o=always\x1b\\") + let (close, expire) = if CLOSES { ("close,", ":w=1") } else { ("", "") }; + format!( + "\x1b]99;i={id}:p=?;a=focus,report:o=invisible:p=title,body,{close}?,alive{expire}\x1b\\" + ) } -/// The answer to OSC 99's `p=alive`, naming `id`: none, as no notification shows yet. -pub fn alive(id: &str) -> String { - format!("\x1b]99;i={id}:p=alive;\x1b\\") +/// The answer to OSC 99's `p=alive`, naming `id`: the ids of notifications still alive. +pub fn alive(id: &str, alive: &[&str]) -> String { + format!("\x1b]99;i={id}:p=alive;{}\x1b\\", alive.join(",")) +} + +/// What `c=1` hears of notification `id`: its close goes untracked, as gpui hears of none. +pub fn untracked(id: &str) -> String { + format!("\x1b]99;i={id}:p=close;untracked\x1b\\") +} + +/// What `a=report` hears when notification `id` is pressed. +pub fn activated(id: &str) -> String { + format!("\x1b]99;i={id};\x1b\\") } /// An OSC 99 payload's most bytes: plain, and base64; and its metadata's. @@ -335,13 +340,6 @@ impl Kitty { return Err(format!("a notification over {GATHERED} bytes")); } let notice = &mut gathering.notice; - if let Some(occasion) = get(b'o') { - notice.occasion = match occasion { - b"unfocused" => Occasion::Unfocused, - b"invisible" => Occasion::Invisible, - _ => Occasion::Always, - }; - } if let Some(actions) = get(b'a') { for action in actions.split(|&byte| byte == b',') { let (on, name) = match action.strip_prefix(b"-") { diff --git a/src/term/notify/tests.rs b/src/term/notify/tests.rs index 3628bba..ac4b351 100644 --- a/src/term/notify/tests.rs +++ b/src/term/notify/tests.rs @@ -87,14 +87,9 @@ fn osc_99_gathers_chunks_by_id_and_reads_its_options() { (Some("a"), "Hel", "World") ); assert_eq!( - ( - notice.occasion, - notice.focus, - notice.report, - notice.close, - notice.expire - ), - (Occasion::Unfocused, false, true, true, Some(5000)) + (notice.focus, notice.report, notice.close, notice.expire), + (false, true, true, Some(5000)), + "o passes by" ); let other = shown(kitty.read(b"i=b:p=body;!")); assert_eq!( @@ -224,9 +219,17 @@ fn osc_99_drops_what_breaks_its_rules() { #[test] fn the_answers_name_the_asker_s_id() { + let offered = if CLOSES { + "p=title,body,close,?,alive:w=1" + } else { + "p=title,body,?,alive" + }; assert_eq!( answer("q"), - "\x1b]99;i=q:p=?;p=title,body,?,alive:o=always\x1b\\" + format!("\x1b]99;i=q:p=?;a=focus,report:o=invisible:{offered}\x1b\\") ); - assert_eq!(alive("0"), "\x1b]99;i=0:p=alive;\x1b\\"); + assert_eq!(alive("0", &[]), "\x1b]99;i=0:p=alive;\x1b\\"); + assert_eq!(alive("z", &["a", "b"]), "\x1b]99;i=z:p=alive;a,b\x1b\\"); + assert_eq!(untracked("n1"), "\x1b]99;i=n1:p=close;untracked\x1b\\"); + assert_eq!(activated("0"), "\x1b]99;i=0;\x1b\\"); } diff --git a/src/term/reports.rs b/src/term/reports.rs new file mode 100644 index 0000000..7e949aa --- /dev/null +++ b/src/term/reports.rs @@ -0,0 +1,268 @@ +use std::time::{Duration, Instant}; + +use gpui::{Context, SharedString, SystemNotification}; + +use super::{ + notify::{self, Ask, CLOSES, Notice}, + status::{self, State, Status}, + view::{TerminalEvent, TerminalView}, +}; +use crate::{ + sessions::{line, plain}, + workspace::TITLE, +}; + +/// One notification a session every 3 seconds at most. +const GAP: Duration = Duration::from_secs(3); + +/// The notifications a session keeps for presses and answers; past them the oldest goes. +const POSTED: usize = 64; + +/// The system notifications a session posted and has not seen go. +#[derive(Default)] +pub struct Notices { + posted: Vec, + /// When it last posted. + last: Option, + /// Posts so far: they tell apart notifications without an id, and an expiry finds its own post. + count: u64, +} + +/// A notification posted. +#[derive(Clone, Debug, PartialEq)] +struct Posted { + tag: SharedString, + /// The OSC 99 id its program gave; none for the session's own and for OSC 9 and 777. + id: Option, + focus: bool, + report: bool, + count: u64, +} + +impl Notices { + /// Program `pid`'s turn to post at `now`, kept as posted; none within 3 seconds of its last. + fn turn(&mut self, pid: u32, asked: Option<&Notice>, now: Instant) -> Option { + if self.last.is_some_and(|last| now.duration_since(last) < GAP) { + return None; + } + self.last = Some(now); + self.count += 1; + let id = asked.and_then(|notice| notice.id.clone()); + // The session's own share a tag, so the last takes its place; an OSC 99 id shares its own. + let tag = match (asked, &id) { + (None, _) => format!("{pid}"), + (Some(_), Some(id)) => format!("{pid}:{id}"), + (Some(_), None) => format!("{pid}#{}", self.count), + }; + let posted = Posted { + tag: tag.into(), + id, + focus: asked.is_none_or(|notice| notice.focus), + report: asked.is_some_and(|notice| notice.report), + count: self.count, + }; + self.posted.retain(|old| old.tag != posted.tag); + if self.posted.len() == POSTED { + let gone = self.posted.remove(0); + log::debug!( + "pty {pid}: notification {} goes from the {POSTED} kept", + gone.tag + ); + } + self.posted.push(posted.clone()); + Some(posted) + } + + /// Takes notification `tag` out; with `count`, only that post of it. + fn take(&mut self, tag: &str, count: Option) -> Option { + let at = self.posted.iter().position(|posted| { + posted.tag == tag && count.is_none_or(|count| posted.count == count) + })?; + Some(self.posted.remove(at)) + } + + /// Whether a post after post `count` holds `tag`. + fn replaced(&self, tag: &str, count: u64) -> bool { + self.posted + .iter() + .any(|posted| posted.tag == tag && posted.count > count) + } + + /// The ids of the notifications still alive: posted, and not pressed, closed or expired. + fn alive(&self) -> Vec<&str> { + self.posted + .iter() + .filter_map(|posted| posted.id.as_deref()) + .collect() + } +} + +impl TerminalView { + /// Keeps a program's report, drops what it clears, and answers its query. + pub(super) fn status(&mut self, status: Status) { + match status { + Status::Report(id, record) => self.records.report(id, record), + Status::Clear(id) => self.records.clear(id.as_deref()), + Status::Query => self.pty.write(status::REPLY.as_bytes().to_vec()), + } + } + + /// Maps progress, answers the query and alive in order, and posts and closes notifications. + pub(super) fn ask(&mut self, ask: Ask, cx: &mut Context) { + match ask { + Ask::Progress(progress) => self.records.progress(progress), + Ask::Query(id) => self.pty.write(notify::answer(&id).into_bytes()), + Ask::Alive(id) => { + let alive = self.notices.alive(); + log::debug!("pty {}: notifications alive: {alive:?}", self.pty.pid); + self.pty.write(notify::alive(&id, &alive).into_bytes()); + } + Ask::Show(notice) => self.post(said(¬ice), Some(¬ice), cx), + Ask::Close(id) => self.close(&id, cx), + } + } + + /// Notes the state its row shows, and tells the owner of a change; true when it changed. + pub(super) fn restate(&mut self, cx: &mut Context) -> bool { + let changed = self.records.restate(); + if changed { + let state = self.records.shows(); + log::info!("terminal {}: its row shows {state:?}", self.pty.pid); + cx.emit(TerminalEvent::Shows(state)); + } + changed + } + + /// Posts the state its row shows when it turned to waiting, done or error. + pub(super) fn tell_state(&mut self, cx: &mut Context) { + let body = match self.records.shown() { + Some((id, record)) + if matches!(record.state, State::Blocked | State::Done | State::Error) => + { + line(&self.records, id, record) + } + _ => return, + }; + self.post(body, None, cx); + } + + /// A command the shell ran ended with `status`, which a notification tells. + pub(super) fn finished(&mut self, status: Option, cx: &mut Context) { + if self.ended != status { + let ended = status.map_or("with no status".into(), |code| format!("with {code}")); + log::info!("terminal {}: its last command ended {ended}", self.pty.pid); + } + self.ended = status; + self.post(finished(status), None, cx); + } + + /// 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) { + let pid = self.pty.pid; + if self.front && self.active { + log::debug!("terminal {pid}: in view, so it posts no {body:?}"); + return; + } + let Some(posted) = self.notices.turn(pid, asked, Instant::now()) else { + log::info!("terminal {pid}: posted within 3 s, so it posts no {body:?}"); + return; + }; + // Its title names the session, so no program speaks as another. + let title = plain(if self.title.is_empty() { + TITLE + } else { + &self.title + }); + log::info!("terminal {pid}: posts {}: {title:?}, {body:?}", posted.tag); + cx.show_system_notification(SystemNotification { + tag: posted.tag.clone(), + title: title.into(), + body: body.into(), + actions: Vec::new(), + }); + let Some(notice) = asked else { + return; + }; + // gpui hears of no close, so a program that asks hears that none is tracked. + if notice.close { + let id = posted.id.as_deref().unwrap_or("0"); + self.pty.write(notify::untracked(id).into_bytes()); + } + match notice.expire { + Some(ms) if CLOSES => self.expire(posted, ms, cx), + Some(_) => log::debug!( + "terminal {pid}: gpui closes none on Linux, so {} stays", + posted.tag + ), + None => {} + } + } + + /// Takes `posted` back once `ms` run out, though its session be gone, unless a later post holds its tag. + fn expire(&mut self, posted: Posted, ms: u32, cx: &mut Context) { + let (tag, count) = (posted.tag, posted.count); + cx.spawn(async move |view, cx| { + cx.background_executor() + .timer(Duration::from_millis(u64::from(ms))) + .await; + let held = view.update(cx, |view, cx| { + if view.notices.replaced(&tag, count) { + return; + } + if view.notices.take(&tag, Some(count)).is_some() { + log::info!("terminal {}: notification {tag} expires", view.pty.pid); + } + cx.dismiss_system_notification(&tag); + }); + // The system keeps what a closed session posted, so its expiry still takes it. + if held.is_err() { + log::info!("app: notification {tag} expires after its session"); + cx.update(|cx| cx.dismiss_system_notification(&tag)); + } + }) + .detach(); + } + + /// A program's close of notification `id`, where gpui can close one. + fn close(&mut self, id: &str, cx: &mut Context) { + let tag = format!("{}:{id}", self.pty.pid); + if !CLOSES { + log::debug!( + "terminal {}: gpui closes none on Linux, so {tag} stays", + self.pty.pid + ); + } else if self.notices.take(&tag, None).is_some() { + log::info!("terminal {}: closes notification {tag}", self.pty.pid); + cx.dismiss_system_notification(&tag); + } + } + + /// 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 { + let posted = self.notices.take(tag, None)?; + log::info!("terminal {}: notification {tag} pressed", self.pty.pid); + if posted.report { + let id = posted.id.as_deref().unwrap_or("0"); + self.pty.write(notify::activated(id).into_bytes()); + } + Some(posted.focus) + } +} + +/// What a command's end says: it finished, or the status it failed with. +fn finished(status: Option) -> String { + match status { + Some(code) if code != 0 => format!("Command exited with {code}"), + _ => "Command finished".into(), + } +} + +/// A program's notification as posted: its title, then its body, each line shown as text. +fn said(notice: &Notice) -> String { + let lines = notice.title.lines().chain(notice.body.lines()); + let shown: Vec = lines.map(plain).filter(|line| !line.is_empty()).collect(); + shown.join("\n") +} + +#[cfg(test)] +mod tests; diff --git a/src/term/reports/tests.rs b/src/term/reports/tests.rs new file mode 100644 index 0000000..da910d7 --- /dev/null +++ b/src/term/reports/tests.rs @@ -0,0 +1,83 @@ +use super::*; + +fn notice(id: Option<&str>, title: &str, body: &str) -> Notice { + Notice { + id: id.map(String::from), + title: title.into(), + body: body.into(), + focus: true, + report: false, + close: false, + expire: None, + } +} + +#[test] +fn a_session_posts_once_in_3_seconds_its_own_under_one_tag() { + let mut notices = Notices::default(); + let start = Instant::now(); + let first = notices.turn(7, None, start).expect("the first post"); + assert_eq!((&*first.tag, first.focus, first.report), ("7", true, false)); + assert_eq!(notices.turn(7, None, start + Duration::from_secs(2)), None); + let again = notices.turn(7, None, start + GAP).expect("past the gap"); + assert_eq!(&*again.tag, "7"); + assert_eq!(notices.posted.len(), 1, "the last takes its place"); +} + +#[test] +fn a_program_s_notifications_go_by_their_id_and_live_until_taken() { + let mut notices = Notices::default(); + let start = Instant::now(); + let mut asked = notice(Some("build"), "T", ""); + asked.report = true; + asked.focus = false; + let posted = notices.turn(7, Some(&asked), start).expect("a post"); + assert_eq!( + (&*posted.tag, posted.focus, posted.report), + ("7:build", false, true) + ); + let untagged = notices + .turn(7, Some(¬ice(None, "T", "")), start + GAP) + .expect("a post"); + assert!(untagged.tag.starts_with("7#"), "{}", untagged.tag); + assert_eq!(notices.alive(), ["build"]); + let update = notices + .turn(7, Some(&asked), start + GAP * 2) + .expect("an update"); + assert!( + notices.replaced(&posted.tag, posted.count), + "a later post holds the tag" + ); + assert!(!notices.replaced(&update.tag, update.count)); + assert_eq!( + notices.take("7:build", Some(posted.count)), + None, + "an expiry takes its own post only" + ); + assert_eq!(notices.take("7:build", Some(update.count)), Some(update)); + assert!(notices.alive().is_empty()); +} + +#[test] +fn a_session_keeps_its_last_64_notifications() { + let mut notices = Notices::default(); + let start = Instant::now(); + for at in 0..=POSTED as u32 { + let asked = notice(Some(format!("n{at}").as_str()), "T", ""); + notices + .turn(7, Some(&asked), start + GAP * at) + .expect("a post"); + } + assert_eq!(notices.posted.len(), POSTED); + assert!(!notices.alive().contains(&"n0"), "the oldest went"); + assert!(notices.alive().contains(&"n64")); +} + +#[test] +fn a_notification_says_its_text_as_text() { + let asked = notice(None, "Build\u{202e} done", "Line 1\n\nLine 2\u{200b}"); + assert_eq!(said(&asked), "Build done\nLine 1\nLine 2"); + assert_eq!(finished(Some(0)), "Command finished"); + assert_eq!(finished(None), "Command finished"); + assert_eq!(finished(Some(2)), "Command exited with 2"); +} diff --git a/src/term/status.rs b/src/term/status.rs index 507080b..815180c 100644 --- a/src/term/status.rs +++ b/src/term/status.rs @@ -300,8 +300,8 @@ impl Records { self.list.len() != before } - /// The record a row shows: blocked, else error, working, done or idle, the one reported last in its state. - pub fn shown(&self) -> Option<&Record> { + /// The record a row shows, with its id: blocked, else error, working, done or idle, the one reported last in its state. + pub fn shown(&self) -> Option<(&str, &Record)> { [ State::Blocked, State::Error, @@ -315,13 +315,13 @@ impl Records { .iter() .filter(|(_, record, _)| record.state == state) .max_by_key(|(.., at)| *at) - .map(|(_, record, _)| record) + .map(|(id, record, _)| (id.as_str(), record)) }) } /// Notes the state a row shows now; true when it changed since the last note. pub fn restate(&mut self) -> bool { - let now = self.shown().map(|record| record.state); + let now = self.shown().map(|(_, record)| record.state); let changed = now != self.shows; self.shows = now; changed diff --git a/src/term/status/tests.rs b/src/term/status/tests.rs index a2683be..ae1df69 100644 --- a/src/term/status/tests.rs +++ b/src/term/status/tests.rs @@ -303,7 +303,11 @@ fn osc_9_4_maps_to_the_root_record_until_a_program_reports() { fn a_row_shows_the_most_pressing_record_the_last_reported_in_its_state() { let mut records = Records::default(); assert_eq!(records.shown(), None); - let shown = |records: &Records| records.shown().map(|record| (record.state, record.kind)); + let shown = |records: &Records| { + records + .shown() + .map(|(_, record)| (record.state, record.kind)) + }; records.report("a".into(), record(State::Idle, None)); assert_eq!(shown(&records), Some((State::Idle, None))); records.report("b".into(), record(State::Done, None)); diff --git a/src/term/view.rs b/src/term/view.rs index 29f4c03..5af6f90 100644 --- a/src/term/view.rs +++ b/src/term/view.rs @@ -24,11 +24,11 @@ use super::{ input::{self, Find, NextPrompt, PreviousPrompt}, marks::Mark, mouse::MouseMode, - notify::{self, Ask}, paint::{self, Look}, pointer::Pointer, pty::{Events, Pty, PtyEvent, config, fit}, - status::{self, Records, State, Status}, + reports::Notices, + status::{Records, State}, zoom, }; use crate::{ @@ -51,10 +51,6 @@ pub enum TerminalEvent { Title(String), Folder(PathBuf), Bell, - /// A command the shell ran ended, with the status it gave. - Finished(Option), - /// A notification to show or close, OSC 9, 777 or 99. - Notice(Ask), /// The state its row shows changed, to this one. Shows(Option), Exited(ExitStatus), @@ -80,7 +76,15 @@ pub struct TerminalView { /// What programs say they are doing, OSC 7501. pub(super) records: Records, /// The status its shell's last command ended with; none before one, or when it gave none. - ended: Option, + pub(super) ended: Option, + /// The system notifications it posted. + pub(super) notices: Notices, + /// The program's title; empty while it names none. + pub(super) title: String, + /// It is the session shown. + pub(super) front: bool, + /// Its window is active. + pub(super) active: bool, cell: (u16, u16), /// Focused in an active window, as the program last heard. focused: bool, @@ -130,6 +134,10 @@ impl TerminalView { reports: 0, records: Records::default(), ended: None, + notices: Notices::default(), + title: String::new(), + front: true, + active: false, cell: (8, 16), focused: false, blinking: false, @@ -163,23 +171,9 @@ impl TerminalView { self.ended } - /// Notes the state its row shows, and tells the owner of each change. - pub(super) fn restate(&mut self, cx: &mut Context) { - if self.records.restate() { - let state = self.records.shows(); - log::info!("terminal {}: its row shows {state:?}", self.pty.pid); - cx.emit(TerminalEvent::Shows(state)); - } - } - - /// A command the shell ran ended with `status`. - fn finished(&mut self, status: Option, cx: &mut Context) { - if self.ended != status { - let ended = status.map_or("with no status".into(), |code| format!("with {code}")); - log::info!("terminal {}: its last command ended {ended}", self.pty.pid); - } - self.ended = status; - cx.emit(TerminalEvent::Finished(status)); + /// Whether it is the session shown: one shown in an active window posts no notification. + pub fn set_front(&mut self, front: bool) { + self.front = front; } fn pump(mut events: Events, cx: &mut Context) -> Task<()> { @@ -229,6 +223,7 @@ impl TerminalView { /// Tells a program that asked, by mode 1004, when the terminal gains or loses focus. fn refocus(&mut self, window: &mut Window, cx: &mut Context) { + self.active = window.is_window_active(); let focused = self.focus.is_focused(window) && window.is_window_active(); if focused != self.focused { self.focused = focused; @@ -254,6 +249,7 @@ impl TerminalView { let drawn = events .iter() .any(|event| matches!(event, PtyEvent::Term(Event::Wakeup))); + let mut restated = false; for event in events { match event { PtyEvent::Folder(folder) => { @@ -266,23 +262,27 @@ impl TerminalView { // A new prompt ends what was working, waiting or idle; done and error stay. PtyEvent::Mark(Mark::Prompt) => { self.records.prompt(); - self.restate(cx); + restated |= self.restate(cx); } PtyEvent::Mark(_) => {} PtyEvent::Status(status) => { self.status(status); - self.restate(cx); + restated |= self.restate(cx); } PtyEvent::Ask(ask) => { self.ask(ask, cx); - self.restate(cx); + restated |= self.restate(cx); } PtyEvent::Reset => { self.records.reset(); - self.restate(cx); + restated |= self.restate(cx); } } } + // A state a later change in the same output passed by posts nothing. + if restated { + self.tell_state(cx); + } // ponytail: a full search per output batch while find is open; search only new lines if it lags. if drawn && self.finder.is_some() { self.search(false, cx); @@ -290,25 +290,6 @@ impl TerminalView { cx.notify(); } - /// Keeps a program's report, drops what it clears, and answers its query. - fn status(&mut self, status: Status) { - match status { - Status::Report(id, record) => self.records.report(id, record), - Status::Clear(id) => self.records.clear(id.as_deref()), - Status::Query => self.pty.write(status::REPLY.as_bytes().to_vec()), - } - } - - /// Maps progress, answers queries, and hands notifications on. - fn ask(&mut self, ask: Ask, cx: &mut Context) { - match ask { - Ask::Progress(progress) => self.records.progress(progress), - Ask::Query(id) => self.pty.write(notify::answer(&id).into_bytes()), - Ask::Alive(id) => self.pty.write(notify::alive(&id).into_bytes()), - ask @ (Ask::Show(_) | Ask::Close(_)) => cx.emit(TerminalEvent::Notice(ask)), - } - } - fn term_event(&mut self, event: Event, cx: &mut Context) { match event { Event::Wakeup => self.shown = true, @@ -318,8 +299,14 @@ impl TerminalView { let settings = config(SettingsStore::get(cx)); self.pty.term.lock().set_options(settings); } - Event::Title(title) => cx.emit(TerminalEvent::Title(title)), - Event::ResetTitle => cx.emit(TerminalEvent::Title(String::new())), + Event::Title(title) => { + self.title.clone_from(&title); + cx.emit(TerminalEvent::Title(title)); + } + Event::ResetTitle => { + self.title.clear(); + cx.emit(TerminalEvent::Title(String::new())); + } Event::Bell => { log::debug!("terminal {}: a bell, kept silent", self.pty.pid); cx.emit(TerminalEvent::Bell); diff --git a/src/workspace.rs b/src/workspace.rs index cd929ac..fa7f895 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -192,11 +192,15 @@ impl Workspace { if index != self.active && let Some(hidden) = self.sessions.get(self.active) { - hidden.terminal.update(cx, |view, cx| view.let_go(cx)); + hidden.terminal.update(cx, |view, cx| { + view.let_go(cx); + view.set_front(false); + }); } let session = &mut self.sessions[index]; session.bell = false; session.waiting = false; + session.terminal.update(cx, |view, _| view.set_front(true)); window.focus(&session.terminal.focus_handle(cx), cx); let folder = session.folder.clone(); self.files.update(cx, |files, cx| files.show(&folder, cx)); diff --git a/src/workspace/events.rs b/src/workspace/events.rs index 1024d7f..409cdd0 100644 --- a/src/workspace/events.rs +++ b/src/workspace/events.rs @@ -37,7 +37,6 @@ impl Workspace { session.bell = true; } TerminalEvent::Bell => {} - TerminalEvent::Finished(_) => {} TerminalEvent::Shows(state) => { let now = session.terminal.read(cx).records().shows(); // A hidden session that starts waiting, and still waits, marks its row until it is shown. @@ -46,10 +45,6 @@ impl Workspace { session.waiting = true; } } - TerminalEvent::Notice(ask) => { - let pid = session.terminal.read(cx).pid(); - log::info!("app: session {pid} asks {ask:?}"); - } TerminalEvent::Exited(status) => { let pid = session.terminal.read(cx).pid(); log::info!("app: session {pid}'s program ended, {status}"); @@ -62,4 +57,25 @@ impl Workspace { } cx.notify(); } + + /// A press on notification `tag`: its session hears of it and shows, unless it asked not to; false when no session here posted it. + pub fn pressed(&mut self, tag: &str, window: &mut Window, cx: &mut Context) -> bool { + let found = self + .sessions + .iter() + .enumerate() + .find_map(|(index, session)| { + let focus = session.terminal.update(cx, |view, _| view.pressed(tag))?; + Some((index, focus)) + }); + let Some((index, focus)) = found else { + return false; + }; + if focus { + cx.activate(true); + window.activate_window(); + self.select(index, window, cx); + } + true + } } diff --git a/tasks/acceptance.md b/tasks/acceptance.md index 944d24f..e1bc0eb 100644 --- a/tasks/acceptance.md +++ b/tasks/acceptance.md @@ -10,7 +10,7 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp |---|---|---|---| | One window: sessions left, terminal middle, Git or Files right | T11 | T11 review; a real X11 window shows the terminal and the right column | Pass | | Every setting has a control in the settings window | T16–T18, T30, T33 | 17 of the 24 fields have a row; `show_sessions`, `show_panel` and `panel` are the main window's toggles and switch, `sessions_width` and `panel_width` its columns' borders, and `window_width` and `window_height` its size, which §6 keeps in the file | Pass | -| Silent: no sound, bell, notification, update check, telemetry, tip or onboarding | T10, T12 | a bell is logged and marks a hidden row; the code holds no network call or notification | Pass | +| Silent: no sound, bell, notification but a session's, update check, telemetry, tip or onboarding | T10, T12, T40 | a bell is logged and marks a hidden row; the code holds no network call, and its one notification is a session's, through gpui's, which sets no sound on macOS and a silent toast on Windows; on Linux the notification daemon decides | Pass | | Clean: no subtitle, tagline, logging option, status bar or badge without state | T11–T18 | reviews of the layout and settings tasks | Pass | | Built-in themes, each with a preview, Catppuccin among them; a theme colors terminal and chrome | T03, T17 | `themes::tests`; T17 review | Pass | | The background blurs on macOS, Windows, Wayland and X11 | T19–T21 | see §11 | Partial | @@ -58,6 +58,7 @@ 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 | 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; 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 |