blob: d7261d3f1f8aa59c866ebe6e29c3ccd79c20e484 [file]
/**
* Attention layer for long-running turns, permission prompts, and errors.
*
* Two signals, driven from the runner's turn lifecycle:
* - the terminal title carries a state marker (busy / attention-needed / idle),
* so a glance at the tab tells you what the session is doing; and
* - a terminal BEL rings when something wants the user *while the terminal is
* not focused*, so a turn finishing or a prompt appearing in a background tab
* is not missed.
*
* Focus is tracked out of band: the runner enables DEC private mode 1004 and
* feeds `focusChanged` from the `\x1b[I` / `\x1b[O` reports. Terminals that do
* not support 1004 never report a blur, so `focused` stays true and no BEL ever
* fires — the title markers still work everywhere, but the ring is suppressed
* rather than sounding while the user is watching. That is the conservative
* degradation: never ring when we cannot confirm the user is away.
*/
/** The terminal surface the attention layer writes to: a raw BEL plus the title. */
export interface AttentionTerminal {
write(data: string): void;
setTitle(title: string): void;
}
export interface AttentionControllerOptions {
/** The bare title with no state marker, e.g. `Maka`. */
baseTitle: string;
/** Injectable clock so tests can drive turn duration; defaults to `Date.now`. */
now?: () => number;
/**
* A prompt turn must run at least this long for its completion to be worth a
* ring — a turn that finishes in a blink does not pull you back to a tab you
* are already watching. Permission prompts and errors ring regardless.
*/
longTurnThresholdMs?: number;
/** Busy-marker spinner frames; defaults to the braille cycle. */
busySpinnerFrames?: readonly string[];
/** Spinner frame interval in ms; defaults to {@link DEFAULT_BUSY_SPINNER_INTERVAL_MS}. */
busySpinnerIntervalMs?: number;
/**
* Injectable interval scheduler for the spinner so tests can advance frames by
* hand instead of waiting real timers. Returns a cancel function. Defaults to
* an unref'd global setInterval so a lingering tick never blocks process exit.
*/
scheduleSpinnerInterval?: (callback: () => void, intervalMs: number) => () => void;
}
/** DEC 1004 focus reports the runner enables and forwards to `focusChanged`. */
export const FOCUS_IN_SEQUENCE = '\x1b[I';
export const FOCUS_OUT_SEQUENCE = '\x1b[O';
/** Enable / disable DEC private mode 1004 (focus reporting). */
export const ENABLE_FOCUS_REPORTING = '\x1b[?1004h';
export const DISABLE_FOCUS_REPORTING = '\x1b[?1004l';
const BELL = '\x07';
// Braille spinner (matches pi-tui's Loader frames) shown while a turn or control
// action runs, so a glance at the tab tells you Maka is working — a static dot
// could not be told apart from an idle indicator.
export const BUSY_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const;
export const DEFAULT_BUSY_SPINNER_INTERVAL_MS = 80;
const ATTENTION_TITLE_MARKER = '★ ';
const DEFAULT_LONG_TURN_THRESHOLD_MS = 8000;
function defaultScheduleSpinnerInterval(callback: () => void, intervalMs: number): () => void {
const handle = setInterval(callback, intervalMs);
// Never let the spinner tick alone keep the process alive; it is always
// cleared when the turn ends or the session closes anyway.
handle.unref?.();
return () => clearInterval(handle);
}
export class AttentionController {
private readonly now: () => number;
private readonly longTurnThresholdMs: number;
// Assume focused until the terminal reports a blur, so a terminal that never
// reports focus (no 1004 support) is treated as always-watching and stays
// silent, rather than ringing on every turn.
private focused = true;
private busy = false;
// Latched when something the user has not yet acknowledged is waiting; drives
// the attention title marker until they engage (focus, answer, or a new turn).
private attention = false;
private turnStartedAt = 0;
private baseTitle: string;
private lastTitle: string | null = null;
// Set once the session is closing; every event method then no-ops so a turn
// finalizer that settles after close() cannot re-dirty the handed-back title.
private stopped = false;
// Spinner animation state for the busy marker.
private readonly busySpinnerFrames: readonly string[];
private readonly busySpinnerIntervalMs: number;
private readonly scheduleSpinnerInterval: (
callback: () => void,
intervalMs: number,
) => () => void;
private spinnerFrame = 0;
private cancelSpinner: (() => void) | null = null;
constructor(
private readonly terminal: AttentionTerminal,
private readonly options: AttentionControllerOptions,
) {
this.baseTitle = options.baseTitle;
this.now = options.now ?? Date.now;
this.longTurnThresholdMs = options.longTurnThresholdMs ?? DEFAULT_LONG_TURN_THRESHOLD_MS;
this.busySpinnerFrames =
options.busySpinnerFrames && options.busySpinnerFrames.length > 0
? options.busySpinnerFrames
: BUSY_SPINNER_FRAMES;
this.busySpinnerIntervalMs =
options.busySpinnerIntervalMs && options.busySpinnerIntervalMs > 0
? options.busySpinnerIntervalMs
: DEFAULT_BUSY_SPINNER_INTERVAL_MS;
this.scheduleSpinnerInterval =
options.scheduleSpinnerInterval ?? defaultScheduleSpinnerInterval;
this.refreshTitle();
}
setBaseTitle(title: string): void {
if (this.stopped) return;
this.baseTitle = title;
this.refreshTitle();
}
/** A prompt turn began: activity starts and any prior attention is acknowledged. */
promptTurnStarted(): void {
if (this.stopped) return;
this.turnStartedAt = this.now();
this.attention = false;
this.busy = true;
this.refreshTitle();
}
/**
* A prompt turn ended (completed, errored, or aborted): a turn that ran past
* the threshold rings if the user is away, so a long turn finishing in a
* background tab pulls them back. The outcome does not matter — a long turn
* that failed is exactly as worth surfacing as one that succeeded.
*/
promptTurnEnded(): void {
if (this.stopped) return;
this.busy = false;
if (this.now() - this.turnStartedAt >= this.longTurnThresholdMs) {
this.raiseAttention();
} else {
this.refreshTitle();
}
}
/** A control action (model/session switch, compaction) started: busy, never rings. */
controlStarted(): void {
if (this.stopped) return;
this.attention = false;
this.busy = true;
this.refreshTitle();
}
/** A control action ended. */
controlEnded(): void {
if (this.stopped) return;
this.busy = false;
this.refreshTitle();
}
/** The app needs the user now (a permission prompt appeared, or an error surfaced). */
attentionNeeded(): void {
if (this.stopped) return;
this.raiseAttention();
}
/** Terminal focus changed; regaining focus acknowledges any pending attention. */
focusChanged(focused: boolean): void {
if (this.stopped) return;
this.focused = focused;
if (focused) this.attention = false;
this.refreshTitle();
}
/**
* The session is closing: drop any busy / attention marker so the tab is not
* handed back to the shell still marked busy, and go inert so a turn finalizer
* that settles after this cannot re-dirty the title.
*/
reset(): void {
this.stopped = true;
this.busy = false;
this.attention = false;
this.refreshTitle();
}
/**
* Ring and mark the title only when the terminal is not focused. The `★`
* marker means "an attention-worthy event fired while you were away", not a
* live needs-you state: it is set on the event and cleared when you return
* (focus) or a new turn starts. While focused the on-screen UI (the finished
* turn, the y/n prompt, the error notice) is the signal, so no ring and no
* decoration — just drop back to the plain title if the turn had made it busy.
*
* The bell rings once per away-episode: a second event while the marker is
* already up (e.g. a turn that errors and then ends long) does not re-ring, so
* a background tab is alerted once, not repeatedly, until the user engages.
*/
private raiseAttention(): void {
if (this.focused) {
this.refreshTitle();
return;
}
if (!this.attention) this.terminal.write(BELL);
this.attention = true;
this.refreshTitle();
}
private refreshTitle(): void {
// Keep the spinner ticking exactly while the busy marker is on screen, so it
// animates during work and stops (freeing the timer) the moment it isn't.
this.syncSpinner();
// Attention outranks busy: a turn parked on a permission prompt is "busy"
// but what it actually needs is the user, so surface that first. Attention
// is only ever set while unfocused, so a normal running turn still shows busy.
const title = this.attention
? `${ATTENTION_TITLE_MARKER}${this.baseTitle}`
: this.busy
? `${this.busySpinnerFrames[this.spinnerFrame] ?? ''} ${this.baseTitle}`
: this.baseTitle;
// Only write on a real change so the title stream stays quiet between turns
// and a test can read the transitions rather than a run of duplicates. Each
// spinner tick advances the frame first, so the title genuinely differs.
if (title === this.lastTitle) return;
this.lastTitle = title;
this.terminal.setTitle(title);
}
// Start or stop the spinner interval to match whether the busy marker is
// currently shown (busy, not overridden by attention, session still live).
private syncSpinner(): void {
const shouldRun =
this.busy && !this.attention && !this.stopped && this.busySpinnerFrames.length > 0;
if (shouldRun && !this.cancelSpinner) {
this.cancelSpinner = this.scheduleSpinnerInterval(() => {
this.spinnerFrame = (this.spinnerFrame + 1) % this.busySpinnerFrames.length;
this.refreshTitle();
}, this.busySpinnerIntervalMs);
} else if (!shouldRun && this.cancelSpinner) {
this.cancelSpinner();
this.cancelSpinner = null;
// Reset so the next busy episode opens on the first frame, not mid-cycle.
this.spinnerFrame = 0;
}
}
}