@apholdings/jensen-tui 0.0.1 → 0.0.3

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.
@@ -84,6 +84,8 @@ export class Editor {
84
84
  cursorLine: 0,
85
85
  cursorCol: 0,
86
86
  };
87
+ promptBg;
88
+ promptChromeBg;
87
89
  /** Focusable interface - set by TUI when focus changes */
88
90
  focused = false;
89
91
  tui;
@@ -99,7 +101,7 @@ export class Editor {
99
101
  promptGlyph = DEFAULT_PROMPT_GLYPH;
100
102
  placeholderText = "";
101
103
  promptGlyphStyle = (str) => str;
102
- placeholderStyle = (str) => `\x1b[90m${str}\x1b[0m`;
104
+ placeholderStyle = (str) => `\x1b[90m${str}\x1b[39m`;
103
105
  // Autocomplete support
104
106
  autocompleteProvider;
105
107
  autocompleteList;
@@ -131,6 +133,8 @@ export class Editor {
131
133
  this.tui = tui;
132
134
  this.theme = theme;
133
135
  this.borderColor = theme.borderColor;
136
+ this.promptBg = theme.promptBg;
137
+ this.promptChromeBg = theme.promptChromeBg ?? theme.promptBg;
134
138
  const paddingX = options.paddingX ?? 0;
135
139
  this.paddingX = Number.isFinite(paddingX) ? Math.max(0, Math.floor(paddingX)) : 0;
136
140
  const maxVisible = options.autocompleteMaxVisible ?? 5;
@@ -138,7 +142,28 @@ export class Editor {
138
142
  this.promptGlyph = options.promptGlyph ?? DEFAULT_PROMPT_GLYPH;
139
143
  this.placeholderText = options.placeholderText ?? "";
140
144
  this.promptGlyphStyle = options.promptGlyphStyle ?? ((str) => `\x1b[1m${this.borderColor(str)}\x1b[22m`);
141
- this.placeholderStyle = options.placeholderStyle ?? ((str) => `\x1b[90m${str}\x1b[0m`);
145
+ this.placeholderStyle = options.placeholderStyle ?? ((str) => `\x1b[90m${str}\x1b[39m`);
146
+ }
147
+ sanitizePromptBgLine(line) {
148
+ // Keep style resets that do NOT clear background.
149
+ // Avoid \x1b[0m because it resets everything, including the prompt background.
150
+ return line.replace(/\x1b\[0m/g, "\x1b[22m\x1b[23m\x1b[24m\x1b[27m\x1b[39m");
151
+ }
152
+ padToVisualWidth(line, width) {
153
+ const padding = Math.max(0, width - visibleWidth(line));
154
+ return line + " ".repeat(padding);
155
+ }
156
+ applyPromptBg(line, width) {
157
+ const safeLine = this.padToVisualWidth(this.sanitizePromptBgLine(line), width);
158
+ if (!this.promptBg)
159
+ return safeLine;
160
+ return this.promptBg(safeLine);
161
+ }
162
+ applyPromptChromeBg(line, width) {
163
+ const safeLine = this.padToVisualWidth(this.sanitizePromptBgLine(line), width);
164
+ if (!this.promptChromeBg)
165
+ return safeLine;
166
+ return this.promptChromeBg(safeLine);
142
167
  }
143
168
  getPaddingX() {
144
169
  return this.paddingX;
@@ -287,38 +312,11 @@ export class Editor {
287
312
  };
288
313
  });
289
314
  }
290
- stylePromptPrefix(text) {
291
- if (text.length === 0)
292
- return text;
293
- const { first: glyph, rest } = splitFirstGrapheme(text);
294
- if (glyph.trim().length === 0) {
295
- return this.borderColor(text);
296
- }
297
- const styledGlyph = `\x1b[1m${this.borderColor(glyph)}\x1b[22m`;
298
- const styledRest = rest.length > 0 ? this.borderColor(rest) : "";
299
- return styledGlyph + styledRest;
300
- }
301
- stylePlaceholderLine(text, promptPrefixLength) {
302
- if (!promptPrefixLength || promptPrefixLength <= 0) {
303
- return this.placeholderStyle(text);
304
- }
305
- const promptPrefix = text.slice(0, promptPrefixLength);
306
- const body = text.slice(promptPrefixLength);
307
- return this.stylePromptPrefix(promptPrefix) + (body ? this.placeholderStyle(body) : "");
308
- }
309
315
  getPromptPrefixWidth() {
310
316
  if (!this.promptGlyph)
311
317
  return 0;
312
318
  return visibleWidth(this.promptGlyph) + 1;
313
319
  }
314
- getRenderedPromptPrefix(isFirstVisualLine, promptPrefixWidth) {
315
- if (!promptPrefixWidth)
316
- return "";
317
- if (isFirstVisualLine) {
318
- return `${this.promptGlyphStyle(this.promptGlyph)} `;
319
- }
320
- return " ".repeat(promptPrefixWidth);
321
- }
322
320
  render(width) {
323
321
  const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
324
322
  const paddingX = Math.min(this.paddingX, maxPadding);
@@ -330,6 +328,9 @@ export class Editor {
330
328
  // Store for cursor navigation (must match wrapping width)
331
329
  this.lastWidth = layoutWidth;
332
330
  const horizontal = this.borderColor("─");
331
+ const glyphWidth = visibleWidth(this.promptGlyph);
332
+ const paintFullLine = (line) => this.applyPromptBg(line, width);
333
+ const paintChromeLine = (line) => this.applyPromptChromeBg(line, width);
333
334
  // Layout the text
334
335
  const layoutLines = this.layoutText(layoutWidth);
335
336
  // Calculate max visible lines: 30% of terminal height, minimum 5 lines
@@ -354,66 +355,67 @@ export class Editor {
354
355
  const result = [];
355
356
  const leftPadding = " ".repeat(paddingX);
356
357
  const rightPadding = leftPadding;
357
- // Render top border (with scroll indicator if scrolled down)
358
+ // Top border
358
359
  if (this.scrollOffset > 0) {
359
360
  const indicator = `─── ↑ ${this.scrollOffset} more `;
360
361
  const remaining = width - visibleWidth(indicator);
361
- result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
362
+ result.push(paintChromeLine(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))));
362
363
  }
363
364
  else {
364
- result.push(horizontal.repeat(width));
365
+ result.push(paintChromeLine(horizontal.repeat(width)));
365
366
  }
366
- // Render each visible layout line
367
367
  // Emit hardware cursor marker only when focused and not showing autocomplete
368
368
  const emitCursorMarker = this.focused && !this.autocompleteState;
369
369
  for (let visibleIndex = 0; visibleIndex < visibleLines.length; visibleIndex++) {
370
370
  const layoutLine = visibleLines[visibleIndex];
371
371
  const absoluteLineIndex = this.scrollOffset + visibleIndex;
372
- const promptPrefix = layoutLine.isPlaceholder && layoutLine.promptPrefixLength !== undefined
373
- ? ""
374
- : this.getRenderedPromptPrefix(absoluteLineIndex === 0, promptPrefixWidth);
375
- let displayText = layoutLine.text;
376
- let lineVisibleWidth = visibleWidth(layoutLine.text);
372
+ let prefixPart = leftPadding;
373
+ let bodyPart = "";
374
+ if (layoutLine.isPlaceholder && layoutLine.promptPrefixLength !== undefined) {
375
+ const promptPrefixText = layoutLine.text.slice(0, layoutLine.promptPrefixLength);
376
+ const { first: glyph } = splitFirstGrapheme(promptPrefixText);
377
+ const glyphSpace = promptPrefixText.slice(glyph.length);
378
+ prefixPart += glyph.trim() ? this.promptGlyphStyle(glyph) : " ".repeat(visibleWidth(glyph));
379
+ bodyPart = glyphSpace;
380
+ }
381
+ else if (promptPrefixWidth > 0) {
382
+ if (absoluteLineIndex === 0) {
383
+ prefixPart += this.promptGlyphStyle(this.promptGlyph);
384
+ bodyPart = " ";
385
+ }
386
+ else {
387
+ prefixPart += " ".repeat(glyphWidth);
388
+ bodyPart = " ";
389
+ }
390
+ }
391
+ let displayText = "";
392
+ let lineVisibleWidth = 0;
377
393
  let cursorInPadding = false;
378
394
  // Add cursor if this line has it
379
395
  if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
380
396
  const marker = emitCursorMarker ? CURSOR_MARKER : "";
381
397
  if (layoutLine.isPlaceholder) {
382
- const cursor = "\x1b[7m \x1b[0m";
383
- if (layoutLine.promptPrefixLength !== undefined) {
384
- const promptPrefixText = layoutLine.text.slice(0, layoutLine.promptPrefixLength);
385
- const placeholderBodyText = layoutLine.text.slice(layoutLine.promptPrefixLength);
386
- displayText =
387
- this.stylePromptPrefix(promptPrefixText) +
388
- marker +
389
- cursor +
390
- this.placeholderStyle(placeholderBodyText);
391
- }
392
- else {
393
- displayText = marker + cursor + this.placeholderStyle(layoutLine.text);
394
- }
395
- lineVisibleWidth = 1 + visibleWidth(layoutLine.text);
398
+ const cursor = "\x1b[7m \x1b[27m";
399
+ const placeholderBodyText = layoutLine.text.slice(layoutLine.promptPrefixLength ?? 0);
400
+ displayText = marker + cursor + this.placeholderStyle(placeholderBodyText);
401
+ lineVisibleWidth = 1 + visibleWidth(placeholderBodyText);
396
402
  }
397
403
  else {
398
- const before = displayText.slice(0, layoutLine.cursorPos);
399
- const after = displayText.slice(layoutLine.cursorPos);
400
- // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
404
+ const lineText = layoutLine.text;
405
+ const before = lineText.slice(0, layoutLine.cursorPos);
406
+ const after = lineText.slice(layoutLine.cursorPos);
401
407
  if (after.length > 0) {
402
- // Cursor is on a character (grapheme) - replace it with highlighted version
403
- // Get the first grapheme from 'after'
404
408
  const afterGraphemes = [...segmenter.segment(after)];
405
409
  const firstGrapheme = afterGraphemes[0]?.segment || "";
406
410
  const restAfter = after.slice(firstGrapheme.length);
407
- const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
411
+ const cursor = `\x1b[7m${firstGrapheme}\x1b[27m`;
408
412
  displayText = before + marker + cursor + restAfter;
409
- // lineVisibleWidth stays the same - we're replacing, not adding
413
+ lineVisibleWidth = visibleWidth(lineText);
410
414
  }
411
415
  else {
412
- // Cursor is at the end - add highlighted space
413
- const cursor = "\x1b[7m \x1b[0m";
416
+ const cursor = "\x1b[7m \x1b[27m";
414
417
  displayText = before + marker + cursor;
415
- lineVisibleWidth = lineVisibleWidth + 1;
416
- // If cursor overflows content width into the padding, flag it
418
+ lineVisibleWidth = visibleWidth(lineText) + 1;
417
419
  if (lineVisibleWidth > layoutWidth && paddingX > 0) {
418
420
  cursorInPadding = true;
419
421
  }
@@ -421,24 +423,28 @@ export class Editor {
421
423
  }
422
424
  }
423
425
  else if (layoutLine.isPlaceholder) {
424
- displayText = this.stylePlaceholderLine(layoutLine.text, layoutLine.promptPrefixLength);
426
+ const placeholderBodyText = layoutLine.text.slice(layoutLine.promptPrefixLength ?? 0);
427
+ displayText = this.placeholderStyle(placeholderBodyText);
428
+ lineVisibleWidth = visibleWidth(placeholderBodyText);
429
+ }
430
+ else {
431
+ displayText = layoutLine.text;
425
432
  lineVisibleWidth = visibleWidth(layoutLine.text);
426
433
  }
427
- // Calculate padding based on actual visible width
434
+ bodyPart += displayText;
428
435
  const linePadding = " ".repeat(Math.max(0, layoutWidth - lineVisibleWidth));
429
436
  const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding;
430
- // Render the line (no side borders, just horizontal lines above and below)
431
- result.push(`${leftPadding}${promptPrefix}${displayText}${linePadding}${lineRightPadding}`);
437
+ result.push(paintFullLine(prefixPart + bodyPart + linePadding + lineRightPadding));
432
438
  }
433
- // Render bottom border (with scroll indicator if more content below)
439
+ // Bottom border
434
440
  const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length);
435
441
  if (linesBelow > 0) {
436
442
  const indicator = `─── ↓ ${linesBelow} more `;
437
443
  const remaining = width - visibleWidth(indicator);
438
- result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
444
+ result.push(paintChromeLine(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))));
439
445
  }
440
446
  else {
441
- result.push(horizontal.repeat(width));
447
+ result.push(paintChromeLine(horizontal.repeat(width)));
442
448
  }
443
449
  // Add autocomplete list if active
444
450
  if (this.autocompleteState && this.autocompleteList) {
@@ -447,7 +453,7 @@ export class Editor {
447
453
  for (const line of autocompleteResult) {
448
454
  const lineWidth = visibleWidth(line);
449
455
  const linePadding = " ".repeat(Math.max(0, layoutWidth - lineWidth));
450
- result.push(`${leftPadding}${autocompleteIndent}${line}${linePadding}${rightPadding}`);
456
+ result.push(paintFullLine(leftPadding + autocompleteIndent + line + linePadding + rightPadding));
451
457
  }
452
458
  }
453
459
  return result;