kronk-cli 0.4.0 → 0.5.1

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/README.md CHANGED
@@ -626,6 +626,46 @@ yellow past 70%, red past 90%. Toggling `/auto`, `/think` or `/steps` updates it
626
626
 
627
627
  The per-turn usage line still prints after each response; this one is the running picture.
628
628
 
629
+ ### It follows your terminal's background
630
+
631
+ That grey is the one colour the whole line depends on, and the sixteen-colour palette has exactly
632
+ one of them — bright black, which most terminals render around `#555`. On a light background it
633
+ reads; on a dark one the status line was the hardest thing on screen to see.
634
+
635
+ So the palette is chosen per background, from the 256-colour cube: a lighter grey on a dark
636
+ terminal, a darker one on a light terminal, and every other colour shifted the same way. Which
637
+ background you have is settled once, at startup, in this order:
638
+
639
+ 1. `KRONK_THEME`, or `"theme"` in `~/.kronk-cli.json` — `dark` or `light` pins it
640
+ 2. `COLORFGBG`, when the terminal exports it
641
+ 3. the terminal's own answer to an `OSC 11` background query, waited on for 100 ms
642
+ 4. failing all of that, dark — what most terminals ship as
643
+
644
+ `/theme dark` or `/theme light` switches it mid-session, for when step 3 guessed wrong. `NO_COLOR`
645
+ still turns every escape off, and a terminal that advertises no 256-colour support gets a
646
+ sixteen-colour palette picked the same way.
647
+
648
+ ---
649
+
650
+ ## Markdown in answers
651
+
652
+ The models write markdown whether or not anything renders it, so an answer used to arrive with the
653
+ punctuation still in your way — `### The letter is a notification`, `**binding**`, backticks around
654
+ every identifier. Those constructs are rendered into the attributes the terminal already has:
655
+ headings lose their hashes and gain bold, emphasis becomes bold or italic, code spans and fenced
656
+ blocks get their own colour and a gutter, lists get a `•`, quotes a `│`, and `---` a rule.
657
+
658
+ It is a line renderer, not a markdown parser — one bit of state, whether a fenced block is open —
659
+ and anything it does not recognise is left exactly as written. Prose that only looks like markup
660
+ survives: `snake_case`, `3 * 4`, a lone asterisk, `**kwargs` inside backticks.
661
+
662
+ Two consequences worth knowing:
663
+
664
+ - **Output appears a line at a time**, not a token at a time, because `**` is bold or two
665
+ asterisks depending on what follows it. At local speeds that is still a line every half second.
666
+ - **With colour off the bytes are the model's own.** Piped, redirected, or `NO_COLOR` — nothing is
667
+ rewritten, so anything parsing kronk-cli's output sees exactly what it saw before.
668
+
629
669
  ---
630
670
 
631
671
  ## REPL commands
@@ -643,6 +683,7 @@ The per-turn usage line still prints after each response; this one is the runnin
643
683
  | `/agents` | the sub-agents `task` can delegate to, and what each may touch |
644
684
  | `/mcp` | list attached MCP servers and their tools |
645
685
  | `/context` | how much of the context window is used |
686
+ | `/theme [dark\|light]` | show or pin the palette, for when the terminal was read wrong |
646
687
  | `/compact` | replace the conversation with a summary of itself |
647
688
  | `/clear` | reset the conversation, keep the model |
648
689
  | `/exit`, `/quit` | quit |
@@ -676,7 +717,9 @@ The per-turn usage line still prints after each response; this one is the runnin
676
717
  | `KRONK_WARM` | `true` | `false` skips the boot-time model preload |
677
718
  | `KRONK_AUTO_COMPACT` | `true` | `false` disables automatic compaction |
678
719
  | `KRONK_COMPACT_AT` | `0.85` | Fraction of the window that triggers compaction |
720
+ | `KRONK_THEME` | `auto` | `dark` or `light` pins the palette; `auto` reads the terminal's background |
679
721
  | `NO_COLOR` | — | Any value disables colour |
722
+ | `FORCE_COLOR` | — | `0` disables colour; any other value keeps it on through a pipe |
680
723
 
681
724
  ### Config file
682
725
 
@@ -690,6 +733,7 @@ The per-turn usage line still prints after each response; this one is the runnin
690
733
  "maxTokens": 16384,
691
734
  "maxSteps": 200,
692
735
  "showThinking": false,
736
+ "theme": "auto",
693
737
  "autoCompact": true,
694
738
  "compactAt": 0.85,
695
739
  "noThink": true,
@@ -1390,6 +1434,9 @@ previous prompt prefix — watch `cached` climb in the usage line.
1390
1434
  | `src/distill.js` | summarizing large tool output in a throwaway context |
1391
1435
  | `src/config.js` | precedence of flags, env, config file |
1392
1436
  | `src/ui.js` | colour, spinner, usage formatting |
1437
+ | `src/theme.js` | which palette to print in, and how the background is worked out |
1438
+ | `src/markdown.js` | rendering the model's markdown for the terminal, as it streams |
1439
+ | `src/prompt.js` | the line reader: opened on the first question, keeping what was typed before it |
1393
1440
 
1394
1441
  ---
1395
1442
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kronk-cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "A Claude-Code-style terminal agent for local models served by Kronk.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/agent.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  import { config, shouldPreserveThinking } from './config.js';
7
7
  import { forRequest } from './reasoning.js';
8
8
  import { c, fmtUsage, spinner, liveLine, toolResultLines } from './ui.js';
9
+ import { markdownStream } from './markdown.js';
9
10
  import { compact, isOverflow, report } from './compact.js';
10
11
  import { maybeDistill } from './distill.js';
11
12
  import {
@@ -125,6 +126,9 @@ export async function runTurn({
125
126
  let calls = [];
126
127
  let wroteAnything = false;
127
128
  let inReasoning = false;
129
+ // One renderer per step: a fence left open by one answer must not colour
130
+ // the next one as code.
131
+ const md = markdownStream();
128
132
 
129
133
  try {
130
134
  for await (const ev of streamChat({
@@ -158,7 +162,10 @@ export async function runTurn({
158
162
  if (!stream) continue;
159
163
  if (sp) { sp.stop(); sp = null; }
160
164
  if (inReasoning) { process.stdout.write(c.grey('\n ┄─────────┄\n\n')); inReasoning = false; }
161
- process.stdout.write(ev.value);
165
+ // Rendered a line at a time — see src/markdown.js. `text` keeps the
166
+ // model's own copy: what goes back in the conversation is never the
167
+ // screen's version of it.
168
+ process.stdout.write(md.write(ev.value));
162
169
  wroteAnything = true;
163
170
  }
164
171
 
@@ -191,6 +198,9 @@ export async function runTurn({
191
198
  }
192
199
 
193
200
  if (inReasoning) process.stdout.write(c.grey('\n ┄─────────┄\n'));
201
+ // The last line has no newline to trigger it: an answer that ends mid-line
202
+ // would otherwise be held in the renderer and never printed.
203
+ if (stream) process.stdout.write(md.flush());
194
204
  if (wroteAnything) process.stdout.write('\n');
195
205
  if (totalUsage) {
196
206
  out(fmtUsage(totalUsage, window));
package/src/config.js CHANGED
@@ -61,6 +61,10 @@ export const config = {
61
61
  // Kronk admits a model on its first inference request, not at server start.
62
62
  // Pay that cold load at boot rather than on the first typed prompt.
63
63
  warm: (process.env.KRONK_WARM ?? String(file.warm ?? 'true')) !== 'false',
64
+ // 'auto' asks the terminal what colour its background is; 'dark' or 'light'
65
+ // pins the palette when it answers wrong or cannot answer at all. See
66
+ // src/theme.js — the value is only ever read there.
67
+ theme: process.env.KRONK_THEME ?? file.theme ?? 'auto',
64
68
  autoCompact: (process.env.KRONK_AUTO_COMPACT ?? String(file.autoCompact ?? 'true')) !== 'false',
65
69
  compactAt: Number(process.env.KRONK_COMPACT_AT ?? file.compactAt ?? 0.85),
66
70
  // Large tool output is summarized in a throwaway context so the raw text
package/src/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import readline from 'node:readline/promises';
3
2
  import { stdin, stdout } from 'node:process';
4
3
  import { homedir } from 'node:os';
5
4
  import { readFile } from 'node:fs/promises';
@@ -8,12 +7,14 @@ import { listModels, listModelDetails, listLoaded, modelLimits, tokenize } from
8
7
  import { pickDefault, ensureLoaded } from './boot.js';
9
8
  import { runTurn, SYSTEM, SYSTEM_AUTO } from './agent.js';
10
9
  import { c, banner, fmtContext, statusLine } from './ui.js';
10
+ import { resolveTheme, theme, useTheme } from './theme.js';
11
11
  import { projectContext } from './context.js';
12
12
  import { forRequest } from './reasoning.js';
13
13
  import { compact, report } from './compact.js';
14
14
  import { loadServers, McpHub, reportFailures } from './mcp.js';
15
15
  import { resolveSandbox, sandbox } from './tools.js';
16
16
  import { parseArgv } from './argv.js';
17
+ import { deferredPrompt } from './prompt.js';
17
18
  import { AGENTS } from './subagent.js';
18
19
  import { runSetup } from './setup.js';
19
20
 
@@ -68,6 +69,8 @@ if (args.help) {
68
69
  KRONK_PRESERVE_THINKING
69
70
  false to stop pinning earlier think blocks in the
70
71
  prompt (smaller prompts, cache lost on every turn)
72
+ KRONK_THEME dark or light to pin the palette; default auto, which
73
+ asks the terminal for its background colour
71
74
  KRONK_WARM false to skip the boot-time model preload
72
75
  KRONK_AUTO_COMPACT false to disable automatic compaction
73
76
  KRONK_COMPACT_AT fraction of the window that triggers it (default 0.85)
@@ -289,6 +292,7 @@ const HELP = `
289
292
  ${c.bold('/agents')} sub-agents you can delegate to with the task tool
290
293
  ${c.bold('/mcp')} list attached MCP servers and their tools
291
294
  ${c.bold('/context')} how much of the context window is used
295
+ ${c.bold('/theme [d|l]')} dark or light palette, for when the terminal guessed wrong
292
296
  ${c.bold('/compact')} replace the conversation with a summary of itself
293
297
  ${c.bold('/clear')} reset the conversation
294
298
  ${c.bold('/exit')} quit
@@ -425,7 +429,26 @@ async function main() {
425
429
  return;
426
430
  }
427
431
 
428
- const rl = readline.createInterface({ input: stdin, output: stdout, historySize: 500 });
432
+ // Before readline takes stdin, because settling the palette may mean asking
433
+ // the terminal for its background colour and reading the reply in raw mode.
434
+ await resolveTheme({ prefer: config.theme, input: stdin, output: stdout });
435
+
436
+ // Not created yet: the interface is opened by the first question, below.
437
+ // Everything between here and there — the model load, the project scan, the
438
+ // MCP connections — is time somebody may spend typing, and readline emits
439
+ // lines whether or not anything is listening. See src/prompt.js.
440
+ let ac = null;
441
+ const rl = deferredPrompt({
442
+ input: stdin,
443
+ output: stdout,
444
+ historySize: 500,
445
+ // Ctrl-C aborts the in-flight request instead of killing the process.
446
+ onSigint: () => {
447
+ if (ac) { ac.abort(); ac = null; console.log(c.yellow('\n interrupted')); }
448
+ else { console.log(); rl.close(); }
449
+ },
450
+ });
451
+
429
452
  await boot();
430
453
  const { content, ctx } = await systemMessage(AUTO);
431
454
  console.log(banner(config.model, config.baseUrl));
@@ -463,13 +486,6 @@ async function main() {
463
486
  // it — so both the status line and the turn read the answer from there.
464
487
  const isAuto = () => messages[0].content.startsWith(SYSTEM_AUTO);
465
488
 
466
- // Ctrl-C aborts the in-flight request instead of killing the process.
467
- let ac = null;
468
- rl.on('SIGINT', () => {
469
- if (ac) { ac.abort(); ac = null; console.log(c.yellow('\n interrupted')); }
470
- else { console.log(); rl.close(); }
471
- });
472
-
473
489
  let autoApprove = AUTO_YES;
474
490
  const approve = async (name) => {
475
491
  if (autoApprove) return true;
@@ -561,6 +577,16 @@ async function main() {
561
577
  + `cap ${config.subagentSteps} steps per task\n`));
562
578
  continue;
563
579
  }
580
+ if (input === '/theme' || input.startsWith('/theme ')) {
581
+ // Everything prints through src/ui.js, which reads the palette per call,
582
+ // so the next line drawn is already in the new one — nothing to redraw.
583
+ const want = input.slice(6).trim().toLowerCase();
584
+ const name = { d: 'dark', dark: 'dark', l: 'light', light: 'light' }[want];
585
+ if (want && !name) { console.log(c.yellow(' /theme dark | /theme light')); continue; }
586
+ if (name) useTheme({ name });
587
+ console.log(c.grey(` ${theme()} palette`));
588
+ continue;
589
+ }
564
590
  if (input === '/models') { await showModels(); continue; }
565
591
  if (input === '/mcp') {
566
592
  if (!mcp || !mcp.routes.size) { console.log(c.grey(' no MCP servers attached — start with --mcp')); continue; }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Markdown, rendered for the terminal as it streams.
3
+ *
4
+ * The models write markdown whether or not anything is going to render it, so
5
+ * an answer arrived as `### The letter is a notification` and `**binding**`
6
+ * with the punctuation left in the reader's way. This turns the handful of
7
+ * constructs that actually show up in an answer — headings, emphasis, code,
8
+ * lists, quotes, rules, links — into the attributes a terminal already has.
9
+ *
10
+ * Deliberately not a markdown parser. It is a line renderer with one bit of
11
+ * state, whether a fenced code block is open, which is all that spans lines in
12
+ * the subset above. Anything it does not recognise is left exactly as written,
13
+ * which is the only safe answer for text that was never markdown to begin with.
14
+ *
15
+ * Line-buffered, because inline emphasis cannot be decided until the line is
16
+ * whole: `**` is bold or two asterisks depending on what comes next. Output
17
+ * therefore appears a line at a time rather than a token at a time. With colour
18
+ * off — piped, redirected, NO_COLOR — nothing is rewritten at all and the bytes
19
+ * are the model's own.
20
+ */
21
+ import { c } from './ui.js';
22
+ import { colorsOn } from './theme.js';
23
+
24
+ const FENCE = /^\s*(?:```|~~~)\s*([\w+-]*)\s*$/;
25
+ const HEADING = /^(#{1,6})\s+(.*)$/;
26
+ const BULLET = /^(\s*)[-*+]\s+(.*)$/;
27
+ const ORDERED = /^(\s*)(\d{1,9})([.)])\s+(.*)$/;
28
+ const QUOTE = /^\s*>\s?(.*)$/;
29
+ const RULE = /^\s*([-*_])(?:\s*\1){2,}\s*$/;
30
+
31
+ /** How wide a `---` is drawn. Narrow enough to sit inside any terminal. */
32
+ const RULE_WIDTH = 40;
33
+
34
+ /**
35
+ * Inline spans, in one pass over the parts that are not code.
36
+ *
37
+ * Code first and separately: whatever is between backticks is content, not
38
+ * markup, and running the emphasis rules over it would eat the asterisks in a
39
+ * `**kwargs` or a glob.
40
+ */
41
+ export function renderInline(text) {
42
+ return text
43
+ .split(/(`[^`]+`)/)
44
+ .map((part, i) => {
45
+ if (i % 2) return c.cyan(part.slice(1, -1));
46
+ return part
47
+ // Links before emphasis: the label can contain either.
48
+ .replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_, label, url) => `${c.blue(label)} ${c.grey(url)}`)
49
+ .replace(/\*\*([^\s*][^*]*?)\*\*/g, (_, s) => c.bold(s))
50
+ .replace(/__([^\s_][^_]*?)__/g, (_, s) => c.bold(s))
51
+ .replace(/~~([^\s~][^~]*?)~~/g, (_, s) => c.strike(s))
52
+ // Emphasis last, and only where the marker is not part of a word:
53
+ // snake_case identifiers and 3 * 4 must survive unchanged.
54
+ .replace(/(^|[\s(])\*([^\s*][^*]*?)\*(?=$|[\s).,;:!?])/g, (_, pre, s) => `${pre}${c.italic(s)}`)
55
+ .replace(/(^|[\s(])_([^\s_][^_]*?)_(?=$|[\s).,;:!?])/g, (_, pre, s) => `${pre}${c.italic(s)}`);
56
+ })
57
+ .join('');
58
+ }
59
+
60
+ /**
61
+ * One line, given the state carried from the lines before it. Mutates `state`,
62
+ * which is what a fenced block needs and the only reason state exists.
63
+ */
64
+ export function renderLine(line, state) {
65
+ const fence = FENCE.exec(line);
66
+ if (fence) {
67
+ if (state.fence) { state.fence = null; return c.grey(' ╰────'); }
68
+ state.fence = fence[1] || 'code';
69
+ return c.grey(` ╭─ ${state.fence}`);
70
+ }
71
+ // Inside a fence every character is the code's own — no emphasis, no
72
+ // reflowing, just a gutter to show where the block runs.
73
+ if (state.fence) return `${c.grey(' │ ')}${line}`;
74
+
75
+ if (RULE.test(line)) return c.grey('─'.repeat(RULE_WIDTH));
76
+
77
+ const heading = HEADING.exec(line);
78
+ if (heading) {
79
+ const [, hashes, body] = heading;
80
+ const text = c.bold(renderInline(body));
81
+ return hashes.length <= 2 ? c.cyan(text) : text;
82
+ }
83
+
84
+ const quote = QUOTE.exec(line);
85
+ if (quote) return `${c.grey('│ ')}${c.grey(renderInline(quote[1]))}`;
86
+
87
+ const bullet = BULLET.exec(line);
88
+ if (bullet) return `${bullet[1]}${c.grey('•')} ${renderInline(bullet[2])}`;
89
+
90
+ const ordered = ORDERED.exec(line);
91
+ if (ordered) {
92
+ const [, indent, n, , rest] = ordered;
93
+ return `${indent}${c.grey(`${n}.`)} ${renderInline(rest)}`;
94
+ }
95
+
96
+ return renderInline(line);
97
+ }
98
+
99
+ /**
100
+ * A renderer for streamed text: feed it deltas, write what comes back, and
101
+ * call `flush` when the stream ends to get the last partial line.
102
+ *
103
+ * `enabled` defaults to whether colour is on at all, so the piped and
104
+ * NO_COLOR paths hand back exactly what the model wrote.
105
+ */
106
+ export function markdownStream({ enabled = colorsOn() } = {}) {
107
+ const state = { fence: null };
108
+ let held = '';
109
+ return {
110
+ write(chunk) {
111
+ if (!enabled) return chunk;
112
+ held += chunk;
113
+ let out = '';
114
+ let at;
115
+ while ((at = held.indexOf('\n')) !== -1) {
116
+ out += `${renderLine(held.slice(0, at), state)}\n`;
117
+ held = held.slice(at + 1);
118
+ }
119
+ return out;
120
+ },
121
+ flush() {
122
+ if (!enabled || !held) { const rest = enabled ? '' : held; held = ''; return rest; }
123
+ const out = renderLine(held, state);
124
+ held = '';
125
+ return out;
126
+ },
127
+ /** Whether a fenced block is still open — the tail of a truncated answer. */
128
+ get inCode() { return state.fence !== null; },
129
+ };
130
+ }
131
+
132
+ /** The whole thing at once. The streaming path is the one the REPL uses. */
133
+ export function renderMarkdown(text, options) {
134
+ const md = markdownStream(options);
135
+ return md.write(text) + md.flush();
136
+ }
package/src/prompt.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The line reader: opened only when something is waiting on a line, and never
3
+ * dropping one that arrives before then.
4
+ *
5
+ * `readline` starts consuming stdin the moment it is created and emits every
6
+ * line it parses, listener or no listener. Startup is not instant — the model
7
+ * list, a cold load that can run to tens of seconds, the project scan, the MCP
8
+ * connections — so creating the interface up front meant every line typed in
9
+ * that window was emitted into nothing: no echo left, no error, and a prompt
10
+ * that arrived as though you had typed nothing at all.
11
+ *
12
+ * Two things fix that, and both are needed. Creation is deferred to the first
13
+ * question, which leaves those keystrokes in the terminal's own input buffer
14
+ * until there is something to read them. And once the interface exists, lines
15
+ * are queued rather than answered directly by `rl.question`, because a paste
16
+ * or a fast typist can hand readline several lines in one read and only the
17
+ * first would have had a listener waiting for it.
18
+ *
19
+ * One interface is created and kept, so history, line editing and Ctrl-C are
20
+ * readline's own, exactly as before.
21
+ */
22
+ import readline from 'node:readline';
23
+
24
+ /** What a pending question rejects with when the reader closes under it. */
25
+ export class PromptClosed extends Error {
26
+ constructor() { super('the prompt was closed'); this.name = 'PromptClosed'; }
27
+ }
28
+
29
+ export function deferredPrompt({ input, output, historySize = 500, onSigint } = {}) {
30
+ let rl = null;
31
+ let closed = false;
32
+ let waiting = null; // the question currently unanswered, if any
33
+ const queued = []; // lines that arrived with nobody asking
34
+
35
+ const open = () => {
36
+ if (rl) return rl;
37
+ rl = readline.createInterface({ input, output, historySize });
38
+ rl.on('line', (line) => {
39
+ if (waiting) { const w = waiting; waiting = null; w.resolve(line); }
40
+ else queued.push(line);
41
+ });
42
+ rl.on('close', () => {
43
+ closed = true;
44
+ if (waiting) { const w = waiting; waiting = null; w.reject(new PromptClosed()); }
45
+ });
46
+ // Registered with the interface rather than by the caller, so there is no
47
+ // moment in which the interface exists and Ctrl-C is unhandled.
48
+ if (onSigint) rl.on('SIGINT', () => onSigint());
49
+ return rl;
50
+ };
51
+
52
+ return {
53
+ /**
54
+ * Ask, opening the interface if this is the first time. A line that was
55
+ * typed ahead answers immediately — echoed after the prompt, so the
56
+ * transcript reads the way it would have if the timing had been kinder.
57
+ */
58
+ question(query) {
59
+ if (closed) return Promise.reject(new PromptClosed());
60
+ const reader = open();
61
+ if (queued.length) {
62
+ const line = queued.shift();
63
+ output.write(`${query}${line}\n`);
64
+ return Promise.resolve(line);
65
+ }
66
+ // setPrompt rather than a bare write: readline needs to know the prompt
67
+ // to redraw the line correctly when it is edited.
68
+ reader.setPrompt(query);
69
+ reader.prompt();
70
+ return new Promise((resolve, reject) => { waiting = { resolve, reject }; });
71
+ },
72
+ /** Closing one that was never opened is not an error, it is a no-op. */
73
+ close() { closed = true; rl?.close(); },
74
+ /** For tests, and for anything that wants to know without opening one. */
75
+ get open() { return rl !== null; },
76
+ /** Lines typed ahead that no question has taken yet. */
77
+ get pending() { return queued.length; },
78
+ };
79
+ }
package/src/theme.js ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * What colour to print in, given the terminal we were launched from.
3
+ *
4
+ * The status line, the context meter and every hint under it were bright black
5
+ * (SGR 90). On a light background that is a readable grey; on a dark one most
6
+ * terminals render it around #555, which is a few points of contrast away from
7
+ * the background it sits on — the line the REPL shows before every prompt was
8
+ * the hardest thing on screen to read. Bright black is the only grey the
9
+ * sixteen-colour palette has, so the fix is not a different code but a
10
+ * different palette per background, chosen from the 256-colour cube.
11
+ *
12
+ * Three ways to learn the background, in order of how much we trust them:
13
+ * what the user said (`KRONK_THEME`, `theme` in ~/.kronk-cli.json), what the
14
+ * terminal advertises (`COLORFGBG`), and what it answers when asked (OSC 11).
15
+ * The last one is a round trip over the tty, so it happens once at startup and
16
+ * never blocks for longer than a blink.
17
+ */
18
+
19
+ /**
20
+ * Palettes, as SGR parameters. Each is chosen for contrast against its own
21
+ * background, not for fidelity to the sixteen-colour name it replaces: `red`
22
+ * on a light background is a darker red than on a dark one, and both are still
23
+ * unmistakably red.
24
+ *
25
+ * The `grey`/`dim` pair is the whole point. 245 on dark and 242 on light both
26
+ * land near 4.5:1 against a typical terminal background, where 90 on dark is
27
+ * closer to 1.9:1.
28
+ */
29
+ export const PALETTES = {
30
+ 256: {
31
+ dark: {
32
+ dim: '38;5;244', bold: '1', italic: '3', strike: '9',
33
+ red: '38;5;203', green: '38;5;114', yellow: '38;5;221', blue: '38;5;111',
34
+ magenta: '38;5;176', cyan: '38;5;116', grey: '38;5;245',
35
+ },
36
+ light: {
37
+ dim: '38;5;244', bold: '1', italic: '3', strike: '9',
38
+ red: '38;5;160', green: '38;5;28', yellow: '38;5;136', blue: '38;5;26',
39
+ magenta: '38;5;90', cyan: '38;5;30', grey: '38;5;242',
40
+ },
41
+ },
42
+ // Sixteen colours: no cube to pick from, so the only lever is whether a hue
43
+ // comes from the normal or the bright half. Light keeps exactly what this
44
+ // program printed before the palettes existed.
45
+ 16: {
46
+ dark: {
47
+ dim: '37', bold: '1', italic: '3', strike: '9',
48
+ red: '91', green: '92', yellow: '93', blue: '94',
49
+ magenta: '95', cyan: '96', grey: '37',
50
+ },
51
+ light: {
52
+ dim: '90', bold: '1', italic: '3', strike: '9',
53
+ red: '31', green: '32', yellow: '33', blue: '34',
54
+ magenta: '35', cyan: '36', grey: '90',
55
+ },
56
+ },
57
+ };
58
+
59
+ /** Anything we cannot classify is dark: it is what most terminals ship as. */
60
+ export const DEFAULT_THEME = 'dark';
61
+
62
+ const THEMES = new Set(['dark', 'light']);
63
+
64
+ /**
65
+ * How many colours we may use. 0 means print no escapes at all, which is what
66
+ * NO_COLOR, a dumb terminal, a redirected stdout and `FORCE_COLOR=0` each ask
67
+ * for; any other FORCE_COLOR overrides the redirect, so a pipe into `less -R`
68
+ * and the test suite can both see what a terminal would.
69
+ */
70
+ export function colorDepth(env = process.env, stream = process.stdout) {
71
+ if (env.NO_COLOR) return 0;
72
+ if (env.TERM === 'dumb') return 0;
73
+ if (env.FORCE_COLOR === '0') return 0;
74
+ if (env.FORCE_COLOR === undefined && !stream?.isTTY) return 0;
75
+ const term = env.TERM ?? '';
76
+ if (env.COLORTERM || /-256(color)?\b/.test(term) || /^(alacritty|kitty|wezterm|xterm-ghostty)/.test(term)) {
77
+ return 256;
78
+ }
79
+ return 16;
80
+ }
81
+
82
+ /**
83
+ * The background as `COLORFGBG` describes it — `foreground;background`, or
84
+ * `foreground;bold;background` from rxvt. The background is the last field,
85
+ * as a palette index: the dark half of the sixteen, plus bright black, means
86
+ * a dark terminal. `default` is the one honest answer some terminals give and
87
+ * it tells us nothing, so it stays unclassified rather than guessed at.
88
+ */
89
+ export function parseColorFgBg(value) {
90
+ if (typeof value !== 'string') return null;
91
+ const bg = value.split(';').pop()?.trim();
92
+ if (!bg || !/^\d+$/.test(bg)) return null;
93
+ const n = Number(bg);
94
+ if (n > 15) return null;
95
+ return [0, 1, 2, 3, 4, 5, 6, 8].includes(n) ? 'dark' : 'light';
96
+ }
97
+
98
+ /**
99
+ * The reply to an OSC 11 query: `ESC ] 11 ; rgb:RRRR/GGGG/BBBB`, an optional
100
+ * alpha component, then BEL or ST. Components are 1 to 4 hex digits wide
101
+ * depending on the terminal — xterm answers in 16 bits, others in 8.
102
+ */
103
+ // eslint-disable-next-line no-control-regex -- ESC is what the reply starts with
104
+ const OSC11 = /\x1b\]11;rgba?:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})(?:\/[0-9a-f]{1,4})?(?:\x07|\x1b\\)?/i;
105
+
106
+ /**
107
+ * The reply found in `buf`: its relative luminance, and where it sat, so the
108
+ * caller can hand back whatever else was in the buffer. Each component is
109
+ * scaled by its own width — a short reply is not a near-black one.
110
+ */
111
+ function matchOsc11(buf) {
112
+ const m = typeof buf === 'string' ? OSC11.exec(buf) : null;
113
+ if (!m) return null;
114
+ const [r, g, b] = m.slice(1, 4).map((h) => parseInt(h, 16) / (16 ** h.length - 1));
115
+ return {
116
+ luminance: 0.2126 * r + 0.7152 * g + 0.0722 * b,
117
+ rest: buf.slice(0, m.index) + buf.slice(m.index + m[0].length),
118
+ };
119
+ }
120
+
121
+ /** The relative luminance of an OSC 11 reply, or null if this is not one. */
122
+ export function parseOsc11(reply) {
123
+ return matchOsc11(reply)?.luminance ?? null;
124
+ }
125
+
126
+ /** Mid-grey is the boundary; a terminal sitting exactly on it reads as light. */
127
+ export const themeForLuminance = (lum) => (lum >= 0.5 ? 'light' : 'dark');
128
+
129
+ /**
130
+ * A theme somebody named, or null for "work it out". `auto` is spelled out
131
+ * rather than left to an empty value so a config file can turn a forced theme
132
+ * back off without deleting the key, and a typo falls through to detection
133
+ * rather than pinning the wrong palette.
134
+ */
135
+ export const namedTheme = (value) => {
136
+ const asked = String(value ?? '').trim().toLowerCase();
137
+ return THEMES.has(asked) ? asked : null;
138
+ };
139
+
140
+ /** What the environment alone can tell us, or null for "ask the terminal". */
141
+ export function detectTheme(env = process.env) {
142
+ return namedTheme(env.KRONK_THEME) ?? parseColorFgBg(env.COLORFGBG);
143
+ }
144
+
145
+ /**
146
+ * Ask the terminal for its background colour and wait, briefly.
147
+ *
148
+ * This puts stdin in raw mode for as long as the answer takes, so it runs
149
+ * once, before the REPL's readline exists, and only when both ends are a tty.
150
+ * A terminal that does not implement OSC 11 says nothing at all, which is why
151
+ * there is a timeout rather than a sentinel to wait for; 100 ms is far longer
152
+ * than a local round trip and short enough to be invisible.
153
+ *
154
+ * Anything read that is not part of the reply is somebody typing ahead, and it
155
+ * is pushed back onto stdin before this resolves. Without that, a fast typist
156
+ * — or a script piping commands in — loses whatever landed in the window, and
157
+ * loses it silently.
158
+ */
159
+ export function probeBackground({ input = process.stdin, output = process.stdout, timeoutMs = 100 } = {}) {
160
+ if (!input?.isTTY || !output?.isTTY || typeof input.setRawMode !== 'function') {
161
+ return Promise.resolve(null);
162
+ }
163
+ return new Promise((resolve) => {
164
+ const wasRaw = input.isRaw;
165
+ let buf = '';
166
+ let settled = false;
167
+ const finish = (value, rest) => {
168
+ if (settled) return;
169
+ settled = true;
170
+ clearTimeout(timer);
171
+ input.off('data', onData);
172
+ if (!wasRaw) input.setRawMode(false);
173
+ input.pause();
174
+ if (rest) input.unshift?.(Buffer.from(rest, 'latin1'));
175
+ resolve(value);
176
+ };
177
+ const onData = (chunk) => {
178
+ buf += chunk.toString('latin1');
179
+ const hit = matchOsc11(buf);
180
+ if (hit) finish(themeForLuminance(hit.luminance), hit.rest);
181
+ // Whatever this is, it is not the reply and no longer plausibly the
182
+ // start of one. Stop reading and give it back.
183
+ else if (buf.length > 64) finish(null, buf);
184
+ };
185
+ const timer = setTimeout(() => finish(null, buf), timeoutMs);
186
+ input.setRawMode(true);
187
+ input.resume();
188
+ input.on('data', onData);
189
+ output.write('\x1b]11;?\x07');
190
+ });
191
+ }
192
+
193
+ let depth = colorDepth();
194
+ let active = detectTheme() ?? DEFAULT_THEME;
195
+
196
+ /** Which palette is in force. Read by the tests and by `/theme`. */
197
+ export const theme = () => active;
198
+
199
+ /** Depth 0 means every colour helper is the identity function. */
200
+ export const colorsOn = () => depth > 0;
201
+
202
+ /** Set both explicitly. Tests use it; nothing in the program has to guess. */
203
+ export function useTheme({ name, colors } = {}) {
204
+ if (THEMES.has(name)) active = name;
205
+ if (colors !== undefined) depth = colors ? (colors === true ? 256 : colors) : 0;
206
+ return active;
207
+ }
208
+
209
+ /**
210
+ * Settle the palette for this session: what the user asked for, what the
211
+ * environment said, or failing both what the terminal answers. Startup awaits this once; everything that
212
+ * prints reads `ansi` per call, so the banner and every later line agree.
213
+ */
214
+ export async function resolveTheme({ prefer, ...options } = {}) {
215
+ const known = namedTheme(prefer) ?? detectTheme();
216
+ if (known) { active = known; return active; }
217
+ if (depth === 0) return active; // nothing to colour, don't touch the tty
218
+ active = (await probeBackground(options)) ?? DEFAULT_THEME;
219
+ return active;
220
+ }
221
+
222
+ /** The SGR parameters for a palette entry, or null when colour is off. */
223
+ export function ansi(name) {
224
+ if (depth === 0) return null;
225
+ return PALETTES[depth === 256 ? 256 : 16][active][name] ?? null;
226
+ }
package/src/ui.js CHANGED
@@ -1,16 +1,27 @@
1
- const on = process.stdout.isTTY && !process.env.NO_COLOR;
2
- const wrap = (code) => (s) => (on ? `\x1b[${code}m${s}\x1b[0m` : s);
1
+ import { ansi } from './theme.js';
2
+
3
+ /**
4
+ * Each colour resolves its escape when it is called, not when this module is
5
+ * loaded: the background is only known after the terminal has been asked, and
6
+ * that answer arrives after the first import. See src/theme.js.
7
+ */
8
+ const wrap = (name) => (s) => {
9
+ const code = ansi(name);
10
+ return code ? `\x1b[${code}m${s}\x1b[0m` : s;
11
+ };
3
12
 
4
13
  export const c = {
5
- dim: wrap('2'),
6
- bold: wrap('1'),
7
- red: wrap('31'),
8
- green: wrap('32'),
9
- yellow: wrap('33'),
10
- blue: wrap('34'),
11
- magenta: wrap('35'),
12
- cyan: wrap('36'),
13
- grey: wrap('90'),
14
+ dim: wrap('dim'),
15
+ bold: wrap('bold'),
16
+ italic: wrap('italic'),
17
+ strike: wrap('strike'),
18
+ red: wrap('red'),
19
+ green: wrap('green'),
20
+ yellow: wrap('yellow'),
21
+ blue: wrap('blue'),
22
+ magenta: wrap('magenta'),
23
+ cyan: wrap('cyan'),
24
+ grey: wrap('grey'),
14
25
  };
15
26
 
16
27
  export const banner = (model, url) => `