Fix hooks.
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user