{"version":3,"file":"notification-panel.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/notification-panel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAI1D;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB;;;CAAyC,CAAC;AAE1E,MAAM,MAAM,gBAAgB,GAAG,MAAM,OAAO,mBAAmB,CAAC;AAuDhE,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,gBAAgB,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,2EAAyE;IACzE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,qBAAa,iBAAkB,YAAW,SAAS;IAqBjD,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,WAAW;IArB7B,kFAAkF;IAClF,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAsD;IAEnF,0DAA0D;IAC1D,OAAO,CAAC,KAAK,CAAsB;IACnC,OAAO,CAAC,KAAK,CAA6B;IAI1C,OAAO,CAAC,KAAK,CAAC,CAAmD;IAEjE;;;;;;;OAOG;IACH,YACkB,aAAa,EAAE,MAAM,IAAI,EACzB,WAAW,GAAE,MAAM,MAA4B,EAC7D;IAEJ,0EAA0E;IAC1E,IAAI,OAAO,IAAI,YAAY,GAAG,SAAS,CAEtC;IAED,iCAAiC;IACjC,IAAI,OAAO,IAAI,SAAS,YAAY,EAAE,CAErC;IAED;;OAEG;IACH,MAAM,CACL,IAAI,EAAE,gBAAgB,EACtB,KAAK,EAAE,MAAM,EACb,IAAI,GAAE,MAAM,EAAO,EACnB,IAAI,CAAC,EAAE,MAAM,EACb,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAO,GAC9C,IAAI,CA8BN;IAED,sEAAsE;IACtE,OAAO,IAAI,IAAI,CAMd;IAED,iEAA+D;IAC/D,IAAI,IAAI,IAAI,CAEX;IAED,UAAU,IAAI,IAAI,CAGjB;IAED;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAuB9B;IAED,2EAA2E;IAC3E,OAAO,CAAC,QAAQ;IAIhB,gFAA8E;IAC9E,OAAO,CAAC,MAAM;IAId;;;;;;OAMG;IACH,OAAO,CAAC,QAAQ;IAYhB;;;;;OAKG;IACH,OAAO,CAAC,GAAG;IAMX,OAAO,CAAC,GAAG;IAeX,OAAO,CAAC,UAAU;CAIlB","sourcesContent":["/**\n * The band that tells you what just changed, and then stops telling you.\n *\n * ## The problem it replaces\n *\n * Moving a dial used to write a line into the transcript: `Model: opus-5`,\n * `Chrome: compact`, `Tool output: peek`. Those lines are true for about a\n * second and then they are litter — a session where someone found their\n * thinking level by stepping through it carries five rows of dead settings\n * chatter forever, interleaved with the conversation that transcript is\n * supposed to be a record of. Warnings had the same shape from the other end: a\n * filled block, kept for the life of the session, for something (\"No previous\n * directory to return to\") that needs to be seen once and never read again.\n *\n * ## The shape\n *\n * One band, one notification at a time, directly above the prompt — which is\n * where the eye already is, and the one place in a bottom-anchored layout that\n * never moves.\n *\n * ## Why a glimpse replaces and a warning waits\n *\n * They are different kinds of message and the queue is where that shows.\n *\n * A glimpse reports *state*: what the model is now, where the thinking dial\n * landed. Only the newest one is true, so a glimpse standing in line replaces\n * itself — holding the dial key down flashes the value it ended on, not five\n * values in sequence three seconds apart, each of them already wrong.\n *\n * A glimpse that names its `topic` goes further and replaces the one *on\n * screen*, because the two are the same dial. Without that, the second press of\n * `alt+z` had nowhere to go but the back of a queue of one, and the band went on\n * showing the stop you had already left until its three seconds were up: a dial\n * that lags a press behind is a dial you cannot step twice.\n *\n * A warning reports an *event*: something happened that you did not ask about.\n * Every one of them is still true when the next arrives, so warnings queue and\n * are shown in turn. Collapsing them would mean the last of three startup\n * warnings silently erasing the two before it.\n *\n * ## What comes through here\n *\n * Every dial step, every warning, and everything a command has to say for\n * itself — `Mode set to \"build\"`, `Cloned to new session`, the plugin\n * catalogue, what `/learn` read. All of those used to write a dimmed row into\n * the conversation, and a session where someone ran five commands carried five\n * rows of dead receipts between the messages the transcript exists to keep.\n *\n * ## What does not\n *\n * Anything you might want to read later. A share URL, an export path, where\n * credentials were saved and every error still go to the transcript\n * (`InteractiveMode.showRecord`), which is the thing that scrolls back. The\n * rule is: if missing it costs you nothing, it belongs here; if missing it\n * costs you the information, it does not.\n */\n\nimport type { Component } from \"@kolisachint/hoocode-tui\";\nimport { applyBackgroundToLine, truncateToWidth, visibleWidth } from \"@kolisachint/hoocode-tui\";\nimport { type BlockFill, type ThemeColor, theme } from \"../theme/theme.js\";\n\n/**\n * How long each kind stays up.\n *\n * A glimpse confirms something the user just did on purpose, so it only has to\n * outlast the glance. A warning is telling them something they did not ask\n * about, and is usually longer, so it gets the time to be read.\n */\nexport const NOTIFICATION_TTL_MS = { info: 3000, warning: 8000 } as const;\n\nexport type NotificationKind = keyof typeof NOTIFICATION_TTL_MS;\n\n/**\n * Reading time a row of body earns on top of the headline's.\n *\n * The band carries listings now — the plugin catalogue, what `/learn` read,\n * where a canvas went — and a five-row listing timed like a one-line glimpse is\n * a listing nobody finished. The ceiling is there because the band is still\n * chrome: past half a minute it is not a notification, it is a pane that forgot\n * to close.\n */\nconst BODY_ROW_MS = 1200;\nconst MAX_TTL_MS = 30_000;\n\n/** The fill behind each kind. Both are block fills, so the band reads as paper. */\nconst FILL: Record<NotificationKind, BlockFill> = { info: \"customMessageBg\", warning: \"warningBg\" };\n\n/**\n * Rows of body the band will show under the headline, when nobody says.\n *\n * Bounded because this is chrome: a forty-line listing that grows the band\n * pushes the conversation off the screen to say something it is about to erase\n * anyway. What does not fit is dropped rather than scrolled — the band has no\n * way to be scrolled, and a message whose remainder cannot be reached reads as\n * a bug. An owner that knows the screen's height passes `maxBodyRows` and gets\n * a share of it instead of this floor.\n */\nconst MAX_BODY_ROWS = 3;\n\n/**\n * How many notifications may be waiting.\n *\n * A bound, not a design: warnings arriving faster than eight seconds apart for\n * longer than a minute is a malfunction, and the useful thing to do with it is\n * to stop growing rather than to show a queue nobody will sit through.\n */\nconst MAX_QUEUE = 8;\n\n/** Leads the headline; filled for a warning, hollow for a glimpse. */\nconst GLYPH = { info: \"◦\", warning: \"●\" } as const;\n\n/**\n * Colour a row, unless it coloured itself.\n *\n * A listing that paints its own columns arrives here already styled, and\n * `theme.fg` closes with `\\x1b[39m` — reset foreground, not *restore* — so\n * wrapping it would drop this colour at the first inner span and leave the rest\n * of the row unstyled. Passing a styled line through is the same rule\n * `styleStatusMessage` follows in the transcript, and the reason a listing\n * still reads in its own colours now that listings come through the band.\n */\nfunction ink(color: ThemeColor, text: string): string {\n\treturn text.includes(\"\\x1b[\") ? text : theme.fg(color, text);\n}\n\nexport interface Notification {\n\tkind: NotificationKind;\n\ttitle: string;\n\tbody: string[];\n\t/** Right-aligned afterword — the key that steps a dial back, usually. */\n\tnote?: string;\n\t/** Overrides the per-kind default. */\n\tttlMs?: number;\n\t/**\n\t * What this is a glimpse *of* — a dial's id, usually.\n\t *\n\t * Two notifications sharing a topic are two readings of one thing, so the\n\t * later one is simply the true one and takes the earlier one's place, on\n\t * screen or in the queue. Left unset, the queue rules below apply as they\n\t * always have.\n\t */\n\ttopic?: string;\n}\n\nexport class NotificationPanel implements Component {\n\t/** Shared across every empty frame: identity is all the render caches compare. */\n\tprivate static readonly EMPTY: string[] = Object.freeze([]) as unknown as string[];\n\n\t/** Head is on screen; the rest are waiting their turn. */\n\tprivate queue: Notification[] = [];\n\tprivate timer: NodeJS.Timeout | undefined;\n\t// Keyed on the row budget too: a terminal resized shorter changes what fits\n\t// without changing the width, and a cache that only watches the width would\n\t// go on drawing a band the screen no longer has room for.\n\tprivate cache?: { width: number; rows: number; lines: string[] };\n\n\t/**\n\t * @param requestRender - the band appears and disappears on its own clock,\n\t *   so it is the one piece of chrome that has to ask for frames rather than\n\t *   being drawn into one somebody else asked for.\n\t * @param maxBodyRows - how many rows of body the screen can spare right now.\n\t *   Asked per frame rather than held: the terminal is resized under a\n\t *   notification as readily as under anything else.\n\t */\n\tconstructor(\n\t\tprivate readonly requestRender: () => void,\n\t\tprivate readonly maxBodyRows: () => number = () => MAX_BODY_ROWS,\n\t) {}\n\n\t/** What is on the band right now, for tests and for the chrome checks. */\n\tget showing(): Notification | undefined {\n\t\treturn this.queue[0];\n\t}\n\n\t/** What is waiting behind it. */\n\tget pending(): readonly Notification[] {\n\t\treturn this.queue.slice(1);\n\t}\n\n\t/**\n\t * Put a notification up: a glimpse replaces, a warning queues (see above).\n\t */\n\tnotify(\n\t\tkind: NotificationKind,\n\t\ttitle: string,\n\t\tbody: string[] = [],\n\t\tnote?: string,\n\t\toptions: { ttlMs?: number; topic?: string } = {},\n\t): void {\n\t\tconst next: Notification = {\n\t\t\tkind,\n\t\t\ttitle,\n\t\t\tbody: body.filter((line) => line.length > 0),\n\t\t\tnote,\n\t\t\tttlMs: options.ttlMs,\n\t\t\ttopic: options.topic,\n\t\t};\n\t\t// Same topic, same subject: the new reading is the true one wherever the\n\t\t// old one is sitting, and if that is the head it gets the clock restarted\n\t\t// under it rather than waiting out a value the user has already changed.\n\t\tconst sameTopic = next.topic === undefined ? -1 : this.queue.findIndex((item) => item.topic === next.topic);\n\t\tconst tail = this.queue.length - 1;\n\t\tif (sameTopic >= 0) {\n\t\t\tthis.queue[sameTopic] = next;\n\t\t\tif (sameTopic === 0) this.arm(next);\n\t\t} else if (kind === \"info\" && tail > 0 && this.queue[tail].kind === \"info\") {\n\t\t\t// A topicless glimpse only replaces another glimpse that has not been\n\t\t\t// seen yet. Overwriting the *head* would cut short a message already on\n\t\t\t// screen, and a notification that can be erased before it is read is not\n\t\t\t// one.\n\t\t\tthis.queue[tail] = next;\n\t\t} else {\n\t\t\tif (this.queue.length >= MAX_QUEUE) this.queue.splice(1, 1);\n\t\t\tthis.queue.push(next);\n\t\t\tif (this.queue.length === 1) this.arm(next);\n\t\t}\n\t\tthis.cache = undefined;\n\t\tthis.requestRender();\n\t}\n\n\t/** Take the band down now, queue and all; safe when nothing is up. */\n\tdismiss(): void {\n\t\tthis.clearTimer();\n\t\tif (this.queue.length === 0) return;\n\t\tthis.queue = [];\n\t\tthis.cache = undefined;\n\t\tthis.requestRender();\n\t}\n\n\t/** Drop the pending fade without repainting — for teardown. */\n\tstop(): void {\n\t\tthis.clearTimer();\n\t}\n\n\tinvalidate(): void {\n\t\t// Styled at render time, so a theme swap has to rebuild the cached rows.\n\t\tthis.cache = undefined;\n\t}\n\n\t/**\n\t * The band, painted.\n\t *\n\t * It carries everything a command has to say now — a mode landing, a plugin\n\t * installed, a listing — so it has to read at a glance against a transcript\n\t * that is already full of text. A fill is what does that: the same block\n\t * fill a message sheet takes, so the band is recognisably the app's own\n\t * paper rather than one more line of output, and the eye finds its edges\n\t * without anything having to be drawn around it.\n\t *\n\t * Every painted row runs the full width — a fill that stops short of the\n\t * margin is a band with a bite out of it — and the one column of lead-in is\n\t * what keeps the glyph off the screen's edge.\n\t */\n\trender(width: number): string[] {\n\t\tconst current = this.queue[0];\n\t\tif (!current || width < 4) return NotificationPanel.EMPTY;\n\t\tconst rows = this.budget();\n\t\tconst cached = this.cache;\n\t\tif (cached && cached.width === width && cached.rows === rows) return cached.lines;\n\n\t\tconst fill = FILL[current.kind];\n\t\tconst paint = (line: string): string => applyBackgroundToLine(line, width, (text) => theme.bg(fill, text));\n\t\tconst glyph = theme.fg(current.kind === \"warning\" ? \"warning\" : \"muted\", GLYPH[current.kind]);\n\t\t// The leading blank is this block's separator from whatever is above it.\n\t\t// The band never pads its own bottom edge: the next row is the prompt's\n\t\t// own border, and a blank line next to a rule is a blank line wasted (see\n\t\t// \"Vertical rhythm\" in docs/ui-map.md).\n\t\t// One column of page either side of the text: the fill runs edge to edge,\n\t\t// what is written on it does not, and a note flush against the screen's\n\t\t// last cell reads as text that ran out of room.\n\t\tconst lines = [\"\", paint(` ${glyph} ${this.headline(current, width - 4)}`)];\n\t\tfor (const line of this.bodyRows(current)) {\n\t\t\tlines.push(paint(`   ${ink(\"muted\", truncateToWidth(line, Math.max(1, width - 4)))}`));\n\t\t}\n\t\tthis.cache = { width, rows, lines };\n\t\treturn lines;\n\t}\n\n\t/** The body rows that fit, at whatever the screen can spare this frame. */\n\tprivate bodyRows(current: Notification): string[] {\n\t\treturn current.body.slice(0, this.budget());\n\t}\n\n\t/** Rows of body this frame can spare — never none, so a body always shows. */\n\tprivate budget(): number {\n\t\treturn Math.max(1, Math.floor(this.maxBodyRows()));\n\t}\n\n\t/**\n\t * Title on the left, note flush right, the note dropped before the title is.\n\t *\n\t * The note is a hint about a key; the title is the thing that happened. On a\n\t * terminal too narrow for both, losing the hint costs nothing and losing the\n\t * headline costs the whole notification.\n\t */\n\tprivate headline(current: Notification, width: number): string {\n\t\tconst color = current.kind === \"warning\" ? \"warning\" : \"text\";\n\t\tconst note = current.note;\n\t\tif (note) {\n\t\t\tconst gap = width - visibleWidth(current.title) - visibleWidth(note) - 3;\n\t\t\tif (gap >= 0) {\n\t\t\t\treturn ink(color, current.title) + \" \".repeat(gap + 3) + theme.fg(\"halftone\", note);\n\t\t\t}\n\t\t}\n\t\treturn ink(color, truncateToWidth(current.title, Math.max(1, width)));\n\t}\n\n\t/**\n\t * How long this one stays up: the headline's time plus the body's.\n\t *\n\t * An explicit `ttlMs` still wins — a caller that knows its message is read\n\t * at a glance, or not at all, is a better judge than this arithmetic.\n\t */\n\tprivate ttl(current: Notification): number {\n\t\tif (current.ttlMs !== undefined) return current.ttlMs;\n\t\tconst base = NOTIFICATION_TTL_MS[current.kind];\n\t\treturn Math.min(MAX_TTL_MS, base + this.bodyRows(current).length * BODY_ROW_MS);\n\t}\n\n\tprivate arm(current: Notification): void {\n\t\tthis.clearTimer();\n\t\tconst timer = setTimeout(() => {\n\t\t\tthis.timer = undefined;\n\t\t\tthis.queue.shift();\n\t\t\tthis.cache = undefined;\n\t\t\tconst next = this.queue[0];\n\t\t\tif (next) this.arm(next);\n\t\t\tthis.requestRender();\n\t\t}, this.ttl(current));\n\t\t// A band waiting to fade must never be the reason the process is still up.\n\t\ttimer.unref?.();\n\t\tthis.timer = timer;\n\t}\n\n\tprivate clearTimer(): void {\n\t\tif (this.timer) clearTimeout(this.timer);\n\t\tthis.timer = undefined;\n\t}\n}\n"]}