diff --git a/README.md b/README.md index 1feb352..5a39a71 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,11 @@ in principle, but a terminal grid is small. - **Saved layouts**: whole tabs — panes, splits, ratios, per-pane directories and scripts — opened in one go, parameterised by `{{name}}`, authored by arranging a tab and saving it. See [Layouts](#layouts) +- **Pane status in the tab strip**, driven by OSC 9;4, so a tab can say whether + it is working, waiting on you, or finished while you were elsewhere. See + [Agent status](#agent-status) +- **Renaming a tab**: `Ctrl+Shift+R`, right-click or double-click a tab row. + A typed name pins the label; clearing it hands the label back to the panes ### Shortcuts @@ -289,6 +294,7 @@ in principle, but a terminal grid is small. | `Ctrl+Shift+W` | close the focused pane (closes the tab with its last one) | | `Ctrl+Shift+←/→/↑/↓` | move the focused pane within its view | | `Ctrl+Shift+V` | paste into a terminal (bracketed-paste aware, refuses unsafe pastes) | +| `Ctrl+Shift+R` | rename the current tab (empty name = follow the terminal) | | `Ctrl+PageUp/PageDown` | previous / next tab | | `Alt+1`..`Alt+8` | jump to tab N, `Alt+9` jumps to the last | @@ -300,6 +306,97 @@ competes with the content's own mouse handling. Both add-pane shortcuts are also buttons in every pane header, which is how you open a web view without remembering the chord. +## Agent status + +Playpen is mostly used to keep several Claude Code sessions side by side, and +the question a sidebar full of them has to answer is "which of these needs me?". +So a tab can carry a dot: + +| | | +|---|---| +| purple, pulsing | working | +| amber | waiting for you — a permission prompt, or a question | +| green | **finished while you were looking at another tab** | +| red | stopped on an error | +| none | idle | + +The green one is the point of the feature. A session that has gone back to idle +looks exactly like one that never ran, so a tab that finishes work while it is +not the visible one latches green and stays that way until you actually visit +it. Nothing else tells you a tab is worth going back to. + +Panes report this individually and the tab shows the most urgent of them, so a +four-pane tab still reduces to one dot. The pane headers carry their own dots to +say which pane inside it was the one asking. + +### How it gets there + +Programs report state with **OSC 9;4** — the ConEmu progress protocol, the same +one Windows Terminal and Ghostty use for taskbar progress — and their title with +OSC 0/2. Nothing about this is Claude-specific: a build that reports progress +lights the same dot. + +Claude Code has no idea about any of this, so a hook tells it: + +```sh +mise run install-claude-hooks # merges into ~/.claude/settings.json +mise run uninstall-claude-hooks +``` + +That installs `hooks/playpen-status.sh` into `~/.claude/hooks/` and wires it to +five events: `UserPromptSubmit` → working (and sets the tab title from your +prompt), `Notification` → waiting, `Stop` / `SessionStart` / `SessionEnd` → +idle. The red state is deliberately not wired to anything: the obvious +candidate, `PostToolUseFailure`, fires for tool errors Claude then goes on to +recover from, so a tab would turn red constantly during ordinary work. The +script still accepts `error` for anything of your own worth flagging that way. + +Existing hooks are left alone — every entry is tagged with a marker +comment, so re-running replaces Playpen's own entries rather than stacking +duplicates, and the previous file is kept at `settings.json.playpen-backup`. +Hooks are read at startup, so open a new session to pick them up. + +The hook writes to `/dev/tty`, not to a socket or a daemon. A hook runs as a +child of Claude Code, so its controlling terminal *is* the pty of the pane +Claude is running in — the bytes land in that pane and no other, with nothing to +configure and no way for two concurrent sessions to be mistaken for each other. +It writes there rather than to stdout because Claude Code parses hook stdout as +the hook's JSON result; an escape sequence written there would corrupt the hook +protocol instead of reaching the terminal. + +A tab's title follows your prompt for as long as Claude holds the foreground. +Once it exits, the shell's own prompt sets the title back, which is the right +answer — there is no task any more. A name you type yourself outranks both. + +### Inside a VM + +Claude in a microVM (`smolvm`, `krunvm`, anything libkrun-based) still works, +because the guest console passes these bytes through to the host pty unchanged: + +``` +$ # written to /dev/tty inside the guest, observed on the host pty: +b'\x1b]9;4;3\x07' b'\x1b]0;hello-from-guest\x07' b'\x1b]9;4;0\x07' +``` + +This is the reason for choosing escape sequences over a socket. A unix socket on +the host is not reachable from inside a guest, and matching a message to a pane +by cwd breaks as soon as guest paths stop corresponding to host ones. A console +you already have is the one channel guaranteed to cross that boundary. + +The guest has its own `$HOME` and so its own `~/.claude/settings.json`. Install +the hook inside the image rather than on the host — the script is dependency-free +POSIX `sh` and behaves identically on both sides: + +```sh +smolvm machine exec --name NAME -- mkdir -p /root/.claude/hooks +# copy hooks/playpen-status.sh to /root/.claude/hooks/ via a mount or a heredoc, +# then merge the same five events into the guest's settings.json. +``` + +`jq` is used for the title when present and a `sed` fallback covers a minimal +image that has no `jq`; if the title can't be read the state still reports and +the tab simply keeps the name it had. + ## Not implemented This is a proof of concept, and the following are deliberately absent: diff --git a/hooks/playpen-status.sh b/hooks/playpen-status.sh new file mode 100755 index 0000000..278a97a --- /dev/null +++ b/hooks/playpen-status.sh @@ -0,0 +1,77 @@ +#!/bin/sh +# Report what Claude Code is doing to whichever terminal it is running in. +# +# Called from Claude Code hooks with the state as the first argument: +# +# playpen-status.sh busy # started working +# playpen-status.sh input # blocked on the user +# playpen-status.sh idle # finished +# playpen-status.sh error # stopped on an error +# +# The state is written as OSC 9;4 — the ConEmu progress protocol — straight to +# /dev/tty, and on `busy` the prompt is written as an OSC 0 title alongside it. +# +# Why /dev/tty and not a socket. A hook runs as a child of Claude Code, so its +# controlling terminal is the pty of the pane Claude is running in. Writing +# there means the bytes arrive in that pane and no other, with nothing to +# configure and no way for two concurrent sessions to be confused for each +# other. It survives a VM boundary too: run Claude inside a microVM and the +# guest's console passes these through to the host pty unchanged, which a unix +# socket on the host could not do. +# +# Nothing here is Playpen-specific. OSC 9;4 is what Windows Terminal, ConEmu +# and Ghostty already use for taskbar progress, so these hooks light up those +# terminals too, and any terminal that ignores it is unharmed. + +set -u + +state=${1:-idle} + +# No controlling terminal — running headless, in CI, or under a harness that +# detached us. Nothing to report to, and a hook must never be the thing that +# breaks a session, so leave quietly. +[ -w /dev/tty ] || exit 0 + +case "$state" in + busy) code=3 ;; # indeterminate + input) code=4 ;; # paused + error) code=2 ;; # error + *) code=0 ;; # removed +esac + +# Every write goes to /dev/tty explicitly. stdout belongs to Claude Code, which +# parses it as the hook's JSON result; an escape sequence written there would +# corrupt the hook protocol rather than reach the terminal. +printf '\033]9;4;%s\007' "$code" > /dev/tty 2>/dev/null || true + +# Only a starting turn carries a task worth naming. The other states leave the +# title alone so the shell's own title comes back when the session ends. +[ "$state" = busy ] || exit 0 + +payload=$(cat 2>/dev/null) || exit 0 +[ -n "$payload" ] || exit 0 + +if command -v jq >/dev/null 2>&1; then + title=$(printf '%s' "$payload" | jq -r '.prompt // ""' 2>/dev/null) || title="" +else + # No jq — pull the field out directly. This gives up on a prompt whose first + # line contains an escaped quote, which is the common case handled badly + # rather than the rare case handled wrongly: a missed title costs nothing + # because the tab just keeps the name it already had. + title=$(printf '%s' "$payload" \ + | sed -n 's/.*"prompt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + + # Still JSON at this point, so a newline is the two characters \ and n + # rather than an actual line break. `head` below would not split on it and + # the whole prompt would arrive as one long title with \n sitting in it. + title=${title%%\\n*} +fi + +# First line only, and no control characters: an OSC string ends at the first +# BEL or ESC, so anything of that sort in a prompt would truncate the sequence +# and leave the rest to be printed as garbage in the pane. +title=$(printf '%s' "$title" | head -n 1 | tr -d '[:cntrl:]' | cut -c1-72) + +[ -n "$title" ] && printf '\033]0;%s\007' "$title" > /dev/tty 2>/dev/null + +exit 0 diff --git a/mise.toml b/mise.toml index 207d308..2417cc2 100644 --- a/mise.toml +++ b/mise.toml @@ -22,6 +22,101 @@ description = "Screenshot playpen in a throwaway headless compositor" depends = ["build-debug"] run = "nix develop --command ./shot.sh" +[tasks.install-claude-hooks] +description = "Teach Claude Code to report its state to the tab it runs in" +run = ''' +#!/usr/bin/env bash +set -euo pipefail + +command -v jq >/dev/null || { echo "install-claude-hooks needs jq" >&2; exit 1; } + +claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +settings="$claude/settings.json" +script="$claude/hooks/playpen-status.sh" + +mkdir -p "$claude/hooks" +install -m755 hooks/playpen-status.sh "$script" + +# Copied rather than referenced in place: hooks configured to run out of a +# checkout break the day the checkout moves, and they break silently, in +# every Claude session at once. +[ -f "$settings" ] || echo '{}' > "$settings" + +# Every entry carries a marker comment. It is what makes re-running this +# replace the previous install instead of stacking a second copy, and it is +# what `uninstall-claude-hooks` matches on. The shell ignores it. +merged=$(jq --arg script "$script" ' + def marker: "# playpen-status-hook"; + + # Drop what a previous run added, leaving every other tools hooks alone. + def clean: + map(.hooks |= map(select((.command // "") | contains(marker) | not))) + | map(select((.hooks | length) > 0)); + + def entry($state; $async): + { hooks: [ + { type: "command", + command: ($script + " " + $state + " " + marker), + timeout: 5 } + + (if $async then { async: true } else {} end) + ] }; + + .hooks //= {} + # UserPromptSubmit is the only one left synchronous: it reads the prompt off + # stdin to build the tab title. The rest are a single printf and need not + # hold the turn up at all. + | .hooks.UserPromptSubmit = ((.hooks.UserPromptSubmit // []) | clean) + [entry("busy"; false)] + | .hooks.Notification = ((.hooks.Notification // []) | clean) + [entry("input"; true)] + | .hooks.Stop = ((.hooks.Stop // []) | clean) + [entry("idle"; true)] + | .hooks.SessionStart = ((.hooks.SessionStart // []) | clean) + [entry("idle"; true)] + | .hooks.SessionEnd = ((.hooks.SessionEnd // []) | clean) + [entry("idle"; true)] +' "$settings") + +# Keep a copy of what was there, and swap the new file in whole. Claude reads +# this file constantly; a half-written one is a broken session. +cp "$settings" "$settings.playpen-backup" +printf '%s\n' "$merged" > "$settings.tmp" +mv "$settings.tmp" "$settings" + +echo "installed $script" +echo "hooked into $settings (previous version kept at $settings.playpen-backup)" +echo +echo "Open a new Claude session to pick them up — hooks are read at startup." +''' + +[tasks.uninstall-claude-hooks] +description = "Remove the Claude Code hooks, leaving any other tools' alone" +run = ''' +#!/usr/bin/env bash +set -euo pipefail + +command -v jq >/dev/null || { echo "uninstall-claude-hooks needs jq" >&2; exit 1; } + +claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +settings="$claude/settings.json" +[ -f "$settings" ] || { echo "no $settings; nothing to do"; exit 0; } + +stripped=$(jq ' + def marker: "# playpen-status-hook"; + def clean: + map(.hooks |= map(select((.command // "") | contains(marker) | not))) + | map(select((.hooks | length) > 0)); + + .hooks //= {} + | reduce ("UserPromptSubmit","Notification","Stop","SessionStart","SessionEnd") as $ev + (.; .hooks[$ev] = ((.hooks[$ev] // []) | clean)) + # An event we emptied was ours alone; drop the key rather than leave "Stop": [] + | .hooks |= with_entries(select((.value | type) != "array" or (.value | length) > 0)) +' "$settings") + +cp "$settings" "$settings.playpen-backup" +printf '%s\n' "$stripped" > "$settings.tmp" +mv "$settings.tmp" "$settings" + +rm -f "$claude/hooks/playpen-status.sh" +echo "removed the hooks from $settings (previous version kept at $settings.playpen-backup)" +''' + [tasks.install] description = "Install playpen into ~/.local with a desktop entry and icon" depends = ["build"] diff --git a/src/Pane.zig b/src/Pane.zig index 3965fee..72d2362 100644 --- a/src/Pane.zig +++ b/src/Pane.zig @@ -69,12 +69,22 @@ pub const Spec = union(Kind) { } }; +/// What the content of a pane is currently doing. Only a terminal ever +/// reports this — a web pane is always `.idle` — but it lives here rather +/// than on Terminal so the view can aggregate across panes without caring +/// which kind each one is. +pub const Status = Terminal.Status; + /// What a content kind reports back to its pane. Shared by both kinds so the /// pane can wire either one up with the same handlers. +/// +/// A web pane simply never calls `on_status`; it has no equivalent of a +/// long-running job to report. pub const Callbacks = struct { on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void, on_exit: *const fn (ctx: ?*anyopaque) void, on_focus: *const fn (ctx: ?*anyopaque) void, + on_status: *const fn (ctx: ?*anyopaque, status: Status) void, ctx: ?*anyopaque, }; @@ -106,6 +116,53 @@ pub const Content = union(Kind) { const all_sides = [_]Side{ .left, .right, .top, .bottom }; +// ------------------------------------------------------------------------- +// Status dots +// +// Both the pane header and the sidebar row show the same small filled circle, +// recoloured by CSS. Symbolic icons take their colour from the `color` +// property, so the state is carried entirely by which class is applied and +// there is no second icon to keep in sync. + +/// The dot itself. A filled circle at whatever size the context asks for. +pub const status_icon = "media-record-symbolic"; + +/// Every class a dot can carry. Listed so that applying one can clear the +/// others without the caller having to remember what it set last. +pub const status_classes = [_][:0]const u8{ + "playpen-status-busy", + "playpen-status-done", + "playpen-status-input", + "playpen-status-failed", +}; + +/// Point a dot at one of `status_classes`, or pass null to hide it. +/// +/// Hiding rather than clearing the colour matters: an idle pane should show +/// nothing at all, not a dot in the background colour that still takes up +/// space and still catches the eye when the row is hovered. +pub fn setDot(dot: *gtk.Image, class: ?[:0]const u8) void { + const w = dot.as(gtk.Widget); + for (status_classes) |c| w.removeCssClass(c); + + if (class) |c| { + w.addCssClass(c); + w.setVisible(1); + } else { + w.setVisible(0); + } +} + +/// The class a pane-level status shows as, or null for idle. +pub fn statusClass(status: Status) ?[:0]const u8 { + return switch (status) { + .idle => null, + .busy => "playpen-status-busy", + .needs_input => "playpen-status-input", + .failed => "playpen-status-failed", + }; +} + /// How close to the view's outer border a drop must be, in pixels, to place /// the pane against the whole layout instead of against the pane under the /// pointer. This is what lets a pane dropped along the bottom span the full @@ -125,9 +182,18 @@ box: *gtk.Box, header: *gtk.Box, label: *gtk.Label, +/// Status dot in the header, hidden while the pane is idle. This is the +/// per-pane counterpart of the dot on the sidebar row: with several panes in +/// a tab, the row tells you the tab needs attention and this tells you which +/// pane inside it does. +dot: *gtk.Image, + /// Latest title reported by the content, kept NUL-terminated for GTK. title: [128:0]u8 = @splat(0), +/// Latest status reported by the content. +status: Status = .idle, + pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane { const self = try alloc.create(Pane); errdefer alloc.destroy(self); @@ -142,6 +208,7 @@ pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane { .box = gtk.Box.new(.vertical, 0), .header = gtk.Box.new(.horizontal, 4), .label = gtk.Label.new(""), + .dot = gtk.Image.newFromIconName(status_icon), }; setTitle(self, kind.initialTitle()); @@ -151,6 +218,7 @@ pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane { .on_title = &onContentTitle, .on_exit = &onContentExit, .on_focus = &onContentFocus, + .on_status = &onContentStatus, .ctx = self, }; self.content = switch (spec) { @@ -234,6 +302,12 @@ fn buildHeader(self: *Pane) void { icon.as(gtk.Widget).addCssClass("playpen-pane-icon"); header.append(icon.as(gtk.Widget)); + // Sits between the kind icon and the title so a busy pane reads as + // "terminal, working, " left to right. + self.dot.as(gtk.Widget).addCssClass("playpen-status-dot"); + setDot(self.dot, statusClass(self.status)); + header.append(self.dot.as(gtk.Widget)); + self.label.setXalign(0); self.label.setEllipsize(.end); self.label.as(gtk.Widget).setHexpand(1); @@ -406,6 +480,15 @@ fn onContentTitle(ctx: ?*anyopaque, title: []const u8) void { self.view.paneTitleChanged(self); } +fn onContentStatus(ctx: ?*anyopaque, status: Status) void { + const self: *Pane = @ptrCast(@alignCast(ctx.?)); + if (self.status == status) return; + + self.status = status; + setDot(self.dot, statusClass(status)); + self.view.paneStatusChanged(self); +} + /// The shell exited, or a page called window.close(). fn onContentExit(ctx: ?*anyopaque) void { const self: *Pane = @ptrCast(@alignCast(ctx.?)); diff --git a/src/Session.zig b/src/Session.zig index 4b32299..66b5ef4 100644 --- a/src/Session.zig +++ b/src/Session.zig @@ -37,6 +37,10 @@ watch: c_uint = 0, /// True once the child process has exited and the PTY hung up. exited: bool = false, +/// Latest state reported by the child. Kept so a repeated report of the +/// state we are already in doesn't wake the UI for nothing. +status: Status = .idle, + /// A script to run once the shell is ready, from the layout this pane came /// from. Held rather than written at spawn time — see `flushStartupCommand`. startup_command: ?[]u8 = null, @@ -50,12 +54,41 @@ on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void, /// Called when the child process exits. on_exit: *const fn (ctx: ?*anyopaque) void, +/// Called when the running program reports a change of state (OSC 9;4). +on_status: *const fn (ctx: ?*anyopaque, status: Status) void, + ctx: ?*anyopaque = null, +/// What the program in this session is currently doing, as reported by +/// OSC 9;4 — the ConEmu progress protocol. +/// +/// Nothing here is Claude-specific. The protocol is a general "what is this +/// program up to" channel that any long-running command can drive; Claude +/// Code just happens to be the one we ship hooks for. That is deliberate: a +/// build that reports progress lights the same indicator as an agent that +/// is thinking, because from across the room they mean the same thing — +/// this pane is working, leave it alone for now. +pub const Status = enum { + /// Nothing running, or whatever was running has finished. + idle, + + /// Working. The protocol distinguishes a percentage from an + /// indeterminate spinner; a tab strip is too small for a progress bar, + /// so both collapse to the same thing here. + busy, + + /// Blocked on the user — a permission prompt, or a question. + needs_input, + + /// Stopped on an error. + failed, +}; + pub const Callbacks = struct { on_damage: *const fn (ctx: ?*anyopaque) void, on_title: *const fn (ctx: ?*anyopaque, title: []const u8) void, on_exit: *const fn (ctx: ?*anyopaque) void, + on_status: *const fn (ctx: ?*anyopaque, status: Status) void, ctx: ?*anyopaque, }; @@ -92,6 +125,7 @@ pub fn create( .on_damage = cbs.on_damage, .on_title = cbs.on_title, .on_exit = cbs.on_exit, + .on_status = cbs.on_status, .ctx = cbs.ctx, }; errdefer self.term.deinit(alloc); @@ -106,6 +140,7 @@ pub fn create( var effects = vt.TerminalStream.Handler.Effects.readonly; effects.write_pty = &effectWritePty; effects.title_changed = &effectTitleChanged; + effects.progress_report = &effectProgressReport; self.stream.handler.effects = effects; const shell = try defaultShell(alloc); @@ -233,6 +268,30 @@ fn effectTitleChanged(handler: *vt.TerminalStream.Handler) void { self.on_title(self.ctx, self.term.title.items); } +/// Effect callback: OSC 9;4 reported what the child is doing. +/// +/// The five states the ConEmu protocol defines collapse onto the four a pane +/// can show. `set` carries a percentage as well, which is dropped: a sidebar +/// row has no room for a progress bar, and the question this feature answers +/// is "does this need me?", not "how far along is it?". +fn effectProgressReport( + handler: *vt.TerminalStream.Handler, + report: vt.osc.Command.ProgressReport, +) void { + const self = fromHandler(handler); + + const status: Status = switch (report.state) { + .remove => .idle, + .set, .indeterminate => .busy, + .pause => .needs_input, + .@"error" => .failed, + }; + + if (self.status == status) return; + self.status = status; + self.on_status(self.ctx, status); +} + /// Recover the owning Session from a stream callback. The handler holds a /// pointer to our embedded `term` field, so we can walk back from it. fn fromHandler(handler: *vt.TerminalStream.Handler) *Session { diff --git a/src/Terminal.zig b/src/Terminal.zig index fb89096..03f160a 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -52,11 +52,18 @@ on_exit: *const fn (ctx: ?*anyopaque) void, /// Called when this terminal takes keyboard focus. on_focus: *const fn (ctx: ?*anyopaque) void, +/// Called when the child reports a change of state, so the owner can show +/// it in the tab strip. +on_status: *const fn (ctx: ?*anyopaque, status: Status) void, + ctx: ?*anyopaque = null, /// What a layout can specify for a terminal pane. pub const Options = Session.Options; +/// What the child is doing. Reported by the session; passed straight through. +pub const Status = Session.Status; + pub fn create( alloc: std.mem.Allocator, opts: Options, @@ -76,6 +83,7 @@ pub fn create( .on_title = cbs.on_title, .on_exit = cbs.on_exit, .on_focus = cbs.on_focus, + .on_status = cbs.on_status, .ctx = cbs.ctx, }; @@ -87,6 +95,7 @@ pub fn create( .on_damage = &onDamage, .on_title = &onSessionTitle, .on_exit = &onSessionExit, + .on_status = &onSessionStatus, .ctx = self, }); errdefer self.session.destroy(); @@ -189,6 +198,11 @@ fn onSessionTitle(ctx: ?*anyopaque, title: []const u8) void { self.on_title(self.ctx, title); } +fn onSessionStatus(ctx: ?*anyopaque, status: Status) void { + const self: *Terminal = @ptrCast(@alignCast(ctx.?)); + self.on_status(self.ctx, status); +} + fn onNotifyHasFocus( _: *gtk.DrawingArea, _: *gobject.ParamSpec, diff --git a/src/View.zig b/src/View.zig index 2c8e470..c3a1300 100644 --- a/src/View.zig +++ b/src/View.zig @@ -20,6 +20,7 @@ const View = @This(); pub const Side = Layout.Side; pub const Kind = Pane.Kind; +pub const Status = Pane.Status; /// Where a dragged pane would land. pub const Target = struct { @@ -72,11 +73,13 @@ closing: bool = false, on_empty: *const fn (ctx: ?*anyopaque) void, on_title: *const fn (ctx: ?*anyopaque) void, +on_status: *const fn (ctx: ?*anyopaque) void, ctx: ?*anyopaque = null, pub const Callbacks = struct { on_empty: *const fn (ctx: ?*anyopaque) void, on_title: *const fn (ctx: ?*anyopaque) void, + on_status: *const fn (ctx: ?*anyopaque) void, ctx: ?*anyopaque, }; @@ -90,6 +93,7 @@ pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View { .layout = .{ .alloc = alloc }, .on_empty = cbs.on_empty, .on_title = cbs.on_title, + .on_status = cbs.on_status, .ctx = cbs.ctx, }; @@ -215,6 +219,10 @@ pub fn closePane(self: *View, pane: *Pane) void { self.setFocused(self.panes.items[next]); self.panes.items[next].grabFocus(); self.on_title(self.ctx); + + // Closing a busy pane changes what the tab as a whole is reporting, so + // the row has to be recomputed even though no pane changed its own state. + self.on_status(self.ctx); } pub fn setFocused(self: *View, pane: *Pane) void { @@ -229,6 +237,34 @@ pub fn paneTitleChanged(self: *View, pane: *Pane) void { if (self.focused == pane or self.panes.items.len == 1) self.on_title(self.ctx); } +pub fn paneStatusChanged(self: *View, pane: *Pane) void { + _ = pane; + if (self.closing) return; + self.on_status(self.ctx); +} + +/// The view's status: the most urgent thing any of its panes is reporting. +/// +/// A tab is one row however many panes it holds, so the row has to say +/// something about all of them at once. Urgency wins rather than, say, the +/// focused pane's status, because the whole point of the indicator is to be +/// read from another tab — a pane blocked on a permission prompt matters +/// even if it isn't the one you left focused. +pub fn status(self: *View) Status { + var worst: Status = .idle; + for (self.panes.items) |pane| { + worst = switch (pane.status) { + // Nothing outranks a pane waiting on the user, so this can + // return the moment one is found. + .needs_input => return .needs_input, + .failed => .failed, + .busy => if (worst == .failed) worst else .busy, + .idle => worst, + }; + } + return worst; +} + // ------------------------------------------------------------------------- // Layouts diff --git a/src/Window.zig b/src/Window.zig index bc43047..3f0f699 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -16,6 +16,7 @@ const vt = @import("ghostty-vt"); const Layouts = @import("Layouts.zig"); const OpenLayoutDialog = @import("OpenLayoutDialog.zig"); +const Pane = @import("Pane.zig"); const SaveLayoutDialog = @import("SaveLayoutDialog.zig"); const Terminal = @import("Terminal.zig"); const View = @import("View.zig"); @@ -57,6 +58,44 @@ layout_popover: *gtk.Popover, /// they belong to are on screen. layout_rows: std.ArrayListUnmanaged(*LayoutRow) = .empty, +/// What a tab's sidebar row is signalling. The four pane-level states, plus +/// one the panes can't know about on their own. +/// +/// `done` is the whole reason this is a separate enum rather than just +/// `View.Status`. A pane that has gone back to idle is indistinguishable from +/// one that never ran, and "it finished" is exactly the thing worth knowing +/// when you're deciding which tab to go back to. So a tab that finishes work +/// while you are looking somewhere else latches into `done` and stays there +/// until you actually visit it. +const Attention = enum { + none, + busy, + done, + needs_input, + failed, + + /// The dot's CSS class, or null when the row should show no dot at all. + fn class(self: Attention) ?[:0]const u8 { + return switch (self) { + .none => null, + .busy => "playpen-status-busy", + .done => "playpen-status-done", + .needs_input => "playpen-status-input", + .failed => "playpen-status-failed", + }; + } + + fn tooltip(self: Attention) [:0]const u8 { + return switch (self) { + .none => "", + .busy => "Working", + .done => "Finished while you were away", + .needs_input => "Waiting for you", + .failed => "Stopped on an error", + }; + } +}; + /// A single tab: a view of one or more panes, plus the sidebar row that /// selects it. const Tab = struct { @@ -69,12 +108,44 @@ const Tab = struct { /// the sidebar without reading the title. icon: *gtk.Image, + /// Status dot, hidden unless the tab has something to report. + dot: *gtk.Image, + + /// A name the user typed, which wins over whatever the panes report. + /// Null means the label tracks the content, which is the default. + custom_name: ?[]u8 = null, + + /// Popover holding the rename entry, parented to this tab's row. + rename_popover: *gtk.Popover, + rename_entry: *gtk.Entry, + + /// What the panes were reporting last time we looked, so a busy → idle + /// transition can be told apart from an idle tab that never ran. + last_status: View.Status = .idle, + + /// Set when work finished while this tab was not the visible one, and + /// cleared when it is next selected. + done_unseen: bool = false, + name: [16]u8, name_len: usize, fn pageName(self: *const Tab) [:0]const u8 { return self.name[0..self.name_len :0]; } + + /// What the row should be showing right now. + /// + /// The latch outranks `busy` deliberately: if one pane is still working + /// but another has already finished unseen, the finished one is the news. + fn attention(self: *const Tab) Attention { + return switch (self.view.status()) { + .needs_input => .needs_input, + .failed => .failed, + .busy => if (self.done_unseen) .done else .busy, + .idle => if (self.done_unseen) .done else .none, + }; + } }; pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window { @@ -226,6 +297,7 @@ fn newTabEmpty(self: *Window) !*Tab { const view = try View.create(self.alloc, .{ .on_empty = &onViewEmpty, .on_title = &onViewTitle, + .on_status = &onViewStatus, .ctx = tab, }); errdefer view.destroy(); @@ -239,6 +311,9 @@ fn newTabEmpty(self: *Window) !*Tab { .row = gtk.ListBoxRow.new(), .label = gtk.Label.new("shell"), .icon = gtk.Image.newFromIconName("utilities-terminal-symbolic"), + .dot = gtk.Image.newFromIconName(Pane.status_icon), + .rename_popover = gtk.Popover.new(), + .rename_entry = gtk.Entry.new(), .name = undefined, .name_len = 0, }; @@ -256,6 +331,12 @@ fn newTabEmpty(self: *Window) !*Tab { tab.label.as(gtk.Widget).setHexpand(1); row_box.append(tab.label.as(gtk.Widget)); + // After the label rather than before it, so the dots down the sidebar + // line up in a column instead of being pushed around by title length. + tab.dot.as(gtk.Widget).addCssClass("playpen-status-dot"); + Pane.setDot(tab.dot, null); + row_box.append(tab.dot.as(gtk.Widget)); + const close = gtk.Button.newFromIconName("window-close-symbolic"); close.as(gtk.Widget).addCssClass("flat"); close.as(gtk.Widget).addCssClass("playpen-close"); @@ -263,6 +344,7 @@ fn newTabEmpty(self: *Window) !*Tab { row_box.append(close.as(gtk.Widget)); tab.row.setChild(row_box.as(gtk.Widget)); + self.buildRename(tab, row_box); self.list.append(tab.row.as(gtk.Widget)); _ = self.stack.addNamed(view.widget(), tab.pageName()); @@ -272,6 +354,128 @@ fn newTabEmpty(self: *Window) !*Tab { return tab; } +// ------------------------------------------------------------------------- +// Renaming +// +// A tab's label normally follows its panes, which is right up until you have +// four of them all reporting some variation on the same thing. A typed name +// pins the row to whatever you actually call that tab, and clearing it hands +// the label back to the panes. +// +// The entry lives in a popover anchored to the row rather than in a dialog: +// renaming a tab is a one-field edit, and a modal window for it would be a +// heavier interruption than the thing being edited. + +/// Attach the rename popover and the gestures that open it. +fn buildRename(self: *Window, tab: *Tab, anchor: *gtk.Box) void { + _ = self; + + const box = gtk.Box.new(.vertical, 6); + box.as(gtk.Widget).addCssClass("playpen-rename"); + + const hint = gtk.Label.new("Tab name — empty to follow the terminal"); + hint.setXalign(0); + hint.as(gtk.Widget).addCssClass("playpen-dialog-hint"); + box.append(hint.as(gtk.Widget)); + + tab.rename_entry.as(gtk.Widget).setHexpand(1); + _ = gtk.Entry.signals.activate.connect( + tab.rename_entry, + *Tab, + &onRenameActivate, + tab, + .{}, + ); + box.append(tab.rename_entry.as(gtk.Widget)); + + tab.rename_popover.setChild(box.as(gtk.Widget)); + tab.rename_popover.as(gtk.Widget).addCssClass("playpen-rename-popover"); + tab.rename_popover.as(gtk.Widget).setParent(anchor.as(gtk.Widget)); + + // Right-click is the discoverable route; double-click matches how tab + // strips elsewhere behave. Both land in the same place. + const secondary = gtk.GestureClick.new(); + secondary.as(gtk.GestureSingle).setButton(3); + _ = gtk.GestureClick.signals.pressed.connect( + secondary, + *Tab, + &onRowSecondary, + tab, + .{}, + ); + anchor.as(gtk.Widget).addController(secondary.as(gtk.EventController)); + + const double = gtk.GestureClick.new(); + double.as(gtk.GestureSingle).setButton(1); + _ = gtk.GestureClick.signals.pressed.connect( + double, + *Tab, + &onRowDoubleClick, + tab, + .{}, + ); + anchor.as(gtk.Widget).addController(double.as(gtk.EventController)); +} + +/// Open the rename entry, prefilled with the name the tab is showing now so +/// that editing it is a tweak rather than a retype. +fn beginRename(self: *Window, tab: *Tab) void { + var buf: [192]u8 = undefined; + const current = self.tabName(tab, &buf); + + var z: [192:0]u8 = undefined; + const n = @min(current.len, z.len - 1); + @memcpy(z[0..n], current[0..n]); + z[n] = 0; + + tab.rename_entry.as(gtk.Editable).setText(z[0..n :0]); + tab.rename_entry.as(gtk.Editable).selectRegion(0, -1); + tab.rename_popover.popup(); + _ = tab.rename_entry.as(gtk.Widget).grabFocus(); +} + +/// Commit whatever is in the entry. Empty clears the custom name, which is +/// how you get back to the automatic label without a separate "reset" action. +fn onRenameActivate(_: *gtk.Entry, tab: *Tab) callconv(.c) void { + const self = tab.window; + const typed = std.mem.span(tab.rename_entry.as(gtk.Editable).getText()); + const trimmed = std.mem.trim(u8, typed, " \t"); + + if (tab.custom_name) |old| self.alloc.free(old); + tab.custom_name = null; + + if (trimmed.len > 0) { + tab.custom_name = self.alloc.dupe(u8, trimmed) catch |err| blk: { + std.log.err("failed to rename tab: {s}", .{@errorName(err)}); + break :blk null; + }; + } + + tab.rename_popover.popdown(); + self.refreshLabel(tab); +} + +fn onRowSecondary( + _: *gtk.GestureClick, + _: c_int, + _: f64, + _: f64, + tab: *Tab, +) callconv(.c) void { + tab.window.beginRename(tab); +} + +fn onRowDoubleClick( + _: *gtk.GestureClick, + n_press: c_int, + _: f64, + _: f64, + tab: *Tab, +) callconv(.c) void { + if (n_press < 2) return; + tab.window.beginRename(tab); +} + // ------------------------------------------------------------------------- // Layouts @@ -505,6 +709,13 @@ fn select(self: *Window, tab: *Tab) void { self.stack.setVisibleChildName(tab.pageName()); self.list.selectRow(tab.row); tab.view.focus(); + + // Visiting the tab is what "seeing it" means, so this is where the + // finished-while-you-were-away flag is spent. + if (tab.done_unseen) { + tab.done_unseen = false; + self.refreshStatus(tab); + } } fn indexOf(self: *Window, tab: *Tab) ?usize { @@ -518,10 +729,16 @@ fn closeTab(self: *Window, tab: *Tab) void { const index = self.indexOf(tab) orelse return; self.stack.remove(tab.view.widget()); + + // A popover attached with setParent is not an ordinary child, so it has + // to be detached by hand; letting the row take it down warns instead. + tab.rename_popover.as(gtk.Widget).unparent(); + self.list.remove(tab.row.as(gtk.Widget)); _ = self.tabs.orderedRemove(index); tab.view.destroy(); + if (tab.custom_name) |name| self.alloc.free(name); self.alloc.destroy(tab); if (self.tabs.items.len == 0) { @@ -559,14 +776,26 @@ fn onRowSelected(_: *gtk.ListBox, row: ?*gtk.ListBoxRow, self: *Window) callconv } } -/// Refresh a sidebar row from its view's current state. -fn refreshLabel(self: *Window, tab: *Tab) void { +/// The text a tab's row should show: the name the user typed, or failing +/// that whatever the panes are reporting. +fn tabName(self: *Window, tab: *Tab, buf: []u8) []const u8 { _ = self; + if (tab.custom_name) |name| { + const n = @min(name.len, buf.len); + @memcpy(buf[0..n], name[0..n]); + return buf[0..n]; + } + + return tab.view.label(buf); +} + +/// Refresh a sidebar row from its view's current state. +fn refreshLabel(self: *Window, tab: *Tab) void { // GTK needs a NUL-terminated string, and titles come from the terminal so // they can be any length; clamp to what a sidebar row can show. var scratch: [192]u8 = undefined; - const text = tab.view.label(scratch[0 .. scratch.len - 1]); + const text = self.tabName(tab, scratch[0 .. scratch.len - 1]); var buf: [192]u8 = undefined; @memcpy(buf[0..text.len], text); @@ -575,6 +804,18 @@ fn refreshLabel(self: *Window, tab: *Tab) void { tab.label.setText(buf[0..text.len :0]); tab.label.as(gtk.Widget).setTooltipText(buf[0..text.len :0]); tab.icon.setFromIconName(tab.view.iconName()); + + self.refreshStatus(tab); +} + +/// Refresh just the status dot. Split out from `refreshLabel` because a +/// pane changing state doesn't change any of the text. +fn refreshStatus(self: *Window, tab: *Tab) void { + _ = self; + + const attention = tab.attention(); + Pane.setDot(tab.dot, attention.class()); + tab.dot.as(gtk.Widget).setTooltipText(attention.tooltip()); } fn onViewTitle(ctx: ?*anyopaque) void { @@ -582,6 +823,29 @@ fn onViewTitle(ctx: ?*anyopaque) void { tab.window.refreshLabel(tab); } +/// A pane in this tab changed state. +/// +/// The latch is set here rather than anywhere else because this is the only +/// place that sees the transition: by the time the user looks at the sidebar, +/// a tab that finished and a tab that never started look identical. +fn onViewStatus(ctx: ?*anyopaque) void { + const tab: *Tab = @ptrCast(@alignCast(ctx.?)); + const self = tab.window; + + const previous = tab.last_status; + const current = tab.view.status(); + tab.last_status = current; + + // Work that finishes in the tab you are already looking at needs no + // flag — you watched it happen. + const visible = self.activeTab() == tab; + if (!visible and current == .idle and previous != .idle) { + tab.done_unseen = true; + } + + self.refreshStatus(tab); +} + /// The view lost its last pane, so the tab goes with it. fn onViewEmpty(ctx: ?*anyopaque) void { const tab: *Tab = @ptrCast(@alignCast(ctx.?)); @@ -597,6 +861,7 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void { // Dropping them here reaps the children rather than orphaning them. for (self.tabs.items) |tab| { tab.view.destroy(); + if (tab.custom_name) |name| self.alloc.free(name); self.alloc.destroy(tab); } self.tabs.deinit(self.alloc); @@ -684,6 +949,10 @@ fn onShortcut( self.addPane(.web); return 1; }, + gdk.KEY_R, gdk.KEY_r => { + if (self.activeTab()) |tab| self.beginRename(tab); + return 1; + }, gdk.KEY_V, gdk.KEY_v => { // Only a terminal needs us to encode a paste for it. A web // pane has its own clipboard handling, so the key is left diff --git a/src/style.css b/src/style.css index c3c8159..0e2dee6 100644 --- a/src/style.css +++ b/src/style.css @@ -129,6 +129,61 @@ color: #b29df5; } +/* Status dots. One shared appearance for the sidebar row and the pane header, + coloured entirely by which state class is on the icon. Symbolic icons take + their colour from `color`, so nothing here needs a second asset. + + These are read at a glance from across a screen full of tabs, so the colours + are picked to survive that: amber and red carry the two states that actually + want you, and they are the only warm colours anywhere in the window. */ +.playpen-status-dot { + -gtk-icon-size: 8px; + opacity: 0; +} + +.playpen-status-busy, +.playpen-status-done, +.playpen-status-input, +.playpen-status-failed { + opacity: 1; +} + +/* Working: the same accent as an active pane's border, so "busy" reads as + ordinary activity rather than something gone wrong. */ +.playpen-status-busy { + color: #b29df5; + animation: playpen-pulse 1.6s ease-in-out infinite; +} + +/* Finished while you were elsewhere. Green rather than the accent so it is + distinct from still-working at a glance, which is the one distinction this + whole indicator exists to make. */ +.playpen-status-done { + color: #7ddc9a; +} + +/* Blocked on you. Amber, and not animated — a pulsing dot reads as progress, + and this is the opposite of progress. */ +.playpen-status-input { + color: #f0c069; +} + +.playpen-status-failed { + color: #f2a0a0; +} + +@keyframes playpen-pulse { + 0% { opacity: 0.35; } + 50% { opacity: 1; } + 100% { opacity: 0.35; } +} + +/* Renaming a tab: a single entry in a popover hanging off the row. */ +.playpen-rename { + padding: 8px; + min-width: 220px; +} + /* A web pane's navigation bar, below the pane header. Kept visually quieter than the header so the two rows don't compete. */ .playpen-nav {