{"version":3,"file":"tips-controller.d.ts","sourceRoot":"","sources":["../../../src/modes/interactive/tips-controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,OAAO,KAAK,EAAE,GAAG,EAAa,MAAM,WAAW,CAAC;AAChD,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAExC,iEAAiE;AACjE,eAAO,MAAM,qBAAqB,QAAS,CAAC;AAE5C;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,QAAS,CAAC;AAEjD,oEAAoE;AACpE,eAAO,MAAM,mBAAmB,SAAU,CAAC;AAE3C,uFAAuF;AACvF,eAAO,MAAM,gBAAgB,QAAS,CAAC;AAEvC,MAAM,WAAW,qBAAqB;IACrC,sFAAsF;IACtF,SAAS,EAAE,MAAM,OAAO,CAAC;IACzB,4EAA4E;IAC5E,UAAU,EAAE,MAAM,OAAO,CAAC;IAC1B,+BAA+B;IAC/B,IAAI,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAC;IACzB,QAAQ,EAAE,WAAW,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,mCAAmC;IACnC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,2CAA2C;IAC3C,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC;IACnD,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;CACvC;AAED,qBAAa,cAAc;IAC1B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAQnB;IAEF,OAAO,CAAC,SAAS,CAAU;IAC3B,OAAO,CAAC,WAAW,CAAU;IAC7B,OAAO,CAAC,SAAS,CAAqB;IACtC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,OAAO,CAAS;IAExB,YAAY,OAAO,EAAE,qBAAqB,EAuBzC;IAED;;;;;OAKG;IACH,UAAU,IAAI,IAAI,CAEjB;IAED,4EAA4E;IAC5E,WAAW,IAAI,IAAI,CAGlB;IAED,8DAA8D;IAC9D,SAAS,IAAI,IAAI,CAGhB;IAED,6CAA6C;IAC7C,IAAI,IAAI,IAAI,CAIX;IAED,OAAO,CAAC,OAAO;IASf,OAAO,CAAC,YAAY;IASpB,OAAO,CAAC,SAAS;IAKjB,OAAO,CAAC,cAAc;IAKtB;;;;;;;OAOG;IACH,OAAO,CAAC,KAAK;CAeb;AAED,OAAO,EAAE,WAAW,EAAE,CAAC","sourcesContent":["/**\n * When a tip is allowed to appear.\n *\n * `tips.ts` decides *what* to say. This decides *when*, and almost all of it is\n * about the times it must stay quiet. A tip is the lowest-priority thing on the\n * screen: it is worth showing only because the moment it uses was going to be\n * spent anyway, which means the instant that stops being true it must not show\n * at all.\n *\n * ## The two moments\n *\n * **Idle.** The prompt is up, nothing is streaming, and the user has not pressed\n * a key for {@link DEFAULT_IDLE_DELAY_MS}. They are reading the last reply, or\n * thinking, or have walked away. A tip here is read or it is not; either way it\n * cost nothing.\n *\n * **Streaming.** A turn has been running for {@link DEFAULT_STREAMING_DELAY_MS}\n * and the user is watching a spinner. This is the better of the two moments,\n * because the time is definitely being spent and the eye is definitely on the\n * bottom of the screen.\n *\n * ## The times it stays quiet\n *\n * - Whenever the band already has something on it. A tip must never delay,\n *   replace, or queue behind a notification the user actually caused — it is\n *   dropped, and the next timer comes round soon enough.\n * - Inside the cooldown after any tip. Two tips in quick succession reads as a\n *   thing talking at you rather than a thing helping you.\n * - For the whole first {@link DEFAULT_GRACE_MS} of a session. Startup already\n *   has a banner, a changelog and possibly warnings; adding a tip to that is\n *   noise on top of noise.\n * - Whenever the user has turned tips off.\n *\n * ## Timers\n *\n * Every timer is `unref`'d. A pending tip must never be the reason the process\n * is still alive — a one-shot `hoocode -p` run that happens to take 30 seconds\n * should exit the moment its work is done, not when a tip timer fires.\n */\n\nimport type { Tip, TipMoment } from \"./tips.js\";\nimport { TipRotation } from \"./tips.js\";\n\n/** Keyboard-quiet time at the prompt before a tip is offered. */\nexport const DEFAULT_IDLE_DELAY_MS = 45_000;\n\n/**\n * Turn duration before a tip is offered mid-stream.\n *\n * Shorter than the idle delay on purpose: a user watching a spinner is already\n * waiting, whereas an idle user may be mid-thought and is more easily\n * interrupted.\n */\nexport const DEFAULT_STREAMING_DELAY_MS = 20_000;\n\n/** Minimum gap between two tips, whatever moment they come from. */\nexport const DEFAULT_COOLDOWN_MS = 180_000;\n\n/** Quiet period after startup, while the banner and changelog are still being read. */\nexport const DEFAULT_GRACE_MS = 60_000;\n\nexport interface TipsControllerOptions {\n\t/** Read fresh every time, so turning tips off in `/settings` takes effect at once. */\n\tisEnabled: () => boolean;\n\t/** True when the notification band has nothing on it and nothing queued. */\n\tbandIsFree: () => boolean;\n\t/** Put the tip on the band. */\n\tshow: (tip: Tip) => void;\n\trotation: TipRotation;\n\tidleDelayMs?: number;\n\tstreamingDelayMs?: number;\n\tcooldownMs?: number;\n\tgraceMs?: number;\n\t/** Clock, injectable for tests. */\n\tnow?: () => number;\n\t/** Timer factory, injectable for tests. */\n\tsetTimer?: (fn: () => void, ms: number) => unknown;\n\tclearTimer?: (handle: unknown) => void;\n}\n\nexport class TipsController {\n\tprivate readonly opts: Required<Pick<TipsControllerOptions, \"isEnabled\" | \"bandIsFree\" | \"show\" | \"rotation\">> & {\n\t\tidleDelayMs: number;\n\t\tstreamingDelayMs: number;\n\t\tcooldownMs: number;\n\t\tgraceMs: number;\n\t\tnow: () => number;\n\t\tsetTimer: (fn: () => void, ms: number) => unknown;\n\t\tclearTimer: (handle: unknown) => void;\n\t};\n\n\tprivate idleTimer: unknown;\n\tprivate streamTimer: unknown;\n\tprivate lastTipAt: number | undefined;\n\tprivate readonly startedAt: number;\n\tprivate stopped = false;\n\n\tconstructor(options: TipsControllerOptions) {\n\t\tconst setTimer =\n\t\t\toptions.setTimer ??\n\t\t\t((fn: () => void, ms: number) => {\n\t\t\t\tconst handle = setTimeout(fn, ms);\n\t\t\t\t// A pending tip must never hold the process open.\n\t\t\t\thandle.unref?.();\n\t\t\t\treturn handle;\n\t\t\t});\n\t\tthis.opts = {\n\t\t\tisEnabled: options.isEnabled,\n\t\t\tbandIsFree: options.bandIsFree,\n\t\t\tshow: options.show,\n\t\t\trotation: options.rotation,\n\t\t\tidleDelayMs: options.idleDelayMs ?? DEFAULT_IDLE_DELAY_MS,\n\t\t\tstreamingDelayMs: options.streamingDelayMs ?? DEFAULT_STREAMING_DELAY_MS,\n\t\t\tcooldownMs: options.cooldownMs ?? DEFAULT_COOLDOWN_MS,\n\t\t\tgraceMs: options.graceMs ?? DEFAULT_GRACE_MS,\n\t\t\tnow: options.now ?? Date.now,\n\t\t\tsetTimer,\n\t\t\tclearTimer: options.clearTimer ?? ((handle) => clearTimeout(handle as NodeJS.Timeout)),\n\t\t};\n\t\tthis.startedAt = this.opts.now();\n\t}\n\n\t/**\n\t * The user did something — a key, a submit, a command.\n\t *\n\t * Restarts the idle clock. This is deliberately cheap and called from the\n\t * input path, so it does nothing but reset a timer.\n\t */\n\tonActivity(): void {\n\t\tthis.armIdle();\n\t}\n\n\t/** A turn started: the idle moment is over and the streaming one begins. */\n\tonTurnStart(): void {\n\t\tthis.clearIdle();\n\t\tthis.armStreaming();\n\t}\n\n\t/** A turn ended: back to waiting for the user to go quiet. */\n\tonTurnEnd(): void {\n\t\tthis.clearStreaming();\n\t\tthis.armIdle();\n\t}\n\n\t/** Teardown. Safe to call more than once. */\n\tstop(): void {\n\t\tthis.stopped = true;\n\t\tthis.clearIdle();\n\t\tthis.clearStreaming();\n\t}\n\n\tprivate armIdle(): void {\n\t\tthis.clearIdle();\n\t\tif (this.stopped) return;\n\t\tthis.idleTimer = this.opts.setTimer(() => {\n\t\t\tthis.idleTimer = undefined;\n\t\t\tthis.offer(\"idle\");\n\t\t}, this.opts.idleDelayMs);\n\t}\n\n\tprivate armStreaming(): void {\n\t\tthis.clearStreaming();\n\t\tif (this.stopped) return;\n\t\tthis.streamTimer = this.opts.setTimer(() => {\n\t\t\tthis.streamTimer = undefined;\n\t\t\tthis.offer(\"streaming\");\n\t\t}, this.opts.streamingDelayMs);\n\t}\n\n\tprivate clearIdle(): void {\n\t\tif (this.idleTimer !== undefined) this.opts.clearTimer(this.idleTimer);\n\t\tthis.idleTimer = undefined;\n\t}\n\n\tprivate clearStreaming(): void {\n\t\tif (this.streamTimer !== undefined) this.opts.clearTimer(this.streamTimer);\n\t\tthis.streamTimer = undefined;\n\t}\n\n\t/**\n\t * Show a tip, if every reason not to has been ruled out.\n\t *\n\t * A refused offer is not rescheduled here: the moment that produced it is\n\t * over, and the next one will arm its own timer. Retrying would turn \"the\n\t * band is busy\" into a tip that pounces the instant the user's own\n\t * notification fades, which is precisely the interruption this avoids.\n\t */\n\tprivate offer(moment: TipMoment): void {\n\t\tif (this.stopped) return;\n\t\tif (!this.opts.isEnabled()) return;\n\n\t\tconst now = this.opts.now();\n\t\tif (now - this.startedAt < this.opts.graceMs) return;\n\t\tif (this.lastTipAt !== undefined && now - this.lastTipAt < this.opts.cooldownMs) return;\n\t\tif (!this.opts.bandIsFree()) return;\n\n\t\tconst tip = this.opts.rotation.next(moment);\n\t\tif (!tip) return;\n\n\t\tthis.lastTipAt = now;\n\t\tthis.opts.show(tip);\n\t}\n}\n\nexport { TipRotation };\n"]}