@giovannijecha/jecode 0.1.5-rc.2
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/LICENSE +21 -0
- package/README.md +264 -0
- package/bin/jecode.js +11 -0
- package/dist/atomic.js +78 -0
- package/dist/batch-view.js +17 -0
- package/dist/batch.js +100 -0
- package/dist/cli-info.js +40 -0
- package/dist/commands.js +171 -0
- package/dist/config.js +97 -0
- package/dist/controller.js +90 -0
- package/dist/credential-commands.js +134 -0
- package/dist/credential-safety.js +86 -0
- package/dist/credentials.js +124 -0
- package/dist/main.js +7 -0
- package/dist/ollama-settings-command.js +75 -0
- package/dist/prompt.js +29 -0
- package/dist/provider-commands.js +236 -0
- package/dist/provider-errors.js +10 -0
- package/dist/providers/anthropic-stream.js +111 -0
- package/dist/providers/anthropic-wire.js +79 -0
- package/dist/providers/anthropic.js +77 -0
- package/dist/providers/catalog.js +37 -0
- package/dist/providers/http.js +219 -0
- package/dist/providers/index.js +18 -0
- package/dist/providers/ollama-endpoint.js +40 -0
- package/dist/providers/ollama-stream.js +60 -0
- package/dist/providers/ollama-wire.js +95 -0
- package/dist/providers/ollama.js +87 -0
- package/dist/providers/openai-stream.js +48 -0
- package/dist/providers/openai-wire.js +112 -0
- package/dist/providers/openai.js +70 -0
- package/dist/providers/sse.js +81 -0
- package/dist/providers/stream-limits.js +11 -0
- package/dist/session.js +3 -0
- package/dist/settings-command.js +202 -0
- package/dist/settings.js +98 -0
- package/dist/start.js +47 -0
- package/dist/tools/args.js +38 -0
- package/dist/tools/fs.js +277 -0
- package/dist/tools/index.js +39 -0
- package/dist/tools/paths.js +122 -0
- package/dist/tools/search.js +213 -0
- package/dist/tools/shell.js +139 -0
- package/dist/tools/text-boundary.js +102 -0
- package/dist/tools/types.js +1 -0
- package/dist/transcript-export.js +11 -0
- package/dist/transcript.js +49 -0
- package/dist/tui/activity.js +11 -0
- package/dist/tui/app-input.js +155 -0
- package/dist/tui/app-state.js +17 -0
- package/dist/tui/app-workflows.js +105 -0
- package/dist/tui/app.js +215 -0
- package/dist/tui/approve.js +67 -0
- package/dist/tui/blocks.js +23 -0
- package/dist/tui/complete.js +54 -0
- package/dist/tui/components/command-menu.js +17 -0
- package/dist/tui/components/composer.js +47 -0
- package/dist/tui/components/dock.js +10 -0
- package/dist/tui/components/footer.js +21 -0
- package/dist/tui/components/menu.js +38 -0
- package/dist/tui/components/messages.js +43 -0
- package/dist/tui/components/misc.js +22 -0
- package/dist/tui/components/status.js +45 -0
- package/dist/tui/components/tool.js +85 -0
- package/dist/tui/components/types.js +1 -0
- package/dist/tui/editor.js +105 -0
- package/dist/tui/feedback.js +74 -0
- package/dist/tui/field.js +60 -0
- package/dist/tui/frame.js +33 -0
- package/dist/tui/input.js +52 -0
- package/dist/tui/keys.js +177 -0
- package/dist/tui/modal.js +24 -0
- package/dist/tui/overlay.js +93 -0
- package/dist/tui/picker.js +99 -0
- package/dist/tui/screen.js +94 -0
- package/dist/tui/scroll.js +7 -0
- package/dist/tui/session-view.js +49 -0
- package/dist/tui/transcript-view.js +134 -0
- package/dist/tui/turn.js +255 -0
- package/dist/tui/view.js +88 -0
- package/dist/tui/workspace.js +59 -0
- package/dist/types.js +9 -0
- package/dist/ui/diff.js +109 -0
- package/dist/ui/highlight.js +158 -0
- package/dist/ui/inline.js +39 -0
- package/dist/ui/markdown.js +147 -0
- package/dist/ui/render.js +232 -0
- package/dist/ui/table.js +127 -0
- package/dist/ui/terminal-text.js +42 -0
- package/dist/ui/theme.js +25 -0
- package/dist/ui/width.js +196 -0
- package/dist/usage.js +30 -0
- package/dist/user-data.js +29 -0
- package/package.json +56 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Inline Markdown notation: the marks inside a line, spent on style.
|
|
2
|
+
//
|
|
3
|
+
// Its own module because two things need it — a paragraph and a table cell —
|
|
4
|
+
// and neither should have to import the other to get it.
|
|
5
|
+
const INLINE = /`([^`]+)`|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*\s][^*]*)\*|\[([^\]]+)\]\(([^)]+)\)/g;
|
|
6
|
+
/**
|
|
7
|
+
* Inline notation to styled segments.
|
|
8
|
+
*
|
|
9
|
+
* Emphasis becomes weight and colour rather than a slant, and a link keeps its
|
|
10
|
+
* label and drops its target: a URL spelled out mid-sentence costs more rows
|
|
11
|
+
* than it earns, and the label is what the sentence was written around.
|
|
12
|
+
*/
|
|
13
|
+
export function inline(text, base, pal, bold = false) {
|
|
14
|
+
const { ink } = pal;
|
|
15
|
+
const segs = [];
|
|
16
|
+
let last = 0;
|
|
17
|
+
INLINE.lastIndex = 0;
|
|
18
|
+
for (let match = INLINE.exec(text); match !== null; match = INLINE.exec(text)) {
|
|
19
|
+
if (match.index > last) {
|
|
20
|
+
segs.push({ text: text.slice(last, match.index), fg: base, bold: bold || undefined });
|
|
21
|
+
}
|
|
22
|
+
const [, mono, strong, strongAlt, emphasis, label] = match;
|
|
23
|
+
if (mono !== undefined)
|
|
24
|
+
segs.push({ text: mono, fg: pal.accentSoft });
|
|
25
|
+
else if (strong !== undefined)
|
|
26
|
+
segs.push({ text: strong, fg: ink.bright, bold: true });
|
|
27
|
+
else if (strongAlt !== undefined)
|
|
28
|
+
segs.push({ text: strongAlt, fg: ink.bright, bold: true });
|
|
29
|
+
else if (emphasis !== undefined)
|
|
30
|
+
segs.push({ text: emphasis, fg: ink.bright });
|
|
31
|
+
else if (label !== undefined)
|
|
32
|
+
segs.push({ text: label, fg: pal.accent });
|
|
33
|
+
last = match.index + match[0].length;
|
|
34
|
+
}
|
|
35
|
+
if (last < text.length) {
|
|
36
|
+
segs.push({ text: text.slice(last), fg: base, bold: bold || undefined });
|
|
37
|
+
}
|
|
38
|
+
return segs.length === 0 ? [{ text: "", fg: base }] : segs;
|
|
39
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Drawing the model's prose, which is Markdown whether or not we asked for it.
|
|
2
|
+
//
|
|
3
|
+
// Printing it raw is the single loudest way a terminal agent looks unfinished:
|
|
4
|
+
// the reader gets asterisks around the emphasis and backticks around the code,
|
|
5
|
+
// which is the notation, not the meaning. So the notation is consumed here and
|
|
6
|
+
// spent on weight and colour instead.
|
|
7
|
+
//
|
|
8
|
+
// Two rules keep this honest. It renders what a terminal can actually draw —
|
|
9
|
+
// emphasis is weight and colour, never a slant, because a slant many terminals
|
|
10
|
+
// drop leaves prose whose marks are already gone. And it is written to be fed
|
|
11
|
+
// a *prefix*: text arrives a token at a time, so an unterminated fence is a
|
|
12
|
+
// code block still being written, not a parse error.
|
|
13
|
+
import { flow } from "./render.js";
|
|
14
|
+
import { highlight } from "./highlight.js";
|
|
15
|
+
import { elide } from "./width.js";
|
|
16
|
+
import { inline } from "./inline.js";
|
|
17
|
+
import * as table from "./table.js";
|
|
18
|
+
const FENCE = /^\s*(?:```|~~~)(.*)$/;
|
|
19
|
+
const HEADING = /^(#{1,6})\s+(.*)$/;
|
|
20
|
+
const BULLET = /^(\s*)[-*+]\s+(.*)$/;
|
|
21
|
+
const ORDERED = /^(\s*)(\d{1,3})[.)]\s+(.*)$/;
|
|
22
|
+
const QUOTE = /^\s*>\s?(.*)$/;
|
|
23
|
+
const HR = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
|
24
|
+
export function markdown(text, max, pal, proseMax = max) {
|
|
25
|
+
const { ink } = pal;
|
|
26
|
+
const prose = Math.min(max, proseMax);
|
|
27
|
+
const rows = [];
|
|
28
|
+
const lines = text.split("\n");
|
|
29
|
+
for (let i = 0; i < lines.length; i++) {
|
|
30
|
+
const line = lines[i];
|
|
31
|
+
const fence = FENCE.exec(line);
|
|
32
|
+
if (fence !== null) {
|
|
33
|
+
i = code(lines, i, max, pal, rows);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (line.trim() === "") {
|
|
37
|
+
rows.push({ segs: [] });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (table.opens(line, lines[i + 1])) {
|
|
41
|
+
const read = table.parse(lines, i);
|
|
42
|
+
rows.push(...table.draw(read.table, max, pal));
|
|
43
|
+
i = read.end;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (HR.test(line)) {
|
|
47
|
+
rows.push({ segs: [{ text: "─".repeat(Math.min(max, 80)), fg: pal.rule }] });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const heading = HEADING.exec(line);
|
|
51
|
+
if (heading !== null) {
|
|
52
|
+
rows.push(...emit(inline(heading[2], ink.bright, pal, true), prose, []));
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const quote = QUOTE.exec(line);
|
|
56
|
+
if (quote !== null) {
|
|
57
|
+
const bar = [{ text: "│ ", fg: pal.rule }];
|
|
58
|
+
rows.push(...emit(inline(quote[1], ink.muted, pal), prose - 2, bar, bar));
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const bullet = BULLET.exec(line);
|
|
62
|
+
if (bullet !== null) {
|
|
63
|
+
const depth = Math.min(2, Math.floor(bullet[1].length / 2));
|
|
64
|
+
const mark = [
|
|
65
|
+
{ text: " ".repeat(depth) },
|
|
66
|
+
{ text: "- ", fg: pal.accent },
|
|
67
|
+
];
|
|
68
|
+
const hang = [{ text: `${" ".repeat(depth)} ` }];
|
|
69
|
+
rows.push(...emit(inline(bullet[2], ink.fg, pal), prose - depth * 2 - 2, mark, hang));
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const ordered = ORDERED.exec(line);
|
|
73
|
+
if (ordered !== null) {
|
|
74
|
+
const label = `${ordered[2]}. `;
|
|
75
|
+
const mark = [{ text: label, fg: pal.accent }];
|
|
76
|
+
const hang = [{ text: " ".repeat(label.length) }];
|
|
77
|
+
rows.push(...emit(inline(ordered[3], ink.fg, pal), prose - label.length, mark, hang));
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// A paragraph is its lines joined, not one row each. The model hard-wraps
|
|
81
|
+
// its prose at some width of its own, and honouring those breaks would
|
|
82
|
+
// wrap already-wrapped text — the ragged half-rows that gives is the
|
|
83
|
+
// clearest tell that nobody parsed the Markdown.
|
|
84
|
+
const paragraph = [line];
|
|
85
|
+
while (i + 1 < lines.length &&
|
|
86
|
+
plain(lines[i + 1]) &&
|
|
87
|
+
!table.opens(lines[i + 1], lines[i + 2])) {
|
|
88
|
+
paragraph.push(lines[++i].trim());
|
|
89
|
+
}
|
|
90
|
+
rows.push(...emit(inline(paragraph.join(" "), ink.fg, pal), prose, []));
|
|
91
|
+
}
|
|
92
|
+
return rows;
|
|
93
|
+
}
|
|
94
|
+
/** Whether a line continues a paragraph rather than starting something. */
|
|
95
|
+
function plain(line) {
|
|
96
|
+
if (line.trim() === "")
|
|
97
|
+
return false;
|
|
98
|
+
return (!FENCE.test(line) &&
|
|
99
|
+
!HR.test(line) &&
|
|
100
|
+
!HEADING.test(line) &&
|
|
101
|
+
!QUOTE.test(line) &&
|
|
102
|
+
!BULLET.test(line) &&
|
|
103
|
+
!ORDERED.test(line));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A fenced block with muted transcript delimiters, never wrapped.
|
|
107
|
+
*
|
|
108
|
+
* Code that wraps stops being code — an indent that moved is a different
|
|
109
|
+
* program. A line too wide keeps both ends and marks the middle instead.
|
|
110
|
+
*/
|
|
111
|
+
function code(lines, start, max, pal, out) {
|
|
112
|
+
const { ink } = pal;
|
|
113
|
+
const open = FENCE.exec(lines[start]);
|
|
114
|
+
const lang = (open?.[1] ?? "").trim();
|
|
115
|
+
let i = start + 1;
|
|
116
|
+
const body = [];
|
|
117
|
+
for (; i < lines.length; i++) {
|
|
118
|
+
const line = lines[i];
|
|
119
|
+
if (FENCE.test(line))
|
|
120
|
+
break;
|
|
121
|
+
body.push(elide(line.replace(/\t/g, " "), max - 2));
|
|
122
|
+
}
|
|
123
|
+
out.push({ segs: [{ text: `\`\`\`${lang}`, fg: ink.muted }] });
|
|
124
|
+
const roles = {
|
|
125
|
+
plain: ink.added,
|
|
126
|
+
comment: ink.muted,
|
|
127
|
+
string: ink.added,
|
|
128
|
+
number: ink.attention,
|
|
129
|
+
keyword: pal.accent,
|
|
130
|
+
};
|
|
131
|
+
for (const tokens of highlight(body, lang)) {
|
|
132
|
+
out.push({
|
|
133
|
+
segs: [
|
|
134
|
+
{ text: " " },
|
|
135
|
+
...tokens.map((token) => ({ text: token.text, fg: roles[token.role] })),
|
|
136
|
+
],
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
out.push({ segs: [{ text: "```", fg: ink.muted }] });
|
|
140
|
+
return i;
|
|
141
|
+
}
|
|
142
|
+
/** Flow one inline run into rows, with an opener and a hanging indent. */
|
|
143
|
+
function emit(segs, max, opener, hang = []) {
|
|
144
|
+
return flow(segs, Math.max(8, max), hang).map((line, index) => ({
|
|
145
|
+
segs: index === 0 ? [...opener, ...line] : line,
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Composing a terminal row out of styled segments. Pure: every function here
|
|
2
|
+
// returns a string or rows of segments, none of them write anywhere.
|
|
3
|
+
import { terminalText } from "./terminal-text.js";
|
|
4
|
+
import { elide, textWidth, wrapText } from "./width.js";
|
|
5
|
+
// Built rather than written literally: a raw escape byte in the source is
|
|
6
|
+
// invisible in a diff and in a code review, which is how they survive.
|
|
7
|
+
const ESC = String.fromCharCode(27);
|
|
8
|
+
export const CSI = `${ESC}[`;
|
|
9
|
+
const RESET = `${CSI}0m`;
|
|
10
|
+
const BOLD = `${CSI}1m`;
|
|
11
|
+
const ITALIC = `${CSI}3m`;
|
|
12
|
+
const INVERSE = `${CSI}7m`;
|
|
13
|
+
// Truecolor is assumed wherever colour is wanted at all — every terminal that
|
|
14
|
+
// has shipped this decade has it. The fallback that matters is "no colour",
|
|
15
|
+
// which is what a pipe and NO_COLOR both get.
|
|
16
|
+
const terminalColor = process.stdout.isTTY === true &&
|
|
17
|
+
process.env["NO_COLOR"] === undefined &&
|
|
18
|
+
process.env["TERM"] !== "dumb";
|
|
19
|
+
let colorEnabled = terminalColor;
|
|
20
|
+
export function configureColor(enabled) {
|
|
21
|
+
colorEnabled = terminalColor && enabled;
|
|
22
|
+
}
|
|
23
|
+
export function paint(seg) {
|
|
24
|
+
const text = terminalText(seg.text);
|
|
25
|
+
if (!colorEnabled)
|
|
26
|
+
return text;
|
|
27
|
+
let out = "";
|
|
28
|
+
if (seg.bold === true)
|
|
29
|
+
out += BOLD;
|
|
30
|
+
if (seg.italic === true)
|
|
31
|
+
out += ITALIC;
|
|
32
|
+
if (seg.inverse === true)
|
|
33
|
+
out += INVERSE;
|
|
34
|
+
if (seg.fg !== undefined)
|
|
35
|
+
out += `${CSI}38;2;${seg.fg[0]};${seg.fg[1]};${seg.fg[2]}m`;
|
|
36
|
+
if (seg.bg !== undefined)
|
|
37
|
+
out += `${CSI}48;2;${seg.bg[0]};${seg.bg[1]};${seg.bg[2]}m`;
|
|
38
|
+
return out === "" ? text : `${out}${text}${RESET}`;
|
|
39
|
+
}
|
|
40
|
+
/** Cells the segments take on screen — never their character count. */
|
|
41
|
+
export function plainLen(segs) {
|
|
42
|
+
return segs.reduce((n, seg) => n + textWidth(terminalText(seg.text)), 0);
|
|
43
|
+
}
|
|
44
|
+
export function columns() {
|
|
45
|
+
const width = process.stdout.columns;
|
|
46
|
+
return typeof width === "number" && width >= 40 ? width : 80;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The margin every row keeps from the edges of the terminal: none.
|
|
50
|
+
*
|
|
51
|
+
* A window is as wide as its owner made it, and the content column is the whole
|
|
52
|
+
* of it. Everything then shares one left edge and one right edge — prose, the
|
|
53
|
+
* ground behind a message, the rules around the composer, the
|
|
54
|
+
* footer — so nothing on screen is wider than the text inside it. It stays a
|
|
55
|
+
* constant because it is a decision, not an assumption: one number moves the
|
|
56
|
+
* whole grid inwards if it ever needs to.
|
|
57
|
+
*/
|
|
58
|
+
export const PAD = 0;
|
|
59
|
+
/** Keep what fits in `cols`, marking the segment that had to give way. */
|
|
60
|
+
export function fitSegs(segs, cols) {
|
|
61
|
+
const out = [];
|
|
62
|
+
let used = 0;
|
|
63
|
+
for (const seg of segs) {
|
|
64
|
+
const safe = { ...seg, text: terminalText(seg.text) };
|
|
65
|
+
const w = textWidth(safe.text);
|
|
66
|
+
if (used + w <= cols) {
|
|
67
|
+
out.push(safe);
|
|
68
|
+
used += w;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const room = cols - used;
|
|
72
|
+
if (room > 0)
|
|
73
|
+
out.push({ ...safe, text: elide(safe.text, room) });
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* One row: left segments, a gap, right segments, optionally on a ground.
|
|
80
|
+
*
|
|
81
|
+
* The left column is the one that gives way when the two do not both fit —
|
|
82
|
+
* the right column is a summary, and a summary that moves is worse than none.
|
|
83
|
+
* A row with neither a ground nor a right column is left ragged rather than
|
|
84
|
+
* padded: trailing spaces are invisible on screen but not in a paste buffer.
|
|
85
|
+
*/
|
|
86
|
+
export function row(width, left, right = [], ground, padX = PAD) {
|
|
87
|
+
const inner = Math.max(0, width - padX * 2);
|
|
88
|
+
const rightW = plainLen(right);
|
|
89
|
+
const gutter = right.length > 0 ? 1 : 0;
|
|
90
|
+
const fitted = fitSegs(left, Math.max(0, inner - rightW - gutter));
|
|
91
|
+
// An empty pad is no pad at all: a zero-width segment still costs a pair of
|
|
92
|
+
// escape sequences on every row of every frame.
|
|
93
|
+
const lead = padX === 0 ? [] : [{ text: " ".repeat(padX), bg: ground }];
|
|
94
|
+
if (ground === undefined && right.length === 0) {
|
|
95
|
+
// Nothing to draw is an empty row, not an indent: leading spaces are
|
|
96
|
+
// invisible on screen and very visible in a paste buffer.
|
|
97
|
+
if (plainLen(fitted) === 0)
|
|
98
|
+
return "";
|
|
99
|
+
return [...lead, ...fitted].map(paint).join("");
|
|
100
|
+
}
|
|
101
|
+
const gap = Math.max(gutter, inner - plainLen(fitted) - rightW);
|
|
102
|
+
return [
|
|
103
|
+
...lead,
|
|
104
|
+
...fitted.map((seg) => ({ ...seg, bg: seg.bg ?? ground })),
|
|
105
|
+
{ text: " ".repeat(gap), bg: ground },
|
|
106
|
+
...right.map((seg) => ({ ...seg, bg: seg.bg ?? ground })),
|
|
107
|
+
...lead,
|
|
108
|
+
]
|
|
109
|
+
.map(paint)
|
|
110
|
+
.join("");
|
|
111
|
+
}
|
|
112
|
+
export function blank(width, ground) {
|
|
113
|
+
return paint({ text: " ".repeat(width), bg: ground });
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* A full-width divider.
|
|
117
|
+
*
|
|
118
|
+
* Edge to edge, because the other thing that spans a whole row is the ground
|
|
119
|
+
* behind a user's message: two elements that stop at different columns read as
|
|
120
|
+
* two grids, and the eye finds the discrepancy before it finds the text.
|
|
121
|
+
*/
|
|
122
|
+
export function rule(width, color) {
|
|
123
|
+
return paint({ text: "─".repeat(Math.max(0, width)), fg: color });
|
|
124
|
+
}
|
|
125
|
+
export function wrap(text, max, continuation = "") {
|
|
126
|
+
return wrapText(terminalText(text, { multiline: true }), max, continuation);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Word wrap that survives styling: segments flow across rows, and a row breaks
|
|
130
|
+
* between words rather than between a word and its colour.
|
|
131
|
+
*
|
|
132
|
+
* `continuation` opens every row after the first, which is what gives a list
|
|
133
|
+
* item a hanging indent instead of a second bullet.
|
|
134
|
+
*/
|
|
135
|
+
export function flow(segs, max, continuation = []) {
|
|
136
|
+
const lead = plainLen(continuation);
|
|
137
|
+
const toks = [];
|
|
138
|
+
for (const seg of segs) {
|
|
139
|
+
const safe = { ...seg, text: terminalText(seg.text, { multiline: true }) };
|
|
140
|
+
for (const piece of safe.text.split(/(\s+)/)) {
|
|
141
|
+
if (piece === "")
|
|
142
|
+
continue;
|
|
143
|
+
if (piece.includes("\n"))
|
|
144
|
+
toks.push({ seg: safe, text: piece, kind: "break" });
|
|
145
|
+
// A space that paints a background is not whitespace, it is the edge of
|
|
146
|
+
// a semantic surface and must not collapse into its neighbour.
|
|
147
|
+
else if (/^\s+$/.test(piece)) {
|
|
148
|
+
toks.push({ seg: safe, text: piece, kind: safe.bg === undefined ? "space" : "word" });
|
|
149
|
+
}
|
|
150
|
+
else
|
|
151
|
+
toks.push({ seg: safe, text: piece, kind: "word" });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const rows = [];
|
|
155
|
+
let current = [];
|
|
156
|
+
let used = 0;
|
|
157
|
+
let pending;
|
|
158
|
+
const room = () => Math.max(1, max - (rows.length === 0 ? 0 : lead));
|
|
159
|
+
const add = (tok) => {
|
|
160
|
+
const last = current[current.length - 1];
|
|
161
|
+
if (last !== undefined && sameStyle(last, tok.seg))
|
|
162
|
+
last.text += tok.text;
|
|
163
|
+
else
|
|
164
|
+
current.push({ ...tok.seg, text: tok.text });
|
|
165
|
+
used += textWidth(tok.text);
|
|
166
|
+
};
|
|
167
|
+
const flush = () => {
|
|
168
|
+
rows.push(rows.length === 0 ? current : [...continuation.map(copy), ...current]);
|
|
169
|
+
current = [];
|
|
170
|
+
used = 0;
|
|
171
|
+
pending = undefined;
|
|
172
|
+
};
|
|
173
|
+
for (const tok of toks) {
|
|
174
|
+
if (tok.kind === "break") {
|
|
175
|
+
flush();
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (tok.kind === "space") {
|
|
179
|
+
if (current.length > 0)
|
|
180
|
+
pending = tok;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const space = pending === undefined ? 0 : textWidth(pending.text);
|
|
184
|
+
let word = tok.text;
|
|
185
|
+
if (used + space + textWidth(word) > room() && current.length > 0)
|
|
186
|
+
flush();
|
|
187
|
+
else if (pending !== undefined) {
|
|
188
|
+
add(pending);
|
|
189
|
+
pending = undefined;
|
|
190
|
+
}
|
|
191
|
+
// A word wider than any row is spent across rows: with autowrap off, what
|
|
192
|
+
// overflows is not ugly, it is gone.
|
|
193
|
+
while (textWidth(word) > room() - used) {
|
|
194
|
+
const head = clipTo(word, room() - used);
|
|
195
|
+
if (head === "")
|
|
196
|
+
break;
|
|
197
|
+
add({ ...tok, text: head });
|
|
198
|
+
word = word.slice(head.length);
|
|
199
|
+
flush();
|
|
200
|
+
}
|
|
201
|
+
if (word !== "")
|
|
202
|
+
add({ ...tok, text: word });
|
|
203
|
+
}
|
|
204
|
+
if (current.length > 0 || rows.length === 0)
|
|
205
|
+
flush();
|
|
206
|
+
return rows;
|
|
207
|
+
}
|
|
208
|
+
function clipTo(text, cols) {
|
|
209
|
+
let out = "";
|
|
210
|
+
for (const char of text) {
|
|
211
|
+
if (textWidth(out + char) > cols)
|
|
212
|
+
break;
|
|
213
|
+
out += char;
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
function copy(seg) {
|
|
218
|
+
return { ...seg };
|
|
219
|
+
}
|
|
220
|
+
function sameStyle(a, b) {
|
|
221
|
+
return (a.fg === b.fg &&
|
|
222
|
+
a.bg === b.bg &&
|
|
223
|
+
a.bold === b.bold &&
|
|
224
|
+
a.italic === b.italic &&
|
|
225
|
+
a.inverse === b.inverse);
|
|
226
|
+
}
|
|
227
|
+
/** One-line preview of a value, for echoing tool arguments back to the user. */
|
|
228
|
+
export function preview(value, max = 120) {
|
|
229
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
230
|
+
const flat = (text ?? "").replace(/\s+/g, " ").trim();
|
|
231
|
+
return elide(terminalText(flat), max);
|
|
232
|
+
}
|
package/dist/ui/table.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Markdown tables, drawn as a table rather than printed as pipes.
|
|
2
|
+
//
|
|
3
|
+
// A table is the one Markdown construct whose meaning is its geometry: the
|
|
4
|
+
// columns *are* the information. Left as source it is the worst thing on the
|
|
5
|
+
// screen — a wall of `|` where the eye has to do the aligning by hand.
|
|
6
|
+
//
|
|
7
|
+
// Two decisions shape the drawing. Columns are measured in cells, never in
|
|
8
|
+
// characters, so a table of Japanese or emoji lines up like a table of ASCII.
|
|
9
|
+
// And there are no vertical rules: a column of `│` down every gap is four more
|
|
10
|
+
// glyphs per row earning nothing that whitespace and one horizontal rule under
|
|
11
|
+
// the header do not already earn.
|
|
12
|
+
import { fitSegs, plainLen } from "./render.js";
|
|
13
|
+
import { inline } from "./inline.js";
|
|
14
|
+
/** Cells between the gaps. Two spaces reads as a column break; one does not. */
|
|
15
|
+
const GAP = 2;
|
|
16
|
+
/** Narrowest a column may be squeezed to before the text stops being readable. */
|
|
17
|
+
const MIN = 4;
|
|
18
|
+
const DELIMITER = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
|
|
19
|
+
/**
|
|
20
|
+
* Whether a line opens a table.
|
|
21
|
+
*
|
|
22
|
+
* A table is only a table once its delimiter has arrived — which matters,
|
|
23
|
+
* because text streams in. A header row on its own is a paragraph that happens
|
|
24
|
+
* to contain pipes, and it is drawn as one until the next line proves otherwise.
|
|
25
|
+
*/
|
|
26
|
+
export function opens(line, next) {
|
|
27
|
+
return line.includes("|") && next !== undefined && DELIMITER.test(next);
|
|
28
|
+
}
|
|
29
|
+
/** Read the table starting at `start`, and say which line it ended on. */
|
|
30
|
+
export function parse(lines, start) {
|
|
31
|
+
const head = cells(lines[start]);
|
|
32
|
+
const align = lines[start + 1]
|
|
33
|
+
.split("|")
|
|
34
|
+
.map((cell) => cell.trim())
|
|
35
|
+
.filter((cell) => cell !== "")
|
|
36
|
+
.map(alignOf);
|
|
37
|
+
const body = [];
|
|
38
|
+
let i = start + 2;
|
|
39
|
+
for (; i < lines.length; i++) {
|
|
40
|
+
const line = lines[i];
|
|
41
|
+
if (!line.includes("|") || line.trim() === "")
|
|
42
|
+
break;
|
|
43
|
+
body.push(cells(line));
|
|
44
|
+
}
|
|
45
|
+
return { table: { head, body, align }, end: i - 1 };
|
|
46
|
+
}
|
|
47
|
+
function cells(line) {
|
|
48
|
+
const parts = line.split("|").map((cell) => cell.trim());
|
|
49
|
+
// A row may or may not be fenced by outer pipes; both spellings are legal,
|
|
50
|
+
// and the difference is two empty cells nobody wrote.
|
|
51
|
+
if (parts[0] === "")
|
|
52
|
+
parts.shift();
|
|
53
|
+
if (parts.length > 0 && parts[parts.length - 1] === "")
|
|
54
|
+
parts.pop();
|
|
55
|
+
return parts;
|
|
56
|
+
}
|
|
57
|
+
function alignOf(spec) {
|
|
58
|
+
const left = spec.startsWith(":");
|
|
59
|
+
const right = spec.endsWith(":");
|
|
60
|
+
if (left && right)
|
|
61
|
+
return "center";
|
|
62
|
+
return right ? "right" : "left";
|
|
63
|
+
}
|
|
64
|
+
export function draw(table, max, pal) {
|
|
65
|
+
const { ink } = pal;
|
|
66
|
+
const count = Math.max(table.head.length, ...table.body.map((r) => r.length), 1);
|
|
67
|
+
const widths = fit(natural(table, count, pal), max);
|
|
68
|
+
const head = {
|
|
69
|
+
segs: laid(table.head, widths, table, pal, ink.bright, true),
|
|
70
|
+
};
|
|
71
|
+
const under = {
|
|
72
|
+
segs: [{ text: widths.map((w) => "─".repeat(w)).join(" ".repeat(GAP)), fg: pal.rule }],
|
|
73
|
+
};
|
|
74
|
+
const body = table.body.map((cells) => ({
|
|
75
|
+
segs: laid(cells, widths, table, pal, ink.fg, false),
|
|
76
|
+
}));
|
|
77
|
+
return [head, under, ...body];
|
|
78
|
+
}
|
|
79
|
+
/** How wide each column would like to be: its widest cell. */
|
|
80
|
+
function natural(table, count, pal) {
|
|
81
|
+
const widths = Array.from({ length: count }, () => 0);
|
|
82
|
+
for (const cells of [table.head, ...table.body]) {
|
|
83
|
+
cells.forEach((cell, i) => {
|
|
84
|
+
if (i < count)
|
|
85
|
+
widths[i] = Math.max(widths[i], plainLen(inline(cell, pal.ink.fg, pal)));
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return widths;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Squeeze the columns into the room there is, widest first.
|
|
92
|
+
*
|
|
93
|
+
* Taking a cell off the widest column each pass is what keeps a table of one
|
|
94
|
+
* long prose column and three short numeric ones readable: the prose gives way,
|
|
95
|
+
* and the numbers — which cannot be elided without becoming wrong — do not.
|
|
96
|
+
*/
|
|
97
|
+
function fit(widths, max) {
|
|
98
|
+
const out = [...widths];
|
|
99
|
+
const gaps = GAP * (out.length - 1);
|
|
100
|
+
let total = out.reduce((n, w) => n + w, 0) + gaps;
|
|
101
|
+
while (total > max) {
|
|
102
|
+
const widest = out.reduce((best, w, i) => (w > out[best] ? i : best), 0);
|
|
103
|
+
if (out[widest] <= MIN)
|
|
104
|
+
break;
|
|
105
|
+
out[widest] = out[widest] - 1;
|
|
106
|
+
total--;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/** One row's cells clipped to their columns and padded to their alignment. */
|
|
111
|
+
function laid(cells, widths, table, pal, base, bold) {
|
|
112
|
+
const segs = [];
|
|
113
|
+
widths.forEach((width, i) => {
|
|
114
|
+
if (i > 0)
|
|
115
|
+
segs.push({ text: " ".repeat(GAP) });
|
|
116
|
+
const content = fitSegs(inline(cells[i] ?? "", base, pal, bold), width);
|
|
117
|
+
const slack = Math.max(0, width - plainLen(content));
|
|
118
|
+
const align = table.align[i] ?? "left";
|
|
119
|
+
const before = align === "right" ? slack : align === "center" ? Math.floor(slack / 2) : 0;
|
|
120
|
+
if (before > 0)
|
|
121
|
+
segs.push({ text: " ".repeat(before) });
|
|
122
|
+
segs.push(...content);
|
|
123
|
+
if (slack - before > 0)
|
|
124
|
+
segs.push({ text: " ".repeat(slack - before) });
|
|
125
|
+
});
|
|
126
|
+
return segs;
|
|
127
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Text that is safe to hand to a terminal.
|
|
2
|
+
//
|
|
3
|
+
// Styling escapes are owned by the renderer. Everything coming from a model,
|
|
4
|
+
// tool, filename, error, or paste is data, so control characters are made
|
|
5
|
+
// visible before layout as well as before paint. This keeps terminal state and
|
|
6
|
+
// cell measurement in agreement.
|
|
7
|
+
const CONTROL_PICTURE = 0x2400;
|
|
8
|
+
const DELETE_PICTURE = String.fromCodePoint(0x2421);
|
|
9
|
+
const TAB = " ";
|
|
10
|
+
export function terminalText(text, options = {}) {
|
|
11
|
+
let safe = "";
|
|
12
|
+
for (const char of text) {
|
|
13
|
+
const code = char.codePointAt(0);
|
|
14
|
+
if (code === undefined)
|
|
15
|
+
continue;
|
|
16
|
+
if (code === 0x0a && options.multiline === true) {
|
|
17
|
+
safe += "\n";
|
|
18
|
+
}
|
|
19
|
+
else if (code === 0x09) {
|
|
20
|
+
safe += TAB;
|
|
21
|
+
}
|
|
22
|
+
else if (code <= 0x1f) {
|
|
23
|
+
safe += String.fromCodePoint(CONTROL_PICTURE + code);
|
|
24
|
+
}
|
|
25
|
+
else if (code === 0x7f) {
|
|
26
|
+
safe += DELETE_PICTURE;
|
|
27
|
+
}
|
|
28
|
+
else if (code >= 0x80 && code <= 0x9f) {
|
|
29
|
+
safe += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
30
|
+
}
|
|
31
|
+
else if (isBidiControl(code)) {
|
|
32
|
+
safe += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
safe += char;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return safe;
|
|
39
|
+
}
|
|
40
|
+
function isBidiControl(code) {
|
|
41
|
+
return (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069);
|
|
42
|
+
}
|
package/dist/ui/theme.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Jecode's fixed terminal identity. Components depend on semantic tokens, not
|
|
2
|
+
// literal colours, while the product exposes one deliberate dark Steel look.
|
|
3
|
+
// Jecode's fixed dark Steel baseline. Components depend on these semantic
|
|
4
|
+
// roles rather than embedding presentation values of their own.
|
|
5
|
+
export const STEEL = {
|
|
6
|
+
accent: [138, 190, 183],
|
|
7
|
+
accentSoft: [0, 215, 255],
|
|
8
|
+
focus: [95, 135, 255],
|
|
9
|
+
rule: [80, 80, 80],
|
|
10
|
+
ink: {
|
|
11
|
+
fg: [212, 212, 212],
|
|
12
|
+
bright: [212, 212, 212],
|
|
13
|
+
muted: [128, 128, 128],
|
|
14
|
+
attention: [255, 255, 0],
|
|
15
|
+
added: [181, 189, 104],
|
|
16
|
+
removed: [204, 102, 102],
|
|
17
|
+
},
|
|
18
|
+
surface: {
|
|
19
|
+
subtle: [52, 53, 65],
|
|
20
|
+
inset: [40, 40, 50],
|
|
21
|
+
added: [40, 50, 40],
|
|
22
|
+
removed: [60, 40, 40],
|
|
23
|
+
attention: [58, 58, 74],
|
|
24
|
+
},
|
|
25
|
+
};
|