@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 = "/tmp/tui";
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.11",
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
  },