@code-yeongyu/senpi-tui 2026.10.11 → 2026.10.12
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.
|
@@ -4,10 +4,17 @@ export interface EditorLineCursor {
|
|
|
4
4
|
readonly marker: string;
|
|
5
5
|
readonly drawFakeCursor: boolean;
|
|
6
6
|
}
|
|
7
|
+
/** A keyword-run piece on one row; `runStart` may be negative when the run began on an earlier row. */
|
|
8
|
+
export interface EditorKeywordRange extends MentionRange {
|
|
9
|
+
readonly runStart: number;
|
|
10
|
+
readonly runLength: number;
|
|
11
|
+
}
|
|
7
12
|
export interface EditorLineRenderInput {
|
|
8
13
|
readonly text: string;
|
|
9
14
|
readonly mentions: readonly MentionRange[];
|
|
10
15
|
readonly mentionStyle: (text: string) => string;
|
|
16
|
+
readonly keywords?: readonly EditorKeywordRange[];
|
|
17
|
+
readonly keywordStyle?: (text: string, index: number, length: number) => string;
|
|
11
18
|
readonly cursor: EditorLineCursor | undefined;
|
|
12
19
|
readonly firstGrapheme: (text: string) => string;
|
|
13
20
|
}
|
|
@@ -16,6 +23,8 @@ export interface EditorLineRenderResult {
|
|
|
16
23
|
/** True when a fake cursor cell was appended past the text (adds one column). */
|
|
17
24
|
readonly cursorAppended: boolean;
|
|
18
25
|
}
|
|
26
|
+
/** Splits runs found in `lines.join("\n")` into per-logical-line pieces keyed by line index. */
|
|
27
|
+
export declare function keywordRangesByLine(lines: readonly string[], runs: readonly MentionRange[]): Map<number, EditorKeywordRange[]>;
|
|
19
28
|
/**
|
|
20
29
|
* Compose one visible editor row: mention ranges get `mentionStyle`, the
|
|
21
30
|
* cursor grapheme gets reverse video, and every fragment is styled on its
|
|
@@ -4,6 +4,39 @@ function clampMentions(mentions, length) {
|
|
|
4
4
|
.map((range) => ({ start: Math.max(0, range.start), end: Math.min(length, range.end) }))
|
|
5
5
|
.filter((range) => range.start < range.end);
|
|
6
6
|
}
|
|
7
|
+
/** Splits runs found in `lines.join("\n")` into per-logical-line pieces keyed by line index. */
|
|
8
|
+
export function keywordRangesByLine(lines, runs) {
|
|
9
|
+
const byLine = new Map();
|
|
10
|
+
let lineStart = 0;
|
|
11
|
+
for (let line = 0; line < lines.length; line++) {
|
|
12
|
+
const lineEnd = lineStart + (lines[line]?.length ?? 0);
|
|
13
|
+
for (const run of runs) {
|
|
14
|
+
const start = Math.max(run.start, lineStart);
|
|
15
|
+
const end = Math.min(run.end, lineEnd);
|
|
16
|
+
if (start >= end)
|
|
17
|
+
continue;
|
|
18
|
+
const pieces = byLine.get(line) ?? [];
|
|
19
|
+
pieces.push({
|
|
20
|
+
start: start - lineStart,
|
|
21
|
+
end: end - lineStart,
|
|
22
|
+
runStart: run.start - lineStart,
|
|
23
|
+
runLength: run.end - run.start,
|
|
24
|
+
});
|
|
25
|
+
byLine.set(line, pieces);
|
|
26
|
+
}
|
|
27
|
+
lineStart = lineEnd + 1;
|
|
28
|
+
}
|
|
29
|
+
return byLine;
|
|
30
|
+
}
|
|
31
|
+
function styleKeywordSegment(segment, from, range, style) {
|
|
32
|
+
let out = "";
|
|
33
|
+
let offset = from;
|
|
34
|
+
for (const char of segment) {
|
|
35
|
+
out += style(char, offset - range.runStart, range.runLength);
|
|
36
|
+
offset += char.length;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
7
40
|
function cutPoints(length, mentions, cursorGlyph) {
|
|
8
41
|
const cuts = new Set([0, length]);
|
|
9
42
|
for (const range of mentions) {
|
|
@@ -24,11 +57,16 @@ function cutPoints(length, mentions, cursorGlyph) {
|
|
|
24
57
|
export function renderEditorLine(input) {
|
|
25
58
|
const { text, cursor } = input;
|
|
26
59
|
const mentions = clampMentions(input.mentions, text.length);
|
|
60
|
+
const keywordStyle = input.keywordStyle;
|
|
61
|
+
const keywords = keywordStyle
|
|
62
|
+
? (input.keywords ?? []).filter((range) => Math.max(0, range.start) < Math.min(text.length, range.end) &&
|
|
63
|
+
!mentions.some((mention) => range.start < mention.end && mention.start < range.end))
|
|
64
|
+
: [];
|
|
27
65
|
const cursorInText = cursor !== undefined && cursor.pos < text.length;
|
|
28
66
|
const cursorGlyph = cursorInText && cursor.drawFakeCursor
|
|
29
67
|
? { start: cursor.pos, end: cursor.pos + input.firstGrapheme(text.slice(cursor.pos)).length }
|
|
30
68
|
: undefined;
|
|
31
|
-
const points = cutPoints(text.length, mentions, cursorGlyph);
|
|
69
|
+
const points = cutPoints(text.length, [...mentions, ...clampMentions(keywords, text.length)], cursorGlyph);
|
|
32
70
|
let out = "";
|
|
33
71
|
for (let index = 0; index < points.length - 1; index++) {
|
|
34
72
|
const from = points[index] ?? 0;
|
|
@@ -42,6 +80,10 @@ export function renderEditorLine(input) {
|
|
|
42
80
|
else if (mentions.some((range) => from >= range.start && to <= range.end)) {
|
|
43
81
|
out += input.mentionStyle(segment);
|
|
44
82
|
}
|
|
83
|
+
else if (keywordStyle) {
|
|
84
|
+
const keyword = keywords.find((range) => from >= range.start && to <= range.end);
|
|
85
|
+
out += keyword ? styleKeywordSegment(segment, from, keyword, keywordStyle) : segment;
|
|
86
|
+
}
|
|
45
87
|
else {
|
|
46
88
|
out += segment;
|
|
47
89
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AutocompleteProvider } from "../autocomplete.ts";
|
|
1
|
+
import type { AutocompleteProvider, MentionRange } from "../autocomplete.ts";
|
|
2
2
|
import type { EditorSubmitDetails } from "../editor-component.ts";
|
|
3
3
|
import { type EditorImageState } from "../image-markers.ts";
|
|
4
4
|
import { type EditorPasteState } from "../paste-markers.ts";
|
|
@@ -27,10 +27,22 @@ export interface TextChunk {
|
|
|
27
27
|
* @returns Array of chunks with text and position information
|
|
28
28
|
*/
|
|
29
29
|
export declare function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[]): TextChunk[];
|
|
30
|
+
/**
|
|
31
|
+
* Per-character decoration for keyword runs found in the whole editor text
|
|
32
|
+
* (lines joined with "\n"). `style` receives one character plus its offset
|
|
33
|
+
* within the run and the run length, so a gradient stays continuous across
|
|
34
|
+
* wrapped rows and logical lines. Decoration only: it never changes widths.
|
|
35
|
+
*/
|
|
36
|
+
export interface EditorKeywordHighlight {
|
|
37
|
+
find: (text: string) => readonly MentionRange[];
|
|
38
|
+
style: (text: string, index: number, length: number) => string;
|
|
39
|
+
}
|
|
30
40
|
export interface EditorTheme {
|
|
31
41
|
borderColor: (str: string) => string;
|
|
32
42
|
/** Style for resolved mention tokens (for example a known `$skill`); omit to render them plain. */
|
|
33
43
|
mention?: (str: string) => string;
|
|
44
|
+
/** Optional keyword-run decoration; a mention or the cursor grapheme takes precedence over it. */
|
|
45
|
+
keywordHighlight?: EditorKeywordHighlight;
|
|
34
46
|
/**
|
|
35
47
|
* Theme for the autocomplete/slash SelectList built by
|
|
36
48
|
* createAutocompleteList(). May carry the optional `renderRow` seam (see
|
|
@@ -61,6 +73,7 @@ export declare class Editor implements Component, Focusable {
|
|
|
61
73
|
private renderedVisibleLineCount;
|
|
62
74
|
private renderedAutocompleteHeight;
|
|
63
75
|
private scrollOffset;
|
|
76
|
+
private keywordRunsCache?;
|
|
64
77
|
borderColor: (str: string) => string;
|
|
65
78
|
private autocompleteProvider?;
|
|
66
79
|
private autocompleteTriggerCharacters;
|
|
@@ -153,6 +166,7 @@ export declare class Editor implements Component, Focusable {
|
|
|
153
166
|
private getWrappedLine;
|
|
154
167
|
protected renderTopBorder(width: number, hiddenLineCount: number): string;
|
|
155
168
|
protected renderBottomBorder(width: number, hiddenLineCount: number): string;
|
|
169
|
+
private keywordRangesByLine;
|
|
156
170
|
render(width: number): string[];
|
|
157
171
|
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined;
|
|
158
172
|
handleInput(data: string): void;
|
|
@@ -9,7 +9,7 @@ import { CURSOR_MARKER, } from "../tui.js";
|
|
|
9
9
|
import { UndoStack } from "../undo-stack.js";
|
|
10
10
|
import { autocompleteBoundaryRegex, autocompleteSeparatorRegex, cjkBreakRegex, getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, sliceByColumn, visibleWidth, } from "../utils.js";
|
|
11
11
|
import { findWordBackward, findWordForward } from "../word-navigation.js";
|
|
12
|
-
import { renderEditorLine } from "./editor-line-render.js";
|
|
12
|
+
import { keywordRangesByLine, renderEditorLine } from "./editor-line-render.js";
|
|
13
13
|
import { SelectList } from "./select-list.js";
|
|
14
14
|
const graphemeSegmenter = getGraphemeSegmenter();
|
|
15
15
|
const wordSegmenter = getWordSegmenter();
|
|
@@ -461,6 +461,13 @@ export class Editor {
|
|
|
461
461
|
const border = hiddenLineCount > 0 ? createScrollBorder("↓", hiddenLineCount, width) : "─".repeat(width);
|
|
462
462
|
return this.borderColor(border);
|
|
463
463
|
}
|
|
464
|
+
keywordRangesByLine(highlight) {
|
|
465
|
+
const text = this.state.lines.join("\n");
|
|
466
|
+
if (this.keywordRunsCache?.text !== text) {
|
|
467
|
+
this.keywordRunsCache = { text, byLine: keywordRangesByLine(this.state.lines, highlight.find(text)) };
|
|
468
|
+
}
|
|
469
|
+
return this.keywordRunsCache.byLine;
|
|
470
|
+
}
|
|
464
471
|
render(width) {
|
|
465
472
|
const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
|
|
466
473
|
const paddingX = Math.min(this.paddingX, maxPadding);
|
|
@@ -518,6 +525,14 @@ export class Editor {
|
|
|
518
525
|
end: range.end - layoutLine.startIndex,
|
|
519
526
|
}));
|
|
520
527
|
};
|
|
528
|
+
const keywordHighlight = this.theme.keywordHighlight;
|
|
529
|
+
const keywordRangesByLine = keywordHighlight ? this.keywordRangesByLine(keywordHighlight) : undefined;
|
|
530
|
+
const keywordRangesFor = (layoutLine) => (keywordRangesByLine?.get(layoutLine.logicalLine) ?? []).map((range) => ({
|
|
531
|
+
start: range.start - layoutLine.startIndex,
|
|
532
|
+
end: range.end - layoutLine.startIndex,
|
|
533
|
+
runStart: range.runStart - layoutLine.startIndex,
|
|
534
|
+
runLength: range.runLength,
|
|
535
|
+
}));
|
|
521
536
|
for (const layoutLine of visibleLines) {
|
|
522
537
|
let lineVisibleWidth = visibleWidth(layoutLine.text);
|
|
523
538
|
let cursorInPadding = false;
|
|
@@ -529,6 +544,8 @@ export class Editor {
|
|
|
529
544
|
text: layoutLine.text,
|
|
530
545
|
mentions: mentionRangesFor(layoutLine),
|
|
531
546
|
mentionStyle: mentionStyle ?? ((text) => text),
|
|
547
|
+
keywords: keywordRangesFor(layoutLine),
|
|
548
|
+
keywordStyle: keywordHighlight?.style,
|
|
532
549
|
cursor,
|
|
533
550
|
firstGrapheme: (text) => [...this.segment(text, "grapheme")][0]?.segment ?? "",
|
|
534
551
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export { type AutocompleteItem, type AutocompleteProvider, type AutocompleteSugg
|
|
|
5
5
|
export { backgroundAnsi, type Color, type ColorMixSpace, colorToHex, colorToOkhsl, colorToOklch, colorToRgb, foregroundAnsi, type IndexedColor, indexedColor, mixColors, type OkhslChannels, type OklchChannels, type OklchColorValue, okhslColor, oklchColor, parseColor, type RgbColorValue, rgbColor, styleText, styleTextWithAnsi, type TerminalColorMode, type TextAttributes, type TextStyle, } from "./colors.ts";
|
|
6
6
|
export { Box } from "./components/box.ts";
|
|
7
7
|
export { CancellableLoader } from "./components/cancellable-loader.ts";
|
|
8
|
-
export { Editor, type EditorOptions, type EditorTheme, } from "./components/editor.ts";
|
|
8
|
+
export { Editor, type EditorKeywordHighlight, type EditorOptions, type EditorTheme, } from "./components/editor.ts";
|
|
9
9
|
export { HStack } from "./components/h-stack.ts";
|
|
10
10
|
export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts";
|
|
11
11
|
export { Input } from "./components/input.ts";
|
package/dist/tui.js
CHANGED
|
@@ -2829,7 +2829,7 @@ export class TuiBase extends Container {
|
|
|
2829
2829
|
}
|
|
2830
2830
|
buffer = this.finishFrame(buffer, cursorPos, newLines.length, finalCursorRow);
|
|
2831
2831
|
if (process.env.PI_TUI_DEBUG === "1") {
|
|
2832
|
-
const debugDir = "
|
|
2832
|
+
const debugDir = path.join(os.tmpdir(), "tui");
|
|
2833
2833
|
fs.mkdirSync(debugDir, { recursive: true });
|
|
2834
2834
|
const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
|
|
2835
2835
|
const debugData = [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-tui",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.12",
|
|
4
4
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"build:native:darwin": "native/darwin/build.sh",
|
|
11
11
|
"build:native:linux": "native/linux/build.sh",
|
|
12
12
|
"build:native:win32": "node native/win32/build.mjs",
|
|
13
|
-
"test": "node --test --import tsx --import ./test/setup-multiplexer-env.mjs --test-reporter=dot --test-reporter-destination=stdout test/*.test.ts",
|
|
13
|
+
"test": "node ../../scripts/run-tests.mjs node --test --import tsx --import ./test/setup-multiplexer-env.mjs --test-reporter=dot --test-reporter-destination=stdout test/*.test.ts",
|
|
14
14
|
"prepublishOnly": "npm run clean && npm run build",
|
|
15
15
|
"bench:frame-cost": "tsx bench/frame-cost.ts"
|
|
16
16
|
},
|