Add hook system.

This commit is contained in:
Greyson Parrelli
2026-08-11 16:24:53 -04:00
parent 73a0187db5
commit dd60e52005
9 changed files with 788 additions and 3 deletions
+97
View File
@@ -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:
+77
View File
@@ -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
+95
View File
@@ -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"]
+83
View File
@@ -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, <title>" 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.?));
+59
View File
@@ -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 {
+14
View File
@@ -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,
+36
View File
@@ -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
+272 -3
View File
@@ -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
+55
View File
@@ -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 {