@code-yeongyu/senpi-tui 2026.11.11 → 2026.11.13
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/dist/components/box.js +32 -7
- package/dist/components/markdown-source.d.ts +17 -0
- package/dist/components/markdown-source.js +262 -0
- package/dist/components/markdown.d.ts +7 -0
- package/dist/components/markdown.js +412 -48
- package/dist/components/scroll-view.js +4 -1
- package/dist/components/spacer.js +2 -0
- package/dist/components/text.js +5 -0
- package/dist/components/v-stack.js +11 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/selection-source.d.ts +43 -0
- package/dist/selection-source.js +200 -0
- package/dist/source-ranges.d.ts +25 -0
- package/dist/source-ranges.js +122 -0
- package/dist/tui-alt-screen.d.ts +1 -0
- package/dist/tui-alt-screen.js +20 -5
- package/dist/tui.js +12 -4
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +39 -6
- package/package.json +1 -1
package/dist/components/box.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CompositeRevision, dispatchMouseEvent, } from "../tui.js";
|
|
2
|
+
import { copyLineSources, getLineSources, setLineSource } from "../selection-source.js";
|
|
2
3
|
import { applyBackgroundToLine, flattenLines, visibleWidth } from "../utils.js";
|
|
3
4
|
/**
|
|
4
5
|
* Box component - a container that applies padding and background to all children
|
|
@@ -55,11 +56,29 @@ export class Box {
|
|
|
55
56
|
}
|
|
56
57
|
matchCache(width, childLines, bgSample) {
|
|
57
58
|
const cache = this.cache;
|
|
58
|
-
|
|
59
|
-
cache.width
|
|
60
|
-
cache.bgSample
|
|
61
|
-
cache.childLines.length
|
|
62
|
-
|
|
59
|
+
if (!cache ||
|
|
60
|
+
cache.width !== width ||
|
|
61
|
+
cache.bgSample !== bgSample ||
|
|
62
|
+
cache.childLines.length !== childLines.length) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
const cachedSources = getLineSources(cache.childLines);
|
|
66
|
+
const nextSources = getLineSources(childLines);
|
|
67
|
+
return cache.childLines.every((line, i) => {
|
|
68
|
+
if (line !== childLines[i])
|
|
69
|
+
return false;
|
|
70
|
+
const before = cachedSources?.[i];
|
|
71
|
+
const after = nextSources?.[i];
|
|
72
|
+
if (before === after)
|
|
73
|
+
return true;
|
|
74
|
+
if (!before || !after)
|
|
75
|
+
return false;
|
|
76
|
+
return (before.document === after.document &&
|
|
77
|
+
before.ranges === after.ranges &&
|
|
78
|
+
before.sourceRow === after.sourceRow &&
|
|
79
|
+
before.columnOffset === after.columnOffset &&
|
|
80
|
+
before.unavailable === after.unavailable);
|
|
81
|
+
});
|
|
63
82
|
}
|
|
64
83
|
invalidate() {
|
|
65
84
|
this.invalidateCache();
|
|
@@ -115,10 +134,12 @@ export class Box {
|
|
|
115
134
|
const mouseChildren = [];
|
|
116
135
|
for (const child of this.children) {
|
|
117
136
|
const lines = child.render(contentWidth);
|
|
137
|
+
const offset = childLines.length;
|
|
118
138
|
mouseChildren.push({ component: child, height: lines.length });
|
|
119
139
|
for (const line of lines) {
|
|
120
140
|
childLines.push(line);
|
|
121
141
|
}
|
|
142
|
+
copyLineSources(lines, childLines, offset);
|
|
122
143
|
}
|
|
123
144
|
this.mouseLayout = { width: contentWidth, children: mouseChildren };
|
|
124
145
|
if (childLines.length === 0) {
|
|
@@ -127,14 +148,16 @@ export class Box {
|
|
|
127
148
|
// Check if bgFn output changed by sampling
|
|
128
149
|
const bgSample = this.bgFn ? this.bgFn("test") : undefined;
|
|
129
150
|
// Check cache validity
|
|
130
|
-
|
|
131
|
-
|
|
151
|
+
const cache = this.cache;
|
|
152
|
+
if (cache && this.matchCache(width, childLines, bgSample)) {
|
|
153
|
+
return cache.lines;
|
|
132
154
|
}
|
|
133
155
|
// Apply background and padding
|
|
134
156
|
const result = [];
|
|
135
157
|
// Top padding
|
|
136
158
|
for (let i = 0; i < this.paddingY; i++) {
|
|
137
159
|
result.push(this.applyBg("", width));
|
|
160
|
+
setLineSource(result, result.length - 1, null);
|
|
138
161
|
}
|
|
139
162
|
// Content
|
|
140
163
|
for (const line of childLines) {
|
|
@@ -143,9 +166,11 @@ export class Box {
|
|
|
143
166
|
// Bottom padding
|
|
144
167
|
for (let i = 0; i < this.paddingY; i++) {
|
|
145
168
|
result.push(this.applyBg("", width));
|
|
169
|
+
setLineSource(result, result.length - 1, null);
|
|
146
170
|
}
|
|
147
171
|
// Update cache
|
|
148
172
|
flattenLines(result);
|
|
173
|
+
copyLineSources(childLines, result, this.paddingY, this.paddingX);
|
|
149
174
|
this.cache = { childLines, width, bgSample, lines: result };
|
|
150
175
|
return result;
|
|
151
176
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type MarkedOptions, type Token, type TokenizerExtension } from "marked";
|
|
2
|
+
import type { SourceDocument } from "../selection-source.ts";
|
|
3
|
+
import { type SourceRanges } from "../source-ranges.ts";
|
|
4
|
+
interface MappedText {
|
|
5
|
+
readonly text: string;
|
|
6
|
+
readonly ranges: SourceRanges;
|
|
7
|
+
readonly document: SourceDocument;
|
|
8
|
+
}
|
|
9
|
+
export declare const sourceExtensions: TokenizerExtension[];
|
|
10
|
+
export declare function lexWithSources(text: string, options: MarkedOptions): Token[];
|
|
11
|
+
export declare function tokenSource(token: Token): {
|
|
12
|
+
raw: MappedText;
|
|
13
|
+
text?: MappedText;
|
|
14
|
+
href?: MappedText;
|
|
15
|
+
} | undefined;
|
|
16
|
+
export {};
|
|
17
|
+
//# sourceMappingURL=markdown-source.d.ts.map
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { Lexer } from "marked";
|
|
2
|
+
import { concatRanges, linearRanges, repeatedRange, sliceRanges } from "../source-ranges.js";
|
|
3
|
+
const locations = new WeakMap();
|
|
4
|
+
const tokenSources = new WeakMap();
|
|
5
|
+
/** Observe actual consumed lexer input, including tokens Marked merges or discards. */
|
|
6
|
+
class SourceLexer extends Lexer {
|
|
7
|
+
record(remaining, tokens) {
|
|
8
|
+
const frame = this.frame;
|
|
9
|
+
if (!frame)
|
|
10
|
+
return;
|
|
11
|
+
const offset = frame.text.length - remaining.length;
|
|
12
|
+
const last = tokens.at(-1);
|
|
13
|
+
if (last && (tokens.length !== frame.count || last.raw !== frame.lastRaw)) {
|
|
14
|
+
const previous = locations.get(last);
|
|
15
|
+
locations.set(last, {
|
|
16
|
+
frame,
|
|
17
|
+
start: previous?.frame === frame ? previous.start : frame.offset,
|
|
18
|
+
end: offset,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
frame.offset = offset;
|
|
22
|
+
frame.count = tokens.length;
|
|
23
|
+
frame.lastRaw = last?.raw;
|
|
24
|
+
}
|
|
25
|
+
blockTokens(src, tokens = [], clipped = false) {
|
|
26
|
+
const outer = this.frame;
|
|
27
|
+
this.frame = { text: src, offset: 0, count: tokens.length, lastRaw: tokens.at(-1)?.raw };
|
|
28
|
+
try {
|
|
29
|
+
const result = super.blockTokens(src, tokens, clipped);
|
|
30
|
+
this.record("", result);
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
this.frame = outer;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
inlineTokens(src, tokens = []) {
|
|
38
|
+
const outer = this.frame;
|
|
39
|
+
this.frame = { text: src, offset: 0, count: tokens.length, lastRaw: tokens.at(-1)?.raw };
|
|
40
|
+
try {
|
|
41
|
+
const result = super.inlineTokens(src, tokens);
|
|
42
|
+
this.record("", result);
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
this.frame = outer;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export const sourceExtensions = ["block", "inline"].map((level) => ({
|
|
51
|
+
name: `source_${level}`,
|
|
52
|
+
level: level === "block" ? "block" : "inline",
|
|
53
|
+
tokenizer(src, tokens = []) {
|
|
54
|
+
if (this.lexer instanceof SourceLexer)
|
|
55
|
+
this.lexer.record(src, tokens);
|
|
56
|
+
return undefined;
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
function sliceSource(source, start, end = source.text.length) {
|
|
60
|
+
return {
|
|
61
|
+
document: source.document,
|
|
62
|
+
text: source.text.slice(start, end),
|
|
63
|
+
ranges: sliceRanges(source.ranges, start, end),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function joinSource(parts, document) {
|
|
67
|
+
return {
|
|
68
|
+
document,
|
|
69
|
+
text: parts.map((part) => part.text).join(""),
|
|
70
|
+
ranges: concatRanges(parts.map((part) => part.ranges)),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** A parser normalization expressed as explicit consumed spans, never a search for token text. */
|
|
74
|
+
function replaceSource(source, pattern, replacement) {
|
|
75
|
+
const parts = [];
|
|
76
|
+
let offset = 0;
|
|
77
|
+
for (const match of source.text.matchAll(pattern)) {
|
|
78
|
+
parts.push(sliceSource(source, offset, match.index), replacement(match));
|
|
79
|
+
offset = match.index + match[0].length;
|
|
80
|
+
}
|
|
81
|
+
parts.push(sliceSource(source, offset));
|
|
82
|
+
return joinSource(parts, source.document);
|
|
83
|
+
}
|
|
84
|
+
function remove(source, pattern) {
|
|
85
|
+
return replaceSource(source, pattern, (match) => sliceSource(source, match.index, match.index));
|
|
86
|
+
}
|
|
87
|
+
function trim(source) {
|
|
88
|
+
return sliceSource(source, source.text.length - source.text.trimStart().length, source.text.trimEnd().length);
|
|
89
|
+
}
|
|
90
|
+
function normalizedText(token, raw) {
|
|
91
|
+
switch (token.type) {
|
|
92
|
+
case "paragraph":
|
|
93
|
+
case "text":
|
|
94
|
+
case "html":
|
|
95
|
+
case "latex_literal":
|
|
96
|
+
return sliceSource(raw, 0, token.text.length);
|
|
97
|
+
case "latex_inline":
|
|
98
|
+
case "latex_block": {
|
|
99
|
+
const formula = trim(raw);
|
|
100
|
+
const delimiter = token.type === "latex_block" || formula.text.startsWith("\\") ? 2 : 1;
|
|
101
|
+
return trim(sliceSource(formula, delimiter, -delimiter));
|
|
102
|
+
}
|
|
103
|
+
case "heading": {
|
|
104
|
+
if (/^ {0,3}#{1,6}(?:\s|$)/.test(raw.text)) {
|
|
105
|
+
const prefix = /^ {0,3}#{1,6}(?:\s+|$)/.exec(raw.text)?.[0].length ?? 0;
|
|
106
|
+
return sliceSource(raw, prefix, prefix + token.text.length);
|
|
107
|
+
}
|
|
108
|
+
return trim(remove(raw, /\n {0,3}(?:=+|-+)[ \t]*(?:\n|$)/g));
|
|
109
|
+
}
|
|
110
|
+
case "strong":
|
|
111
|
+
case "em":
|
|
112
|
+
case "del": {
|
|
113
|
+
const size = token.type === "em" ? 1 : 2;
|
|
114
|
+
return sliceSource(raw, size, raw.text.length - size);
|
|
115
|
+
}
|
|
116
|
+
case "escape":
|
|
117
|
+
return sliceSource(raw, 1);
|
|
118
|
+
case "codespan": {
|
|
119
|
+
const size = /^`+/.exec(raw.text)?.[0].length ?? 0;
|
|
120
|
+
let text = sliceSource(raw, size, raw.text.length - size);
|
|
121
|
+
text = replaceSource(text, /\n/g, (match) => ({
|
|
122
|
+
...sliceSource(text, match.index, match.index + 1),
|
|
123
|
+
text: " ",
|
|
124
|
+
}));
|
|
125
|
+
if (/^ .* $/.test(text.text) && /[^ ]/.test(text.text))
|
|
126
|
+
text = sliceSource(text, 1, -1);
|
|
127
|
+
return text;
|
|
128
|
+
}
|
|
129
|
+
case "link":
|
|
130
|
+
case "image": {
|
|
131
|
+
const prefix = raw.text.startsWith("![") ? 2 : raw.text.startsWith("[") || raw.text.startsWith("<") ? 1 : 0;
|
|
132
|
+
if (token.type === "link" && token.autolink) {
|
|
133
|
+
return sliceSource(raw, prefix, prefix + token.text.length);
|
|
134
|
+
}
|
|
135
|
+
// Marked unescapes bracket escapes in labels before inline lexing.
|
|
136
|
+
const text = remove(sliceSource(raw, prefix), /\\(?=[[\]])/g);
|
|
137
|
+
return sliceSource(text, 0, token.text.length);
|
|
138
|
+
}
|
|
139
|
+
case "code": {
|
|
140
|
+
if (token.codeBlockStyle === "indented") {
|
|
141
|
+
return sliceSource(remove(raw, /^(?: {0,3}\t| {1,4})/gm), 0, token.text.length);
|
|
142
|
+
}
|
|
143
|
+
const fence = new RegExp(Lexer.rules.block.gfm.fences.source, "d").exec(raw.text);
|
|
144
|
+
if (!fence)
|
|
145
|
+
return undefined;
|
|
146
|
+
const capture = fence.indices?.[3];
|
|
147
|
+
let text = capture ? sliceSource(raw, capture[0], capture[1]) : sliceSource(raw, 0, 0);
|
|
148
|
+
const indent = /^(\s+)(?:```)/.exec(raw.text)?.[1].length ?? 0;
|
|
149
|
+
if (indent)
|
|
150
|
+
text = remove(text, new RegExp(`^[^\\S\\n]{1,${indent}}`, "gm"));
|
|
151
|
+
return sliceSource(text, 0, token.text.length);
|
|
152
|
+
}
|
|
153
|
+
default:
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function mapTokens(tokens, input) {
|
|
158
|
+
for (const token of tokens) {
|
|
159
|
+
const location = locations.get(token);
|
|
160
|
+
if (!location || location.frame.text !== input.text)
|
|
161
|
+
continue;
|
|
162
|
+
locations.delete(token);
|
|
163
|
+
let raw = sliceSource(input, location.start, location.end);
|
|
164
|
+
// Marked removes tight task checkboxes after the block lexer has consumed them.
|
|
165
|
+
const checkbox = /^\[[ xX]\][ \t]+/.exec(raw.text);
|
|
166
|
+
if (checkbox && (token.type === "paragraph" || token.type === "text") && !/^\[[ xX]\]/.test(token.raw)) {
|
|
167
|
+
raw = sliceSource(raw, checkbox[0].length);
|
|
168
|
+
}
|
|
169
|
+
const text = normalizedText(token, raw);
|
|
170
|
+
const validText = text && "text" in token && text.text === token.text ? text : undefined;
|
|
171
|
+
let href;
|
|
172
|
+
if (token.type === "link") {
|
|
173
|
+
const capture = new RegExp(Lexer.rules.inline.gfm.link.source, "d").exec(raw.text)?.indices?.[2];
|
|
174
|
+
if (capture) {
|
|
175
|
+
href = trim(sliceSource(raw, capture[0], capture[1]));
|
|
176
|
+
if (href.text.startsWith("<"))
|
|
177
|
+
href = sliceSource(href, 1, -1);
|
|
178
|
+
href = remove(href, /\\(?=[!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~])/g);
|
|
179
|
+
if (href.text !== token.href)
|
|
180
|
+
href = undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
tokenSources.set(token, { raw, text: validText, href });
|
|
184
|
+
if (token.type === "list") {
|
|
185
|
+
let offset = 0;
|
|
186
|
+
for (const item of token.items) {
|
|
187
|
+
const itemRaw = sliceSource(raw, offset, offset + item.raw.length);
|
|
188
|
+
offset += item.raw.length;
|
|
189
|
+
tokenSources.set(item, { raw: itemRaw });
|
|
190
|
+
const marker = /^( {0,3}(?:[*+-]|\d{1,9}[.)]))( *)([^\n]*)/.exec(itemRaw.text);
|
|
191
|
+
if (!marker)
|
|
192
|
+
continue;
|
|
193
|
+
const padding = marker[2].length > 4 ? 1 : marker[2].length;
|
|
194
|
+
const indent = marker[1].length + padding;
|
|
195
|
+
let contents = sliceSource(itemRaw, indent);
|
|
196
|
+
contents = remove(contents, new RegExp(`(?<=\\n)(?: {${indent}}| {1,${indent}}(?=\\n|$))`, "g"));
|
|
197
|
+
// The list tokenizer consumes each line's terminator in raw, but appends
|
|
198
|
+
// newlines to itemContents only before the next line.
|
|
199
|
+
if (contents.text.endsWith("\n"))
|
|
200
|
+
contents = sliceSource(contents, 0, -1);
|
|
201
|
+
// The last item is trimmed by Marked before its child lexer runs.
|
|
202
|
+
if (item === token.items.at(-1))
|
|
203
|
+
contents = sliceSource(contents, 0, contents.text.trimEnd().length);
|
|
204
|
+
mapTokens(item.tokens, contents);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else if (token.type === "blockquote") {
|
|
208
|
+
const quote = remove(raw, /^ {0,3}>[ \t]?/gm);
|
|
209
|
+
mapTokens(token.tokens ?? [], sliceSource(quote, 0, quote.text.replace(/\n+$/, "").length));
|
|
210
|
+
}
|
|
211
|
+
else if (token.type === "table") {
|
|
212
|
+
// Cell locations are consumed in source row order, including escaped pipes.
|
|
213
|
+
const rows = [...raw.text.matchAll(/[^\n]+/g)];
|
|
214
|
+
const cells = [token.header, ...token.rows];
|
|
215
|
+
for (let row = 0; row < cells.length; row++) {
|
|
216
|
+
const line = rows[row === 0 ? 0 : row + 1];
|
|
217
|
+
if (!line)
|
|
218
|
+
continue;
|
|
219
|
+
let start = /^\s*\|/.exec(line[0])?.[0].length ?? 0;
|
|
220
|
+
let cell = 0;
|
|
221
|
+
for (let cursor = start; cursor <= line[0].length; cursor++) {
|
|
222
|
+
if (line[0][cursor] === "\\") {
|
|
223
|
+
cursor++;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (cursor < line[0].length && line[0][cursor] !== "|")
|
|
227
|
+
continue;
|
|
228
|
+
const target = cells[row][cell++];
|
|
229
|
+
if (!target)
|
|
230
|
+
break;
|
|
231
|
+
const mapped = remove(trim(sliceSource(raw, line.index + start, line.index + cursor)), /\\(?=\|)/g);
|
|
232
|
+
mapTokens(target.tokens, mapped);
|
|
233
|
+
start = cursor + 1;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
else if (validText && "tokens" in token && token.tokens) {
|
|
238
|
+
const childText = token.tokens[0]?.type === "checkbox" ? remove(validText, /^\[[ xX]\][ \t]+/g) : validText;
|
|
239
|
+
mapTokens(token.tokens, childText);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
export function lexWithSources(text, options) {
|
|
244
|
+
const document = { text, enclosures: [] };
|
|
245
|
+
let source = {
|
|
246
|
+
document,
|
|
247
|
+
text,
|
|
248
|
+
ranges: linearRanges(0, text.length),
|
|
249
|
+
};
|
|
250
|
+
source = replaceSource(source, /\t|\r\n|\r/g, (match) => {
|
|
251
|
+
const range = { start: match.index, end: match.index + match[0].length };
|
|
252
|
+
const replacement = match[0] === "\t" ? " " : "\n";
|
|
253
|
+
return { document, text: replacement, ranges: repeatedRange(range, replacement.length) };
|
|
254
|
+
});
|
|
255
|
+
const tokens = new SourceLexer(options).lex(source.text);
|
|
256
|
+
mapTokens(tokens, source);
|
|
257
|
+
return tokens;
|
|
258
|
+
}
|
|
259
|
+
export function tokenSource(token) {
|
|
260
|
+
return tokenSources.get(token);
|
|
261
|
+
}
|
|
262
|
+
//# sourceMappingURL=markdown-source.js.map
|
|
@@ -67,6 +67,8 @@ export declare class Markdown implements Component {
|
|
|
67
67
|
private theme;
|
|
68
68
|
private options;
|
|
69
69
|
private defaultStylePrefix?;
|
|
70
|
+
private activeSourceDocument?;
|
|
71
|
+
private collectSources;
|
|
70
72
|
private cachedText?;
|
|
71
73
|
private cachedWidth?;
|
|
72
74
|
private cachedLines?;
|
|
@@ -79,6 +81,10 @@ export declare class Markdown implements Component {
|
|
|
79
81
|
/** Exact `Markdown` instances only; a subclass may render more than its source text. */
|
|
80
82
|
getRenderRevision(): number | undefined;
|
|
81
83
|
render(width: number): string[];
|
|
84
|
+
/** Shared render caches must not make two separate messages one source interval. */
|
|
85
|
+
private ownSources;
|
|
86
|
+
private wrapSource;
|
|
87
|
+
private enclose;
|
|
82
88
|
/**
|
|
83
89
|
* Apply default text style to a string.
|
|
84
90
|
* This is the base styling applied to all text content.
|
|
@@ -121,5 +127,6 @@ export declare class Markdown implements Component {
|
|
|
121
127
|
* Cells that don't fit are wrapped to multiple lines.
|
|
122
128
|
*/
|
|
123
129
|
private renderTable;
|
|
130
|
+
private mapTableRow;
|
|
124
131
|
}
|
|
125
132
|
//# sourceMappingURL=markdown.d.ts.map
|