Fix hooks.

This commit is contained in:
Greyson Parrelli
2026-08-12 18:50:16 -04:00
parent dca5ec0b7d
commit 4e0e400372
7 changed files with 471 additions and 126 deletions
+57 -15
View File
@@ -279,7 +279,7 @@ in principle, but a terminal grid is small.
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
it is working, waiting on you, or finished and still unanswered. 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
@@ -345,18 +345,45 @@ 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** |
| green | **finished, and you haven't answered it** |
| red | stopped on an error |
| none | idle |
Each colour is carried at three sizes, so it survives being glanced at rather
than read: an 11px dot on the row, a bar down the row's leading edge, and a wash
behind the whole row. Working gets the bar without the wash — it is the resting
state of a busy afternoon, and tinting half the sidebar all day would only teach
you to stop looking. Inside a tab, a pane that is asking colours its own frame
and header the same way, which is what picks it out of a four-way split.
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.
looks exactly like one that never ran, so a pane that finishes latches green and
stays that way until you deal with it. Nothing else tells you a tab is worth
going back to.
**Opening the tab clears the row.** A pane's own dot clears when you go to that
pane or type in it. The two answer slightly different questions: the row's is
"should I go there?", which visiting settles whether or not you then deal with
everything inside, and a pane's is "have you dealt with me?", which only you can
answer. In a split that difference is the whole point — you open a flagged tab,
the row goes quiet, and the panes you haven't been to yet are still marked.
Work that finishes while you are sitting in the tab flags it too, deliberately.
Having a tab on screen when a session stopped says only that the pixels were in
front of you; watching it finish and then moving on to something else is the
case this is most needed for. It goes quiet as soon as you answer that pane, or
the next time you come back to the tab.
Green also outranks purple. A tab holding three sessions goes green as soon as
any one of them finishes rather than waiting for the last one to stop, because
"one of these is ready for you" is the news. The two states that actually want
something from you — amber and red — outrank it in turn.
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.
four-pane tab still reduces to one dot. The pane headers carry their own dots,
so once the row has brought you to the tab, they say which pane inside it was
the one asking; the row stays lit until every finished pane in it has been
answered.
### How it gets there
@@ -385,13 +412,28 @@ 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.
The hook writes to a pty, not to a socket or a daemon: 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.
Finding that pty is the one genuinely fiddly part, and `/dev/tty` — the obvious
answer, and what this used to do — is the wrong one. **Claude Code starts each
hook in its own session**, so a hook has no controlling terminal at all and
opening `/dev/tty` fails with `ENXIO`. It fails invisibly, too: `[ -w /dev/tty ]`
returns true regardless, because it stats a path whose mode is `0666` rather
than opening it, so guarding on that reports success and then writes into
nothing. Every state change was being dropped on the floor with no error
anywhere.
The pty is only one hop away, though — it is on Claude Code's own standard file
descriptors. So the script tries its controlling terminal by *opening* it, and
failing that walks up `/proc` for the nearest ancestor holding a pty, with a
`ps -o tty=` fallback for systems without `/proc`. The nearest ancestor is the
right answer even with something in between: a Claude running inside tmux inside
a pane finds tmux's pty, which is where its output actually goes.
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
@@ -403,7 +445,7 @@ 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:
$ # written to the guest's console, observed on the host pty:
b'\x1b]9;4;3\x07' b'\x1b]0;hello-from-guest\x07' b'\x1b]9;4;0\x07'
```
+83 -16
View File
@@ -8,16 +8,15 @@
# 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.
# The state is written as OSC 9;4 — the ConEmu progress protocol — to the pty
# Claude Code is running on, 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.
# Why a pty and not a socket. The bytes then 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
@@ -27,10 +26,78 @@ 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
# ---------------------------------------------------------------------------
# Find the pty to write to.
#
# Not /dev/tty, which is the obvious answer and the wrong one: Claude Code
# starts each hook in its own session, so a hook has no controlling terminal
# and opening /dev/tty fails with ENXIO. Worse, `[ -w /dev/tty ]` still says
# yes — it stats the path, whose mode is 0666, rather than opening it — so
# guarding on that reports success and then writes into nothing.
#
# The pty is still one hop away: it is on Claude Code's own standard fds. So
# test our controlling terminal by actually opening it, and otherwise walk up
# the process tree for the nearest ancestor holding a pty open. That nearest
# ancestor is the right answer even when something else is in between: a
# Claude running inside tmux inside a pane finds tmux's pty, which is where
# its output is really going.
tty_target=""
if (: > /dev/tty) 2>/dev/null; then
tty_target=/dev/tty
fi
if [ -z "$tty_target" ] && [ -d /proc ]; then
pid=$PPID
hops=0
while [ "$hops" -lt 16 ] && [ -n "$pid" ] && [ "$pid" != 0 ] && [ "$pid" != 1 ]; do
for fd in 0 1 2; do
link=$(readlink "/proc/$pid/fd/$fd" 2>/dev/null) || continue
case "$link" in
# Guarded to terminal devices before opening it for write, so this
# can never truncate a regular file an fd happened to point at.
/dev/pts/[0-9]* | /dev/tty[0-9]*)
if (: > "$link") 2>/dev/null; then
tty_target=$link
break
fi
;;
esac
done
[ -n "$tty_target" ] && break
# Walk to the parent. The comm field of /proc/pid/stat is parenthesised
# and may itself contain spaces, so cut through it rather than counting
# fields from the start: after the trim, $2 is the ppid.
stat=$(cat "/proc/$pid/stat" 2>/dev/null) || break
# shellcheck disable=SC2086
set -- ${stat#*") "}
pid=${2:-}
hops=$((hops + 1))
done
fi
# No /proc to walk: ask ps for the parent's terminal instead. Linux spells it
# "pts/4" and macOS "s004", so try it both as given and with the tty prefix.
if [ -z "$tty_target" ]; then
name=$(ps -o tty= -p "$PPID" 2>/dev/null | tr -d ' \n')
case "$name" in
"" | "?" | "??") ;;
*)
for cand in "/dev/$name" "/dev/tty$name"; do
if (: > "$cand") 2>/dev/null; then
tty_target=$cand
break
fi
done
;;
esac
fi
# Nothing anywhere to report to — headless, in CI, or a harness that detached
# us from every terminal. A hook must never be the thing that breaks a
# session, so leave quietly.
[ -n "$tty_target" ] || exit 0
case "$state" in
busy) code=3 ;; # indeterminate
@@ -39,10 +106,10 @@ case "$state" in
*) code=0 ;; # removed
esac
# Every write goes to /dev/tty explicitly. stdout belongs to Claude Code, which
# Every write goes to the pty 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
printf '\033]9;4;%s\007' "$code" > "$tty_target" 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.
@@ -72,6 +139,6 @@ fi
# 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
[ -n "$title" ] && printf '\033]0;%s\007' "$title" > "$tty_target" 2>/dev/null
exit 0
+156 -12
View File
@@ -78,13 +78,14 @@ 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.
/// A web pane simply never calls `on_status` or `on_input`; 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,
on_input: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque,
};
@@ -158,14 +159,106 @@ pub fn setDot(dot: *gtk.Image, class: ?[:0]const u8) void {
}
}
/// 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",
};
/// What a dot is signalling: the four states content can report, plus one it
/// cannot know about on its own.
///
/// `done` is the whole reason this is a separate type from `Status`. Content
/// that has gone back to idle is indistinguishable from content that never
/// ran, and "it finished" is exactly the thing worth knowing when you are
/// deciding what to go back to. So work that lands while you are looking
/// somewhere else latches here and stays until you answer it.
///
/// The same five states drive both dots. A pane's dot is spent by going to
/// that pane; a row's by visiting that tab. Two scopes of the same question —
/// which tab wants me, and which pane inside it — so they share the mapping
/// rather than each having their own.
pub const Attention = enum {
none,
busy,
done,
needs_input,
failed,
/// What a status and an unanswered latch add up to.
///
/// `done` outranks `busy` deliberately: when one pane has finished and is
/// still waiting on you while another is working, the finished one is the
/// news. The two states that actually want you outrank it in turn.
pub fn of(status: Status, done_unanswered: bool) Attention {
return switch (status) {
.needs_input => .needs_input,
.failed => .failed,
.busy => if (done_unanswered) .done else .busy,
.idle => if (done_unanswered) .done else .none,
};
}
/// The classes a state paints with: one on the dot, one on the surface
/// carrying it — a sidebar row, or a pane's frame.
///
/// Returned as a pair so the two can never drift into disagreeing about
/// what colour a state is. Null means idle, which paints nothing at all.
pub fn classes(self: Attention) ?struct {
dot: [:0]const u8,
surface: [:0]const u8,
} {
return switch (self) {
.none => null,
.busy => .{ .dot = "playpen-status-busy", .surface = "playpen-attn-busy" },
.done => .{ .dot = "playpen-status-done", .surface = "playpen-attn-done" },
.needs_input => .{ .dot = "playpen-status-input", .surface = "playpen-attn-input" },
.failed => .{ .dot = "playpen-status-failed", .surface = "playpen-attn-failed" },
};
}
/// The dot's CSS class, or null when nothing should be shown at all.
pub fn class(self: Attention) ?[:0]const u8 {
return if (self.classes()) |c| c.dot else null;
}
/// The class for the row or pane the dot sits on, or null when idle.
pub fn surfaceClass(self: Attention) ?[:0]const u8 {
return if (self.classes()) |c| c.surface else null;
}
pub 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",
};
}
};
/// Every class a surface can carry, for the same reason as `status_classes`.
pub const attention_classes = [_][:0]const u8{
"playpen-attn-busy",
"playpen-attn-done",
"playpen-attn-input",
"playpen-attn-failed",
};
/// Bring a dot in line with what it should be signalling. The tooltip comes
/// along with the colour, since a colour on its own doesn't say what it means.
pub fn applyDot(dot: *gtk.Image, state: Attention) void {
setDot(dot, state.class());
dot.as(gtk.Widget).setTooltipText(state.tooltip());
}
/// Paint a state onto a dot and the surface around it at once.
///
/// A dot alone is a few pixels, which is fine once you know where to look and
/// no use at all for the thing this is for: sweeping a sidebar and picking out
/// the row that wants you. The surface class is what lets the row and the pane
/// frame carry the same colour at a size you can catch out of the corner of
/// your eye.
pub fn applyAttention(surface: *gtk.Widget, dot: *gtk.Image, state: Attention) void {
applyDot(dot, state);
for (attention_classes) |c| surface.removeCssClass(c);
if (state.surfaceClass()) |c| surface.addCssClass(c);
}
/// How close to the view's outer border a drop must be, in pixels, to place
@@ -203,6 +296,14 @@ title: [128:0]u8 = @splat(0),
/// Latest status reported by the content.
status: Status = .idle,
/// Set when this pane's content finished and you haven't answered it yet.
///
/// Latched here because this is the only place that sees the transition: by
/// the time you glance at the pane, work that finished and work that never
/// started look identical. It is spent by typing into the pane — see
/// `onContentInput` for why that and not merely looking.
done_unanswered: bool = false,
pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane {
const self = try alloc.create(Pane);
errdefer alloc.destroy(self);
@@ -229,6 +330,7 @@ pub fn create(alloc: std.mem.Allocator, view: *View, spec: Spec) !*Pane {
.on_exit = &onContentExit,
.on_focus = &onContentFocus,
.on_status = &onContentStatus,
.on_input = &onContentInput,
.ctx = self,
};
self.content = switch (spec) {
@@ -291,6 +393,23 @@ pub fn titleSlice(self: *const Pane) [:0]const u8 {
return std.mem.sliceTo(&self.title, 0);
}
/// What this pane's dot is signalling right now.
pub fn attention(self: *const Pane) Attention {
return .of(self.status, self.done_unanswered);
}
/// Bring the header dot and the pane's own frame in line with its state.
fn refreshDot(self: *Pane) void {
applyAttention(self.widget(), self.dot, self.attention());
}
/// You have answered whatever this pane finished, so it stops asking.
pub fn markAnswered(self: *Pane) void {
if (!self.done_unanswered) return;
self.done_unanswered = false;
self.refreshDot();
}
/// Mark this pane as the focused one in its view.
pub fn setActive(self: *Pane, active: bool) void {
if (active) {
@@ -315,7 +434,7 @@ fn buildHeader(self: *Pane) void {
// 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));
self.refreshDot();
header.append(self.dot.as(gtk.Widget));
self.label.setXalign(0);
@@ -529,12 +648,37 @@ fn onContentStatus(ctx: ?*anyopaque, status: Status) void {
const self: *Pane = @ptrCast(@alignCast(ctx.?));
if (self.status == status) return;
// Work going quiet is the transition worth remembering, and it is worth
// remembering whether or not anyone was watching: a session you saw finish
// and then left alone is still a session waiting on you.
const finished = status == .idle and self.status != .idle;
if (finished) self.done_unanswered = true;
self.status = status;
setDot(self.dot, statusClass(status));
self.refreshDot();
// The tab needs to hear about a finish as an event, not just see the
// state afterwards, so that opening the tab can clear it.
if (finished) self.view.paneFinished(self);
self.view.paneStatusChanged(self);
}
/// The shell exited, or a page called window.close().
/// The user typed into this pane.
///
/// This, rather than focus or visibility, is what clears the dot. Having the
/// tab on screen when a session finished says only that the pixels were in
/// front of you; typing into it is the first moment anything says you dealt
/// with what it had to say. The pane going busy again is a natural consequence
/// of the same keystrokes, so the two never disagree.
fn onContentInput(ctx: ?*anyopaque) void {
const self: *Pane = @ptrCast(@alignCast(ctx.?));
if (!self.done_unanswered) return;
self.markAnswered();
self.view.paneStatusChanged(self);
}
fn onContentExit(ctx: ?*anyopaque) void {
const self: *Pane = @ptrCast(@alignCast(ctx.?));
self.view.closePane(self);
+7
View File
@@ -56,6 +56,11 @@ on_focus: *const fn (ctx: ?*anyopaque) void,
/// it in the tab strip.
on_status: *const fn (ctx: ?*anyopaque, status: Status) void,
/// Called when the user types into this terminal. Focus isn't enough for
/// this: the owner uses it as the sign that whatever the session had to say
/// has been dealt with, and that is answering, not looking.
on_input: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque = null,
/// What a layout can specify for a terminal pane.
@@ -84,6 +89,7 @@ pub fn create(
.on_exit = cbs.on_exit,
.on_focus = cbs.on_focus,
.on_status = cbs.on_status,
.on_input = cbs.on_input,
.ctx = cbs.ctx,
};
@@ -311,6 +317,7 @@ fn onKeyPressed(
// Typing should always snap the view back to the prompt.
self.session.term.screens.active.pages.scroll(.active);
self.session.write(encoded);
self.on_input(self.ctx);
self.area.as(gtk.Widget).queueDraw();
return 1;
}
+42
View File
@@ -81,12 +81,20 @@ closing: bool = false,
on_empty: *const fn (ctx: ?*anyopaque) void,
on_title: *const fn (ctx: ?*anyopaque) void,
on_status: *const fn (ctx: ?*anyopaque) void,
/// A pane in here just finished work. Separate from `on_status` because the
/// tab needs the *edge*, not the state: "something finished since you were
/// last here" cannot be recovered by looking at the panes afterwards, since
/// one that finished before your last visit looks identical to one that
/// finished after it.
on_finished: *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,
on_finished: *const fn (ctx: ?*anyopaque) void,
ctx: ?*anyopaque,
};
@@ -101,6 +109,7 @@ pub fn create(alloc: std.mem.Allocator, cbs: Callbacks) !*View {
.on_empty = cbs.on_empty,
.on_title = cbs.on_title,
.on_status = cbs.on_status,
.on_finished = cbs.on_finished,
.ctx = cbs.ctx,
};
@@ -313,6 +322,19 @@ pub fn setFocused(self: *View, pane: *Pane) void {
self.focused = pane;
pane.setActive(true);
self.on_title(self.ctx);
// Deliberately moving into a pane answers it, the same as typing would.
// The identity check above is what keeps this honest: work finishing in
// the pane you are already sitting in leaves the focus unchanged, so it
// stays flagged — which is the case the whole indicator exists for.
pane.markAnswered();
self.on_status(self.ctx);
}
pub fn paneFinished(self: *View, pane: *Pane) void {
_ = pane;
if (self.closing) return;
self.on_finished(self.ctx);
}
pub fn paneTitleChanged(self: *View, pane: *Pane) void {
@@ -347,6 +369,26 @@ pub fn status(self: *View) Status {
return worst;
}
/// Whether any pane here has finished work that hasn't been answered.
///
/// Any one pane is enough, and deliberately so: the view's own status can't
/// answer this, because a tab holding three agents never goes idle as a whole
/// until the last of them stops, and "one of them is ready for you" is worth
/// hearing before then.
pub fn anyDoneUnanswered(self: *View) bool {
for (self.panes.items) |pane| if (pane.done_unanswered) return true;
return false;
}
/// Answer the pane the user has landed in, without touching its siblings.
///
/// Called when a tab is opened: the pane you arrive in is the one you are
/// looking at, while the others in a split are still holding news you have not
/// gone to yet, and their dots are the only thing that says which.
pub fn answerFocused(self: *View) void {
if (self.focusedPane()) |pane| pane.markAnswered();
}
// -------------------------------------------------------------------------
// Layouts
+37 -81
View File
@@ -58,43 +58,9 @@ 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",
};
}
};
/// What a sidebar row is signalling. Defined with the dots themselves, since
/// a row and a pane header show the same five states for the same reasons.
const Attention = Pane.Attention;
/// A single tab: a view of one or more panes, plus the sidebar row that
/// selects it.
@@ -119,13 +85,14 @@ const Tab = struct {
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,
/// Set when a pane in this tab finished since the last time you opened it.
///
/// The row and the panes answer slightly different questions, which is why
/// this exists alongside the panes' own latches. The row's question is
/// "should I go there?", and opening the tab settles it whether or not you
/// then deal with every pane inside. A pane's question is "have you dealt
/// with me?", which only you can answer.
finished_since_visit: bool = false,
name: [16]u8,
name_len: usize,
@@ -136,15 +103,12 @@ const Tab = struct {
/// 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.
/// Both halves have to hold. The flag alone would keep a row lit after you
/// answered the last pane in it without leaving the tab; the panes alone
/// would keep it lit after you had already come and looked.
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,
};
const news = self.finished_since_visit and self.view.anyDoneUnanswered();
return .of(self.view.status(), news);
}
};
@@ -298,6 +262,7 @@ fn newTabEmpty(self: *Window) !*Tab {
.on_empty = &onViewEmpty,
.on_title = &onViewTitle,
.on_status = &onViewStatus,
.on_finished = &onViewFinished,
.ctx = tab,
});
errdefer view.destroy();
@@ -334,7 +299,7 @@ fn newTabEmpty(self: *Window) !*Tab {
// 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);
Pane.applyAttention(tab.row.as(gtk.Widget), tab.dot, .none);
row_box.append(tab.dot.as(gtk.Widget));
const close = gtk.Button.newFromIconName("window-close-symbolic");
@@ -710,12 +675,12 @@ fn select(self: *Window, tab: *Tab) void {
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);
}
// Opening the tab is the acknowledgement the row was asking for, and the
// pane you land in counts as answered along with it. Any other pane in a
// split keeps its own dot until you go to it.
tab.finished_since_visit = false;
tab.view.answerFocused();
self.refreshStatus(tab);
}
fn indexOf(self: *Window, tab: *Tab) ?usize {
@@ -812,10 +777,7 @@ fn refreshLabel(self: *Window, tab: *Tab) void {
/// 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());
Pane.applyAttention(tab.row.as(gtk.Widget), tab.dot, tab.attention());
}
fn onViewTitle(ctx: ?*anyopaque) void {
@@ -823,27 +785,21 @@ 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.
/// A pane in this tab changed state, or answered one it was carrying.
fn onViewStatus(ctx: ?*anyopaque) void {
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
const self = tab.window;
tab.window.refreshStatus(tab);
}
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);
/// A pane in this tab finished work.
///
/// Recorded even when the tab is the one on screen: you may well have watched
/// it stop and then gone somewhere else, and the next time you open this tab is
/// when that stops being news.
fn onViewFinished(ctx: ?*anyopaque) void {
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
tab.finished_since_visit = true;
tab.window.refreshStatus(tab);
}
/// The view lost its last pane, so the tab goes with it.
+89 -2
View File
@@ -38,10 +38,57 @@
color: #f0ecf8;
}
.playpen-list > row:selected image {
/* The kind icon picks up the accent on the current row. The status dot is
excluded: it carries a colour that *is* the information, and this rule is
specific enough to beat the state classes, which used to leave the selected
row's dot accent-coloured whatever it was trying to say. */
.playpen-list > row:selected image:not(.playpen-status-dot) {
color: #b29df5;
}
/* State on the row itself, not just on its dot.
A bar down the leading edge and a wash behind the whole row, both in the
state's colour. The dot says what a row is doing once you are reading it;
these are what let you sweep a sidebar of a dozen tabs and land on the one
that wants you without reading any of them.
The bar is an inset shadow rather than a real border because a border takes
part in layout: 3px of it would shove every label sideways as a tab changed
state, and a sidebar that twitches is worse than one that is hard to read.
These come after the :selected rules deliberately. Equal specificity means
source order decides, and a state outranks "this is the tab you are on". */
.playpen-list > row.playpen-attn-busy {
box-shadow: inset 3px 0 0 #b29df5;
}
/* No wash for working. It is the most common state by far, and a sidebar where
half the rows are tinted all afternoon teaches you to stop looking. */
.playpen-list > row.playpen-attn-done {
box-shadow: inset 3px 0 0 #7ddc9a;
background-color: rgba(125, 220, 154, 0.13);
}
.playpen-list > row.playpen-attn-input {
box-shadow: inset 3px 0 0 #f0c069;
background-color: rgba(240, 192, 105, 0.13);
}
.playpen-list > row.playpen-attn-failed {
box-shadow: inset 3px 0 0 #f2a0a0;
background-color: rgba(242, 160, 160, 0.13);
}
/* The current row keeps its own background: the wash and the selection would
otherwise blend into a colour that reads as neither. The bar survives, which
is the part that carries the state. */
.playpen-list > row:selected.playpen-attn-done,
.playpen-list > row:selected.playpen-attn-input,
.playpen-list > row:selected.playpen-attn-failed {
background-color: #342c4a;
}
/* Keep the close button unobtrusive until the row is hovered or current. */
.playpen-close {
opacity: 0;
@@ -137,7 +184,7 @@
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;
-gtk-icon-size: 11px;
opacity: 0;
}
@@ -230,6 +277,46 @@
opacity: 0.55;
}
/* State on a pane's own frame, so a tab full of splits says which pane is
asking without being read pane by pane.
Only the three states that are news get a border. Working deliberately does
not: it is the resting state of every pane you have set going, and colouring
the frame for it would leave the whole view repainting itself all afternoon
while saying nothing you did not already know. Its pulsing dot is enough.
After `.active` and `.zoomed` in source order, so a state outranks both —
they say where you are, which you know, and this says what happened, which
you don't. `.dragging` still wins over all of it, further down: while you are
moving a pane, feedback about the move is the only thing that matters. */
.playpen-pane.playpen-attn-done {
border-color: #7ddc9a;
}
.playpen-pane.playpen-attn-input {
border-color: #f0c069;
}
.playpen-pane.playpen-attn-failed {
border-color: #f2a0a0;
}
/* The header carries a wash of the same colour. The border alone is a hairline
around a large shape; the header is a solid band right next to the dot and
the title, which is what makes a pane readable at a glance in a four-way
split. */
.playpen-pane.playpen-attn-done .playpen-pane-header {
background-color: rgba(125, 220, 154, 0.12);
}
.playpen-pane.playpen-attn-input .playpen-pane-header {
background-color: rgba(240, 192, 105, 0.12);
}
.playpen-pane.playpen-attn-failed .playpen-pane-header {
background-color: rgba(242, 160, 160, 0.12);
}
/* The pane being dragged. There is no separate drop indicator: the layout
rearranges live during the drag, so the view itself is the preview. */
.playpen-pane.dragging {