diff --git a/README.md b/README.md index 8003cfc..debaf05 100644 --- a/README.md +++ b/README.md @@ -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' ``` diff --git a/hooks/playpen-status.sh b/hooks/playpen-status.sh index 278a97a..091e08d 100755 --- a/hooks/playpen-status.sh +++ b/hooks/playpen-status.sh @@ -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 diff --git a/src/Pane.zig b/src/Pane.zig index d1c1661..4c452fd 100644 --- a/src/Pane.zig +++ b/src/Pane.zig @@ -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,