kronk-cli 0.4.0 → 0.5.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/README.md +47 -0
- package/package.json +1 -1
- package/src/agent.js +11 -1
- package/src/config.js +4 -0
- package/src/index.js +35 -9
- package/src/markdown.js +136 -0
- package/src/prompt.js +79 -0
- package/src/theme.js +226 -0
- package/src/ui.js +22 -11
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
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
|
-
|
|
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
|
-
|
|
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; }
|
package/src/markdown.js
ADDED
|
@@ -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
|
-
|
|
2
|
-
|
|
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('
|
|
6
|
-
bold: wrap('
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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) => `
|