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 {