Selkie--App
NAME
Selkie::App - The main entry point: event loop, screens, modals, toasts, focus
SYNOPSIS
use Selkie::App;
use Selkie::Layout::VBox;
use Selkie::Widget::Text;
use Selkie::Sizing;
my $app = Selkie::App.new;
my $root = Selkie::Layout::VBox.new(sizing => Sizing.flex);
$root.add: Selkie::Widget::Text.new(
text => 'Hello from Selkie',
sizing => Sizing.fixed(1),
);
$app.add-screen('main', $root);
$app.switch-screen('main');
$app.on-key('ctrl+q', -> $ { $app.quit });
$app.run; # blocks until quitDESCRIPTION
Selkie::App is what you construct to start a Selkie program. It owns the notcurses handle, the reactive store, the screen manager, the active modal (if any), the toast overlay, the focused widget, and the event loop.
Your app code:
Builds a widget tree
Registers it as a screen with
add-screenActivates a screen with
switch-screenPicks an initial focused widget with
focusRegisters global keybinds with
on-keyStarts the loop with
run
The loop runs at the hot rate (default 60 Hz, see hot-hz) while the user is interacting, then steps down through an idle ladder ā 30 Hz, 12 Hz, and finally 4 Hz after two minutes ā so a passive TUI costs near-zero battery. Background store traffic (streaming repaints, worker progress events) keeps the loop ticking at up to 30 Hz, but only user activity (input, resize) drives it back to the hot rate. Each wake it polls for input, dispatches events (to the focused widget, then up the parent chain, then to global keybinds), runs registered frame callbacks, ticks the store, processes any queued focus cycling, ticks the toast, and renders dirty widgets. Idle work is minimized: when nothing changed, the store's subscription walk and the composite render to the terminal are both skipped.
run only returns when quit is called or an unhandled exception reaches the top of the loop. In either case the terminal is restored before the program exits.
Theme background
When constructed with a theme, Selkie::App paints the notcurses standard plane's base cell from $theme.base during init so any region no widget writes to falls through to the theme background rather than the terminal's own default. Combined with Selkie::Widget doing the same per-plane on init-plane / set-theme / each apply-style, this gives themed backgrounds full-terminal coverage ā no gaps between widgets or at screen edges.
The standard plane itself is exposed via stdplane if you need to reach it directly (e.g. to paint a custom base cell from application code).
Default keybinds
Selkie::App registers these out of the box so you don't have to:
Tab/Shift-Tabā cycle focus through focusable descendantsEscā close the active modal (no-op if none)Ctrl+Qā quit the app
Your own on-key registrations don't override these by default ā if you need to, register your handler with a matching spec and call quit or close-modal yourself.
LIFECYCLE
Construction calls notcurses_init, enables mouse support, drains any pending terminal-query responses, and registers the default keybinds. If notcurses_init fails, construction throws immediately.
An END phaser registered during construction guarantees shutdown runs even if the program exits abnormally (e.g. an exception before run is called). This means your terminal is always restored.
shutdown itself is exception-isolated step-by-step: a throw in modal destroy, screen-manager destroy, or notcurses_stop (the most common real-world cause is a NativeCall dlopen failure when the bundled notcurses library was reinstalled to a new path mid-session) doesn't abort the rest. TTY restoration, the escape-sequence backstop, and stderr-redirect teardown always get a chance to run.
run wraps the event loop in a CATCH block. If anything inside the loop throws, the terminal is restored, the error is printed to STDERR with a full backtrace, and the process exits with code 1.
EXAMPLES
A single-screen app
The simplest pattern. One screen, one focused input, a quit binding:
use Selkie::App;
use Selkie::Layout::VBox;
use Selkie::Widget::TextInput;
use Selkie::Sizing;
my $app = Selkie::App.new;
my $root = Selkie::Layout::VBox.new(sizing => Sizing.flex);
my $input = Selkie::Widget::TextInput.new(sizing => Sizing.fixed(1));
$root.add($input);
$app.add-screen('main', $root);
$app.switch-screen('main');
$app.focus($input);
$input.on-submit.tap: -> $text { $app.toast("You typed: $text") };
$app.on-key('ctrl+q', -> $ { $app.quit });
$app.run;Multiple screens
Register each screen with a name; switch between them with switch-screen. The inactive screens are parked off-screen but keep their state (widget instances, focus, scroll position):
$app.add-screen('login', $login-root);
$app.add-screen('main', $main-root);
# Start on login:
$app.switch-screen('login');
$app.focus($login-form.password-input);
# Later, after authentication:
$app.switch-screen('main');
$app.focus($main-root.focusable-descendants.List[0]);A modal dialog
Show a modal to ask the user a question. The modal traps focus ā all keystrokes go to it or its descendants until closed ā and Esc closes it automatically:
use Selkie::Widget::ConfirmModal;
my $cm = Selkie::Widget::ConfirmModal.new;
$cm.build(
title => 'Really delete?',
message => "This cannot be undone.",
yes-label => 'Delete',
no-label => 'Cancel',
);
$cm.on-result.tap: -> Bool $confirmed {
$app.close-modal;
delete-item() if $confirmed;
};
$app.show-modal($cm.modal);
$app.focus($cm.no-button); # default to the safe buttonModals stack. Calling show-modal while another modal is already open pushes the new modal on top ā useful for, say, a confirm dialog opened from inside an editor. close-modal pops the topmost modal, and the previous modal becomes active again with all its keybinds intact and its pre-modal-focus restored. Repeat close-modal to drain the stack.
A frame callback for animation
on-frame fires on every iteration of the event loop (~60fps), even when there's no input. Use it to drive timers, animations, or pull from an external stream:
$app.on-frame: {
$progress-bar.tick; # indeterminate animation
$chat-view.pull-tokens; # pull from an LLM stream
};Screen-scoped keybinds
Scope a keybind to one screen by passing :screen. It fires only when that screen is active:
$app.on-key('ctrl+n', :screen('tasks'), -> $ { create-task });
$app.on-key('ctrl+n', :screen('notes'), -> $ { create-note });
$app.on-key('ctrl+q', -> $ { $app.quit }); # unscoped = every screenReacting to terminal resizes
on-resize fires whenever Selkie's polling detects a change in the host terminal's dimensions. Multiple callbacks are supported and run in registration order, after the widget tree has been re-laid-out and the new frame has been composited. Selkie marks every Image dirty during the resize pass so pixel blitters re-diff geometry and re-emit from the framework path; callbacks are for app-owned layout caches or telemetry that needs the final ($rows, $cols):
$app.on-resize: -> UInt $rows, UInt $cols {
$layout-cache.recompute(:$rows, :$cols);
};SEE ALSO
Selkie::Widget ā the base role every widget composes
Selkie::ScreenManager ā multi-screen management (used via
add-screen/switch-screen)Selkie::Store ā the reactive state store
Selkie::AppownsSelkie::Widget::Modal ā modal dialogs
Selkie::Event ā the keyboard / mouse event abstraction
method stdplane
method stdplane() returns Notcurses::Native::Types::NcplaneHandleThe notcurses standard plane ā the root of the compositing tree, with the terminal's full dimensions. Exposed for apps that need to set a base cell (fill colour for otherwise-empty cells) so a theme background reaches every corner. Only valid after run has initialised notcurses.
has Selkie::Theme $.theme
The theme installed on every screen's root. Defaults to Selkie::Theme.default if not provided to .new.
has Selkie::Store $.store
The reactive store owned by this app. Constructed automatically on .new; every screen added to the app gets this store propagated into its widget tree. Subscribe to state paths from widgets via self.subscribe(...).
has Positional @!crash-restore-taps
Taps for asynchronously-recoverable fatal signals (SIGABRT, SIGTERM, SIGHUP, SIGQUIT). When any of these arrives we run shutdown so the terminal returns to cooked mode and the alternate screen is exited before the process dies. Without these taps the kernel takes the default action (terminate) and Selkie's LEAVE / END / CATCH cleanup never runs, leaving the user staring at a wedged shell. SIGSEGV / SIGBUS / SIGILL / SIGFPE bypass Raku's Supply-based dispatch (the process is dead by the time the scheduler thread wakes); restoring on those requires a NativeCall sigaction-based handler running in the offending thread, which is out of scope for this change.
has Num $.hot-hz
Hot-rate frame budget in Hz. The main loop caps itself at this rate while the user is interacting (input, resize); the idle ladder then steps down (to 30 / 12 / 4 Hz) after periods of inactivity. Background store traffic ā streaming repaints, worker progress dispatches ā holds the loop at no more than 30 Hz on its own; only user activity drives the full hot rate (see pick-frame-budget). Defaults to 60 Hz ā enough for smooth typing and scrolling without burning battery on passive sits. Apps doing terminal video playback, high-refresh animations, or live plot rendering can bump this higher ā notcurses itself supports video, so 120 Hz+ is a legitimate use case for that flavour of app. This is a CEILING, not a floor: the loop sleeps at least 1 / $hot-hz seconds between frames, but may sleep longer when the idle ladder has ramped down.
has Num $.sprixel-refresh-idle-threshold
Seconds of user-idle before the run loop begins forcing every Selkie::Widget::Image to re-emit its sprixel. Defends against terminals that drop or fail to redraw inline graphics on tab / window-focus changes ā Kitty's per-tab compositor is the canonical example: switching to another Kitty tab and back typically restores the cell buffer but not the direct-placement graphics that notcurses emitted there, so any Image whose owning widget hasn't dirtied since the switch stays visually blank even though Selkie's blit-plane cache thinks it's still live. User idle specifically ā background store traffic (a streaming response, image-gen progress) doesn't reset the clock, so a tab switch during a long-running job still refreshes. The loop also runs this check before dispatching the first input after an idle gap, but it no longer waits for that input: once this threshold is crossed, the idle loop keeps re-emitting at sprixel-refresh-idle-interval until the user interacts again. Defaults: 0.5e0 on Kitty (detected via KITTY_WINDOW_ID or TERM starting with xterm-kitty); 0e0 (disabled) elsewhere. Set to 0e0 explicitly to opt out even on Kitty. The cost of a triggered refresh is roughly O(images-in-tree) destroy-blit-plane calls plus fresh blits for visible Images on the next render. The tree walk itself is free on apps without Images.
has Num $.sprixel-refresh-idle-interval
Minimum seconds between automatic idle sprixel refreshes once sprixel-refresh-idle-threshold has been crossed. Defaults to 0.5e0, which means a Kitty tab regains images within one idle frame or two at Selkie's deep-idle 4 Hz cadence without continuously re-emitting on every frame.
has Str $.error-log
Path to a file that receives everything that would normally hit stderr while the app is running ā Raku warnings (Use of uninitialized value ā¦), runtime failures logged via note, C-level libraries writing to stderr, notcurses internal diagnostics, etc. Without this, warnings splat into the TUI compositor's cell grid and produce visible garbage that stays on screen until the next full repaint ā a TUI can't share stderr with its own drawing surface. When set, Selkie::App uses dup2(2) on construction to point file descriptor 2 at this file (append mode); the saved copy of the original stderr is restored on shutdown. Parent directory is auto-created. A new "=== session ā¦" banner is written at the top of each run so long-lived log files stay navigable. Leave as Str (the type object) to disable the redirect ā then stderr goes where it would normally.
method screen-manager
method screen-manager() returns Selkie::ScreenManagerThe screen manager. Useful for .active-screen and .screen-names ā you don't typically need to manipulate it directly, since the add-screen and switch-screen methods on Selkie::App are preferred.
method detect-sprixel-bug-prone-terminal
method detect-sprixel-bug-prone-terminal() returns BoolTrue when the host terminal is one of the known sprixel-survives-tab-switch problem cases (currently: Kitty). Used by TWEAK to pick the default sprixel-refresh-idle-threshold for apps that don't override. Exposed as a regular method (callable on the type object) so the detection logic is unit-testable without spinning up notcurses ā Selkie::App.detect-sprixel-bug-prone-terminal() runs cleanly in plain Raku.
method force-refresh-sprixels
method force-refresh-sprixels() returns NilWalk the live tree (active screen + modals + toast) and force every Selkie::Widget::Image to re-emit its sprixel on the next render: destroy the live blit-plane (which also clears the Image's geometry cache so the next render takes the cache-miss path) and mark the Image dirty (in case it had no live blit-plane and so destroy was a no-op ā the mark-widgets-in-rect-dirty walk inside destroy only fires for Images that actually had a blit to tear down). Called automatically by run when an input event arrives after sprixel-refresh-idle-threshold seconds of input-idle (the "user came back from a Kitty tab" heuristic). Apps can also call it directly ā e.g., from a manual-refresh keybind, or after an operation known to bypass the auto-refresh signal.
method focused
method focused() returns Selkie::WidgetThe widget that currently has focus, or Nil if none.
method event-supply
method event-supply() returns SupplyA Supply that emits every event received by the app. Tap this for global event logging, analytics, or cross-cutting behaviour that doesn't fit the per-widget handler model.
method root
method root() returns Selkie::ContainerConvenience accessor for the active screen's root container. Equivalent to $app.screen-manager.active-root. Returns Nil if no screen is active.
method set-theme
method set-theme(
Selkie::Theme:D $theme
) returns MuSwap the active theme at runtime. Updates the app's theme attribute, repaints the stdplane base cell, cascades set-theme to every registered screen's root widget (which in turn walks their subtrees), and marks every screen dirty so the next frame re-renders with the new palette. App consumers that hold their own cached Style objects derived from a theme's slots still need to rebuild those ā set-theme can't reach closures that copied style values at construction time. The primary guarantee here is "every plane's base cell and every widget's inherited theme updates"; cached styles at the consumer layer are the consumer's responsibility.
method add-screen
method add-screen(
Str:D $name,
Selkie::Container $root
) returns MuRegister a screen under a name. The screen's root container is attached to the theme, the store, and the notcurses stdplane, then parked either at origin (if it's the first screen added) or off-screen (for subsequent screens ā switch-screen will move it to origin when activated). Re-registering a name (common pattern: an overlay screen rebuilt each time it opens) discards any stashed per-screen focus from the previous incarnation ā that widget is about to be destroyed.
method switch-screen
method switch-screen(
Str:D $name
) returns MuActivate a registered screen by name. The previously-active screen is parked off-screen; the new one is moved to the origin, resized to full terminal dimensions, and marked dirty so its entire subtree renders fresh on the next frame. Focus follows the user: before switching, the outgoing screen's focused widget is stashed in per-screen focus memory (if it's still attached to that screen's tree). On arrival, the incoming screen's last-focused widget is restored ā or, if the screen has never been visited (or the saved reference went stale), focus lands on the first focusable widget in the new tree. Apps don't need to manage focus across screen transitions themselves.
method set-title
method set-title(
Str:D $title
) returns MuSet the terminal window title via OSC 0 ("icon name + window title"). Writes directly to /dev/tty to bypass notcurses's output buffering -- the stdplane's double-buffered render path can otherwise stomp interleaved escape sequences. Handles three common cases: =item Bare terminal -- emits ESC]0;TITLE BEL. =item Inside tmux ($TMUX set) -- wraps in the DCS passthrough (ESC Ptmux; ... ESC \\) so the host terminal actually sees it. Requires set -g allow-passthrough on in tmux >= 3.3, which is the default from 3.4 onward. =item No /dev/tty available (tests, piped stdin) -- silently no-op. Control characters (ESC, BEL, CR, LF) in $title are stripped before emission so a hostile title string can't terminate the sequence early or inject further escapes.
method build-title-osc
method build-title-osc(
Str:D $title,
Bool :$tmux = Bool::False
) returns StrBuild the OSC sequence for a title. Factored out as a class method so tests can exercise the sanitisation + tmux-passthrough logic without needing a real tty. Public for callers that want to emit the sequence elsewhere (logging, snapshot tests, etc).
method build-terminal-cleanup-sequence
method build-terminal-cleanup-sequence() returns StrBuild the "exit terminal protocol" escape sequence emitted by shutdown as a belt-and-suspenders layer on top of notcurses_stop. Why it exists: notcurses_stop handles most of this on most terminals, but the Kitty keyboard protocol push (CSI n u>) doesn't reliably pop on iTerm2 ā after the app exits every keystroke arrives as CSI codepoint u at the shell and the user sees literal escape codes where typing should be. The Kitty pop is the load-bearing fix; the other disables ride along on the same emit because every one is idempotent ("disable a mode that's already off" is a no-op on every terminal that parses them), so any future protocol leak that notcurses_stop misses is also covered. Sequences, in order: show cursor, reset SGR, mouse tracking off (every encoding variant), focus event reporting off, bracketed paste off, modify-other-keys off, Kitty kbd protocol pop Ć3 (overshoots a single push in case anything else nested), alt-screen exit. Factored as a class method so tests can verify the exact bytes without spinning up a notcurses instance.
method log-terminal-startup-state
method log-terminal-startup-state() returns NilOne-shot startup diagnostic for terminal-pixel issues. Writes a summary of the chosen pixel implementation, cell + cell-pixel dimensions, and the env vars notcurses uses for terminal identification to /tmp/selkie-terminal-debug.{pid}.log. Gated on SELKIE_TERMINAL_DEBUG=1 so it never fires in production. Used to diagnose Image-rendering bugs that differ across terminals (e.g. AvatarList renders empty in iTerm2 but works in Kitty / Terminal.app).
method toast
method toast(
Str:D $message,
Num :$duration = 2e0
) returns MuShow a temporary message bar at the bottom of the screen. It auto-dismisses after $duration seconds (default 2). The toast overlay is created lazily on first call ā subsequent toasts reuse the same widget.
method show-modal
method show-modal(
Selkie::Widget::Modal $modal
) returns MuShow a modal dialog. The currently-focused widget is remembered and restored when the modal closes. While a modal is open, all events are routed through it (focus trap); only Tab, Shift-Tab, and Esc reach the app's global keybinds. Modals stack: calling show-modal while another modal is already open pushes the new modal on top. close-modal pops the top, so the previous modal becomes active again with all its keybinds intact. This is how a confirm dialog opened from inside an editor returns focus to the editor when dismissed.
method close-modal
method close-modal() returns MuClose the topmost modal, restore the matching pre-modal focus target, and mark the now-revealed surface dirty so it re-renders over the area the closing modal covered. No-op if no modal is open. With nested modals, popping the top reveals the modal underneath ā that becomes the new active modal, and focus is restored to the widget inside it that had focus right before the popped modal opened. When the stack drains to empty, focus restores against the active screen. The pre-modal focus target is validated against the live tree before restoration ā if the widget was destroyed while the modal was open (e.g. the modal's action removed the previously-focused row from a list), focus falls through to the first focusable on the now-active surface instead of dangling.
method has-modal
method has-modal() returns BoolTrue while at least one modal is currently being displayed.
method on-key
method on-key(
Str:D $spec,
&handler,
Str :$screen
) returns MuRegister a global keybind. The spec is a string matching Selkie::Event's syntax ('ctrl+q', 'f1', 'ctrl+shift+a', etc). Pass :screen to scope the bind to a single named screen ā it will only fire when that screen is active. Leave :screen unset for a truly global bind like Ctrl+Q for quit. Global keybinds must include a modifier (Ctrl, Alt, Super) to avoid clashing with text input. Bare character binds belong on focusable widgets that own the key.
method on-frame
method on-frame(
&callback,
Str :$name = ""
) returns MuRegister a callback that fires once per frame (~60 times per second), regardless of input. Use this for: =item Timer and countdown logic =item Animations and indeterminate progress bars ($widget.tick) =item Pulling from external streams that aren't tied to user input Multiple callbacks can be registered; they run in registration order.
method on-resize
method on-resize(
&callback
) returns MuRegister a callback that fires when the terminal is resized. Receives the new ($rows, $cols) as positional arguments. Fires after the widget tree has been re-laid-out and the post-resize frame composited, so callbacks can safely walk the live tree, refresh app-owned layout caches, and log the final dimensions. Selkie handles Image/sprixel dirtying internally during the resize pass; consumers should not need a resize callback just to force framework Images to re-blit.
method focus
method focus(
Selkie::Widget $w
) returns MuMove focus to a specific widget. The previously-focused widget's set-focused(False) is called (if it has one); the new widget's set-focused(True) is called. A ui/focus event is dispatched to the store so subscribers (e.g. Selkie::Widget::Border) can update their appearance. Passing an undefined widget is treated as "focus the first focusable on the active surface" ā Selkie maintains the invariant that $!focused is attached whenever focusable widgets exist. The only legitimate "focus: nothing" state is a surface with zero focusables, in which case $!focused stays undefined.
method widget-attached
method widget-attached(
Selkie::Widget $w,
$root
) returns BoolTrue iff walking up $w's parent chain reaches $root. Used internally to validate that a saved focus reference (in %!screen-focus or @!pre-modal-focus-stack) is still attached to the live tree before we try to restore it. O(tree depth); cheap. Public (rather than private with a leading bang) so tests can exercise the logic via the type object ā Selkie::App.widget-attached(...) works without constructing an App instance (which would require notcurses_init). Apps rarely need to call this directly.
method check-focus-invariant
method check-focus-invariant() returns MuVerify that $!focused is still attached to the input-owning surface (the active modal, or the active screen). If it's dangling ā its container was removed, its screen was destroyed, etc. ā re-focus the first focusable on the surface. No-op when focus is already valid, or when nothing was focused to begin with. Called automatically at the top of every event-loop iteration. Exposed as a public method mainly so tests can drive the guard directly without spinning run ā apps don't normally need to call it.
method focus-next
method focus-next() returns MuMove focus to the next focusable widget in the tree. Wraps around at the end. Bound to Tab by default.
method focus-prev
method focus-prev() returns MuMove focus to the previous focusable widget. Wraps around at the beginning. Bound to Shift-Tab by default.
method quit
method quit() returns MuSignal the event loop to exit. run returns after the current frame completes; the terminal is restored by shutdown.
sub pick-frame-budget
sub pick-frame-budget(
Num:D $hot-budget,
Num:D $user-idle-for,
Num:D $store-idle-for
) returns Num:DPick the frame budget (seconds the loop may spend on this iteration, i.e. 1/Hz) from how long each activity source has been idle. $user-idle-for counts seconds since the last input event, resize, or toast visibility change; $store-idle-for counts seconds since the last store event or write. User activity drives the full ladder back to the hot rate. Store activity alone is floored at IDLE-HALF (30 Hz) ā background dispatch traffic keeps the loop responsive enough to drain the queue promptly without pinning it at 60 Hz for the duration of a long-running worker. Whichever source wants the faster rate wins, and the result is clamped to never exceed the configured hot rate. A package sub (not a closure in run) so the tier math is unit testable without standing up notcurses.
method run
method run() returns MuStart the event loop. Blocks until quit is called or an unhandled exception bubbles up. Each iteration handles: input polling, event dispatch, frame callbacks, store tick, focus action processing, toast tick, and rendering. The tick rate follows the idle ladder (see pick-frame-budget): the hot rate while the user interacts, stepping down to 4 Hz at deep idle; store-only activity holds it at no more than 30 Hz. Idle work is minimized on each dimension: resize polling is throttled to ~12 Hz, the store tick only walks subscriptions when events were processed, and the renderer only composites to the terminal when a widget actually rendered (or the toast just auto-dismissed). A static screen produces near-zero CPU. The loop body is wrapped in a CATCH block: any thrown exception triggers an orderly shutdown, prints a backtrace to STDERR, and exits the process with status 1.
method widget-at-in
method widget-at-in(
$root,
Int $y,
Int $x
) returns Selkie::WidgetPublic hit-test against an arbitrary root. Returns the deepest widget whose on-screen rectangle contains the given absolute cell, falling back to $root itself when the point is in the root's own bounds but no descendant claims it. Returns the Selkie::Widget type object when the root doesn't contain the point. Two-phase resolution: =item Phase 1: walk the whole tree looking for any widget whose claims-overlay-at returns True. This catches widgets that paint outside their nominal rect (open dropdowns, popovers) ā the layout-aware walk would miss them because their parent's contains-point doesn't extend over the overlay area. =item Phase 2: fall through to the standard depth-first containment walk. Exposed (rather than left private) so tests can exercise the coordinate-walk logic without needing a live App instance ā same pattern as widget-attached. App's mouse dispatcher uses this with the active modal / screen root resolved at call time.
method shutdown
method shutdown() returns MuShut down notcurses and destroy the active modal and screen manager. Idempotent ā safe to call multiple times. Usually you don't call this directly; the event loop's CATCH, the END phaser, or DESTROY takes care of it. Each cleanup step is best-effort: an exception thrown in modal destroy, screen-manager destroy, or notcurses_stop (e.g. when a NativeCall dlopen fails because Notcurses-Native was reinstalled to a new path mid-session) is caught, logged via !try-log, and isolated so the later steps ā TTY restore, the escape-sequence backstop in !emit-terminal-cleanup, and !uninstall-error-log ā still run. Without this isolation, a single failure mid-shutdown would strand the terminal in raw mode / alt-screen / Kitty-kbd- protocol-pushed state.
method set-error-log
method set-error-log(
Str $path
) returns MuSwap the active error-log file at runtime. Tears down the current redirection (restoring fd 2 to the original stderr), updates the path, and reinstalls the redirect pointing at the new file. Passing Str (the type object) or an empty string disables redirection ā fd 2 goes back to wherever it pointed before the first install-error-log. Useful for apps whose log location only becomes known after some runtime event. App::Cantina is the canonical consumer: the path is {cantina-home}/{db-name}/error.log, and db-name is only known after the user selects / creates a profile on the login screen. The app boots with error-log unset, then calls set-error-log from its post-login handler. A new session banner is written to the new log file on each invocation so interleaved runs stay navigable. No-op (save for the banner) when called with the same path it already has.