kronk-cli 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -4,7 +4,8 @@ import { homedir } from 'node:os';
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { config, DEFAULT_MODEL, applyLimits, warnIfInsecure } from './config.js';
6
6
  import { listModels, listModelDetails, listLoaded, modelLimits, tokenize } from './client.js';
7
- import { pickDefault, ensureLoaded } from './boot.js';
7
+ import { pickDefault, chatModels, ensureLoaded } from './boot.js';
8
+ import { resolveModel } from './resolve.js';
8
9
  import { runTurn, SYSTEM, SYSTEM_AUTO } from './agent.js';
9
10
  import { c, banner, fmtContext, statusLine } from './ui.js';
10
11
  import { resolveTheme, theme, useTheme } from './theme.js';
@@ -15,9 +16,12 @@ import { loadServers, McpHub, reportFailures } from './mcp.js';
15
16
  import { resolveSandbox, sandbox } from './tools.js';
16
17
  import { parseArgv } from './argv.js';
17
18
  import { deferredPrompt } from './prompt.js';
18
- import { AGENTS } from './subagent.js';
19
+ import { AGENTS, setServedModels } from './subagent.js';
19
20
  import { runLines, transcriptLines, sweep, cleanup } from './tasklog.js';
20
- import { runSetup } from './setup.js';
21
+ import { runSetup, runKronk } from './setup.js';
22
+ import { runRescue } from './rescue.js';
23
+ import { VERSION, parseKronkVersion } from './version.js';
24
+ import { updateStatus } from './update.js';
21
25
 
22
26
  // ---- argv -------------------------------------------------------------
23
27
  const args = parseArgv(process.argv.slice(2));
@@ -41,6 +45,22 @@ if (args.help) {
41
45
  pull the model, write its /AGENT profile to
42
46
  ~/.kronk/models/model_config.yaml, restart Kronk
43
47
 
48
+ kronk-cli rescue "<prompt>" [--role explore|code] [--model <id>] [-y|-a] [--steps <n>] [--json]
49
+ submit a self-contained task; runs detached,
50
+ prints a job id and exits immediately
51
+ kronk-cli rescue status <id> [--json]
52
+ queued | running (step n) | done | stopped
53
+ | interrupted | failed
54
+ kronk-cli rescue wait <id> [--json]
55
+ block until the job lands, then print its
56
+ report; exit code is done 0, stopped 1,
57
+ interrupted 2, failed 3
58
+ kronk-cli rescue result <id> [--json]
59
+ print the report; refuses (exit 4) if the
60
+ job has not reached one of those four states yet
61
+ without -y/-a, a rescue job refuses any tool
62
+ that needs approval rather than asking nobody
63
+
44
64
  OPTIONS
45
65
  -l, --models list the models Kronk is serving, then exit
46
66
  --no-context skip the startup scan of the working directory
@@ -56,6 +76,8 @@ if (args.help) {
56
76
  --no-subagents remove the task tool; no delegation
57
77
  --steps <n> cap tool calls per task (default: unlimited)
58
78
  -h, --help this message
79
+ -v, --version print the kronk-cli version, and Kronk's if it can be
80
+ had without a network call, then exit
59
81
  -- end option parsing; everything after is the prompt
60
82
 
61
83
  ENVIRONMENT
@@ -77,15 +99,37 @@ if (args.help) {
77
99
  KRONK_COMPACT_AT fraction of the window that triggers it (default 0.85)
78
100
  KRONK_SUBAGENTS false to remove the task tool
79
101
  KRONK_SUBAGENT_MODEL
80
- model sub-agents run on (default: the main model)
102
+ default model for a task that names none itself
103
+ (default: the main model) — a task can pick its own
104
+ from what Kronk is serving, see /agents
81
105
  KRONK_SUBAGENT_STEPS
82
106
  tool-call cap for one delegated task (default 40)
107
+ KRONK_UPDATE_CHECK false to never ask the package manager whether a
108
+ newer kronk-cli exists (default true)
83
109
 
84
110
  Config file: ~/.kronk-cli.json
85
111
  `);
86
112
  process.exit(0);
87
113
  }
88
114
 
115
+ if (args.version) {
116
+ // package.json's version, not a network call, not the server — this must
117
+ // print with no Kronk running, which is the common case for `--version`.
118
+ console.log(`kronk-cli ${VERSION}`);
119
+ // A cheap, short-timeout, local-only ask: no server round trip, and never
120
+ // held open past the timeout if `kronk` itself hangs. Kronk's own API
121
+ // exposes no version field anywhere client.js already reads (checked
122
+ // before writing this), so the binary on disk is the only source there is.
123
+ try {
124
+ const { code, output } = await runKronk(['--version'], { timeoutMs: 3000 });
125
+ const v = code === 0 ? parseKronkVersion(output) : null;
126
+ if (v) console.log(`kronk ${v}`);
127
+ } catch {
128
+ // No `kronk` on PATH, or it did not behave — say nothing rather than guess.
129
+ }
130
+ process.exit(0);
131
+ }
132
+
89
133
  // --auto: run the whole task unattended (implies --yes)
90
134
  const AUTO = args.auto;
91
135
  const AUTO_YES = args.yes || AUTO;
@@ -124,15 +168,12 @@ async function boot() {
124
168
  // Fall back to the configured default before guessing.
125
169
  if (!config.model && ids.includes(DEFAULT_MODEL)) config.model = DEFAULT_MODEL;
126
170
 
171
+ // Exact id wins. Otherwise take a substring match, preferring an /AGENT
172
+ // profile — each profile is a SEPARATE resident copy in the pool, so
173
+ // picking the wrong one silently loads a second 20 GB instance. One
174
+ // resolver (src/resolve.js) for this and for the identical problem below.
127
175
  if (config.model) {
128
- // exact id wins; otherwise accept a unique-enough substring
129
- // Exact id wins. Otherwise take a substring match, preferring an /AGENT
130
- // profile — each profile is a SEPARATE resident copy in the pool, so
131
- // picking the wrong one silently loads a second 20 GB instance.
132
- const subs = ids.filter((id) => id.includes(config.model));
133
- const hit = ids.find((id) => id === config.model)
134
- ?? subs.find((id) => id.endsWith('/AGENT'))
135
- ?? subs[0];
176
+ const hit = resolveModel(config.model, ids);
136
177
  if (hit) config.model = hit;
137
178
  else {
138
179
  console.error(c.yellow(` no model matching "${config.model}" — falling back`));
@@ -145,10 +186,7 @@ async function boot() {
145
186
  // model: a substring in the config file that matches nothing would otherwise
146
187
  // only surface as a 404 in the middle of somebody's first delegated task.
147
188
  if (config.subagentModel) {
148
- const subs = ids.filter((id) => id.includes(config.subagentModel));
149
- const hit = ids.find((id) => id === config.subagentModel)
150
- ?? subs.find((id) => id.endsWith('/AGENT'))
151
- ?? subs[0];
189
+ const hit = resolveModel(config.subagentModel, ids);
152
190
  if (hit) config.subagentModel = hit;
153
191
  else {
154
192
  console.error(c.yellow(` no model matching "${config.subagentModel}" for sub-agents`
@@ -157,6 +195,14 @@ async function boot() {
157
195
  }
158
196
  }
159
197
 
198
+ // What a delegated task's own `model` argument may name — see the `task`
199
+ // tool in src/subagent.js. `chatModels` is a wider list than the one
200
+ // `pickDefault` above chose the session's model from, and deliberately so:
201
+ // picking one default prefers a tuned /AGENT profile outright, while
202
+ // offering a menu must keep the models that have no profile, because the
203
+ // small fast one a survey should be sent to is usually one of them.
204
+ setServedModels(chatModels(ids));
205
+
160
206
  if (config.warm) await ensureLoaded(ids);
161
207
 
162
208
  // After `ensureLoaded`, which may have fallen back to a different model
@@ -164,11 +210,10 @@ async function boot() {
164
210
  // describe a model this session is not using.
165
211
  applyLimits(await modelLimits(config.model));
166
212
 
167
- // The sub-agent model's profile is its own. Resolved once here rather than
168
- // on the first delegation, where the wait would land mid-task.
169
- config.subagentLimits = config.subagentModel
170
- ? await modelLimits(config.subagentModel)
171
- : null;
213
+ // The sub-agent model's own profile is no longer resolved here: a task can
214
+ // run on any served model now, not only the one configured for delegation,
215
+ // so src/subagent.js resolves and caches each one's limits the first time a
216
+ // task actually runs on it — see `limitsFor` there.
172
217
  return ids;
173
218
  }
174
219
 
@@ -416,6 +461,13 @@ async function main() {
416
461
  return;
417
462
  }
418
463
 
464
+ // Same reasoning as `setup` just above: reserved before the one-shot path
465
+ // gets a chance to read the rest of the line as a prompt.
466
+ if (args.words[0] === 'rescue') {
467
+ process.exitCode = await runRescue(args);
468
+ return;
469
+ }
470
+
419
471
  if (SHOW_MODELS) { await showModels(); return; }
420
472
  if (SHOW_MCP) { await showMcp(); process.exit(0); }
421
473
 
@@ -456,9 +508,14 @@ async function main() {
456
508
  },
457
509
  });
458
510
 
511
+ // Synchronous and cheap — a cache-file read, never a subprocess on this
512
+ // path — so it costs nothing to call before `boot()`'s network round trips.
513
+ // See src/update.js for why this never waits on a package manager.
514
+ const update = updateStatus();
515
+
459
516
  await boot();
460
517
  const { content, ctx } = await systemMessage(AUTO);
461
- console.log(banner(config.model, config.baseUrl));
518
+ console.log(banner(config.model, config.baseUrl, update));
462
519
 
463
520
  // Information, not a failure: the profile is doing exactly what it was
464
521
  // told to, it's just not what the model's own GGUF recommends. One-shot
@@ -580,8 +637,9 @@ async function main() {
580
637
  console.log(` ${c.green('●')} ${c.bold(name)} ${c.grey(`· ${a.tools.join(', ')}`)}`);
581
638
  console.log(c.grey(` for ${a.use}`));
582
639
  }
583
- console.log(c.grey(` model: ${config.subagentModel ?? config.model} · `
584
- + `cap ${config.subagentSteps} steps per task\n`));
640
+ console.log(c.grey(` default model: ${config.subagentModel ?? config.model} · `
641
+ + `cap ${config.subagentSteps} steps per task`));
642
+ console.log(c.grey(' a task can name a different served model itself, when the default is the wrong size for it\n'));
585
643
  continue;
586
644
  }
587
645
  if (input === '/theme' || input.startsWith('/theme ')) {
@@ -635,12 +693,13 @@ async function main() {
635
693
  if (input.startsWith('/model ')) {
636
694
  const want = input.slice(7).trim();
637
695
  const all = await listModels();
638
- const subs = all.filter((m) => m.includes(want));
639
- const hit = all.find((m) => m === want)
640
- ?? subs.find((m) => m.endsWith('/AGENT'))
641
- ?? subs[0];
696
+ const hit = resolveModel(want, all);
642
697
  if (!hit) { console.log(c.red(` no model matching "${want}"`)); continue; }
643
698
  await switchModel(hit);
699
+ // Kronk may be serving something new since boot — a model pulled or
700
+ // unloaded mid-session — so a task's own `model` enum is refreshed from
701
+ // the same list this command just asked for, not left stale from boot.
702
+ setServedModels(chatModels(all));
644
703
  console.log(c.green(` switched to ${hit}`));
645
704
  console.log(c.grey(` window ${config.contextWindow?.toLocaleString() ?? 'unknown'}`
646
705
  + ` · output cap ${config.maxTokens.toLocaleString()}`
package/src/mcp.js CHANGED
@@ -3,9 +3,12 @@ import { readFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { c } from './ui.js';
6
+ import { VERSION } from './version.js';
6
7
 
7
8
  const PROTOCOL = '2025-06-18';
8
- const CLIENT = { name: 'kronk-cli', version: '0.1.0' };
9
+ // Six releases carried '0.1.0' here regardless of what package.json actually
10
+ // said, because nothing read the real number. VERSION is that number.
11
+ const CLIENT = { name: 'kronk-cli', version: VERSION };
9
12
  const CALL_TIMEOUT = 120_000;
10
13
  const START_TIMEOUT = 20_000;
11
14
 
package/src/pane.js ADDED
@@ -0,0 +1,276 @@
1
+ import readline from 'node:readline';
2
+ import { c, elapsed } from './ui.js';
3
+
4
+ /**
5
+ * The transient pane a delegated run draws in while it is running.
6
+ *
7
+ * A sub-agent's tool calls used to go straight into the conversation, nested
8
+ * under ` │`, and stayed there. That is the wrong trade in both directions.
9
+ * While the run is going a live view is the only sign the thing is alive, and
10
+ * it is where you notice a sub-agent thrashing; once it has finished, almost
11
+ * none of it is worth keeping, because the question you asked and the report
12
+ * you got are now forty lines apart. The window never paid for any of it —
13
+ * that is the point of delegating — but the screen paid for all of it.
14
+ *
15
+ * So: draw it in a box that redraws in place, and erase the box when the run
16
+ * ends. Nothing is lost by erasing it. Every line the pane shows was already
17
+ * written to the run's transcript as it happened (src/tasklog.js), so the pane
18
+ * is a view of a file that outlives it, and `/tasks <n>` is how you get it
19
+ * back. That is what makes discarding the pane safe.
20
+ *
21
+ * Scrollback, not the alternate screen. A full takeover would be easier to
22
+ * draw and worse to live with, because it hides the conversation you are in
23
+ * the middle of. A fixed block redrawn in place keeps the history above it.
24
+ */
25
+
26
+ /** Rows of sub-agent activity kept on screen. The box is bounded; the file is not. */
27
+ const BODY_ROWS = 6;
28
+
29
+ const MIN_WIDTH = 40;
30
+ const MAX_WIDTH = 100;
31
+
32
+ // eslint-disable-next-line no-control-regex -- matching the escapes is the point
33
+ const SGR = /\x1b\[[0-9;]*m/g;
34
+
35
+ /** How wide a string prints, once its colour escapes are discounted. */
36
+ export const visibleWidth = (s) => String(s).replace(SGR, '').length;
37
+
38
+ /** The box's outer width for a terminal this wide, clamped both ways. */
39
+ export const paneWidth = (columns) => Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (columns || 80) - 2));
40
+
41
+ /**
42
+ * Cut a coloured string to `width` printed characters.
43
+ *
44
+ * Escapes are copied through without being counted — cutting in the middle of
45
+ * one would leave the rest of the terminal painted in whatever colour the
46
+ * fragment happened to open. The trailing reset is only added when something
47
+ * was actually opened, so a no-colour terminal still gets no escapes at all.
48
+ */
49
+ export function clip(line, width) {
50
+ const s = String(line);
51
+ if (visibleWidth(s) <= width) return s;
52
+ if (width <= 0) return '';
53
+ let out = '';
54
+ let seen = 0;
55
+ let i = 0;
56
+ while (i < s.length && seen < width - 1) {
57
+ if (s[i] === '\x1b') {
58
+ SGR.lastIndex = i;
59
+ const m = SGR.exec(s);
60
+ if (m && m.index === i) { out += m[0]; i += m[0].length; continue; }
61
+ }
62
+ out += s[i];
63
+ i += 1;
64
+ seen += 1;
65
+ }
66
+ return `${out}…${out.includes('\x1b') ? '\x1b[0m' : ''}`;
67
+ }
68
+
69
+ /** Right-pad to `width` printed characters, so every row ends on the same column. */
70
+ export const pad = (line, width) => `${line}${' '.repeat(Math.max(0, width - visibleWidth(line)))}`;
71
+
72
+ const rule = (n) => '─'.repeat(Math.max(1, n));
73
+
74
+ /**
75
+ * One frame of the box, as lines — pure, so the geometry is testable without a
76
+ * terminal. Every line it returns is exactly `width` printed characters wide;
77
+ * `test/pane.test.js` asserts that, because an off-by-one in the arithmetic
78
+ * below is invisible in a diff and unmissable on screen.
79
+ */
80
+ export function renderPane({ agent, rows = [], status = '', hint = '', width = 60 }) {
81
+ const inner = Math.max(4, width - 4);
82
+ // Printed width, not string length: the status and the hint arrive coloured,
83
+ // and counting their escapes would pull the right-hand corner off the edge.
84
+ const w = (s) => (s ? visibleWidth(s) + 1 : 0);
85
+
86
+ // `┌─ agent ──…── 6/40 · 48s ─┐`
87
+ const headFixed = 3 + w(agent) + 1 + w(status) + 2;
88
+ const top = c.grey('┌─ ') + c.bold(agent) + c.grey(` ${rule(width - headFixed)} ${status ? `${status} ` : ''}─┐`);
89
+
90
+ // `└─ esc to collapse · ctrl-c to stop ───┘`
91
+ const footFixed = 3 + w(hint) + 2;
92
+ const bottom = c.grey(`└─ ${hint ? `${hint} ` : ''}${rule(width - footFixed)}─┘`);
93
+
94
+ const body = rows.map((r) => `${c.grey('│')} ${pad(clip(r, inner), inner)} ${c.grey('│')}`);
95
+ return [top, ...body, bottom];
96
+ }
97
+
98
+ /**
99
+ * Watch for a bare `esc` while the pane is up.
100
+ *
101
+ * Deliberately not `setRawMode`. The REPL's readline already owns stdin and
102
+ * already holds it in raw mode, and it is still reading during a turn — that
103
+ * is how typing ahead survives (see src/prompt.js). Taking the mode away from
104
+ * it and handing back what we swallowed is the kind of thing that eats a
105
+ * user's typing when it goes wrong, and there is no need: readline has already
106
+ * made stdin emit `keypress`, so a listener is purely additive. Ctrl-C keeps
107
+ * working because we never intercepted it.
108
+ *
109
+ * Armed only when stdin is *already* raw, which is exactly "a readline is
110
+ * open and reading". In one-shot and `--auto` runs it is not, nobody is
111
+ * watching, and the footer does not offer a key that would do nothing.
112
+ */
113
+ export function watchEsc({ input = process.stdin, onEsc } = {}) {
114
+ if (!onEsc || !input?.isTTY || !input.isRaw) return null;
115
+ // Idempotent in Node: it returns early if the stream already has a decoder.
116
+ readline.emitKeypressEvents(input);
117
+ // `meta` is true on a bare esc and false on `esc [ A`, because node cannot
118
+ // know which it has until the next byte arrives or `escapeCodeTimeout`
119
+ // (500 ms) expires — so this is the key, and an arrow key is not. That half
120
+ // second of lag is invisible next to a run that takes tens of seconds.
121
+ const onKey = (_s, key) => { if (key?.name === 'escape' && !key.ctrl) onEsc(); };
122
+ input.on('keypress', onKey);
123
+ return () => input.off('keypress', onKey);
124
+ }
125
+
126
+ /**
127
+ * Whether to draw at all.
128
+ *
129
+ * `node src/index.js "hello" | grep` and every CI run land here with a pipe,
130
+ * and a pipe must get exactly what it got before the pane existed: the nested
131
+ * lines, in order, with no cursor control in them. A dumb terminal cannot move
132
+ * a cursor either, so it takes the same path.
133
+ */
134
+ export const paneUsable = (stream = process.stdout, env = process.env) =>
135
+ Boolean(stream?.isTTY) && env.TERM !== 'dumb';
136
+
137
+ /** The inert pane: every call a no-op, so the caller needs no branch of its own. */
138
+ const OFF = {
139
+ enabled: false,
140
+ open() {}, line() {}, step() {}, close() {}, collapse() {},
141
+ shield: (fn) => fn,
142
+ };
143
+
144
+ /**
145
+ * The live pane. `open` draws it, `line` feeds it, `close` erases it.
146
+ *
147
+ * `shield(fn)` is how the approval prompt stays readable: it wipes the box
148
+ * before `fn` runs and redraws it after. An approval prompt has to be exactly
149
+ * as visible and as blocking as it is without a pane, so it gets the terminal
150
+ * to itself rather than competing with a redraw every 250 ms.
151
+ */
152
+ export function livePane({
153
+ agent = 'agent', model, maxSteps, rows: keep = BODY_ROWS, signal,
154
+ stream = process.stdout, input = process.stdin, env = process.env,
155
+ now = Date.now, tickMs = 250,
156
+ } = {}) {
157
+ if (!paneUsable(stream, env)) return OFF;
158
+
159
+ // The label carries the model too, the same way the opening line above a
160
+ // pipe does (src/subagent.js's `openLine`) — a task on a model that had to
161
+ // be loaded cold is exactly the run a person watches the box wondering why
162
+ // it is slow, and the header is where that question gets answered. The last
163
+ // two path segments, not one: a served id's last segment alone (`AGENT`,
164
+ // `base`) is shared by every profile of every model and says nothing.
165
+ const label = model ? `${agent} · ${String(model).split('/').slice(-2).join('/')}` : agent;
166
+
167
+ const started = now();
168
+ const fed = [];
169
+ let steps = 0;
170
+ let height = 0; // rows the box currently occupies on screen
171
+ let collapsed = false;
172
+ let timer = null;
173
+ let unwatch = null;
174
+ let unabort = null;
175
+
176
+ const status = () => {
177
+ const at = Number.isFinite(maxSteps) ? `${steps}/${maxSteps}` : String(steps);
178
+ return c.grey(`${at} · ${elapsed(now() - started)}`);
179
+ };
180
+
181
+ const paint = () => {
182
+ if (collapsed) return;
183
+ const width = paneWidth(stream.columns);
184
+ const hint = c.grey(unwatch ? 'esc to collapse · ctrl-c to stop' : 'ctrl-c to stop');
185
+ const frame = renderPane({ agent: label, rows: fed, status: status(), hint, width });
186
+ // Up to the top of the old box, then repaint row by row. `\x1b[K` on each
187
+ // row so a shorter line does not leave the tail of a longer one behind.
188
+ let buf = height ? `\r\x1b[${height}A` : '';
189
+ for (const l of frame) buf += `${l}\x1b[K\n`;
190
+ // The box only ever grows, but a narrower terminal can shorten it; clear
191
+ // whatever the previous frame left below this one.
192
+ if (height > frame.length) buf += '\x1b[J';
193
+ stream.write(buf);
194
+ height = frame.length;
195
+ };
196
+
197
+ const erase = () => {
198
+ if (!height) return;
199
+ stream.write(`\r\x1b[${height}A\x1b[J`);
200
+ height = 0;
201
+ };
202
+
203
+ const tick = () => {
204
+ if (timer) return;
205
+ timer = setInterval(paint, tickMs);
206
+ // Never a reason to keep the process alive: the run is what we are waiting on.
207
+ timer.unref?.();
208
+ };
209
+ const untick = () => { clearInterval(timer); timer = null; };
210
+
211
+ const stop = () => {
212
+ untick();
213
+ unwatch?.();
214
+ unwatch = null;
215
+ unabort?.();
216
+ unabort = null;
217
+ };
218
+
219
+ return {
220
+ enabled: true,
221
+
222
+ open() {
223
+ unwatch = watchEsc({ input, onEsc: () => this.collapse() });
224
+ // Ctrl-C aborts the run and then prints `interrupted` — which would land
225
+ // on top of the box, leaving the next repaint counting rows from the
226
+ // wrong place and erasing lines of the conversation above. Abort fires
227
+ // this listener synchronously, so the box is gone before that line
228
+ // prints. Nothing is lost: the transcript has everything it showed.
229
+ const onAbort = () => this.collapse();
230
+ signal?.addEventListener('abort', onAbort, { once: true });
231
+ // Released with everything else: one turn can delegate several times, and
232
+ // a listener left on that turn's signal each time is a leak node warns about.
233
+ unabort = () => signal?.removeEventListener('abort', onAbort);
234
+ paint();
235
+ tick();
236
+ },
237
+
238
+ /** One line of sub-agent activity. Only the last `keep` survive. */
239
+ line(text) {
240
+ if (collapsed) return;
241
+ for (const l of String(text).split('\n')) fed.push(l);
242
+ if (fed.length > keep) fed.splice(0, fed.length - keep);
243
+ paint();
244
+ },
245
+
246
+ step(n) { steps = n; },
247
+
248
+ /** `esc`: give the screen back now. The run carries on into the transcript. */
249
+ collapse() {
250
+ if (collapsed) return;
251
+ erase();
252
+ collapsed = true;
253
+ stop();
254
+ },
255
+
256
+ close() {
257
+ erase();
258
+ collapsed = true;
259
+ stop();
260
+ },
261
+
262
+ shield(fn) {
263
+ if (!fn) return fn;
264
+ return async (...args) => {
265
+ const wasUp = !collapsed && height > 0;
266
+ erase();
267
+ untick();
268
+ try {
269
+ return await fn(...args);
270
+ } finally {
271
+ if (wasUp && !collapsed) { paint(); tick(); }
272
+ }
273
+ };
274
+ },
275
+ };
276
+ }