@kolisachint/hoocode-tui 0.4.157 → 0.4.158

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.
@@ -125,7 +125,23 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
125
125
  // in a narrow terminal). Re-wrap it at grapheme granularity.
126
126
  // The segment remains logically atomic for cursor
127
127
  // movement / editing — the split is purely visual for word-wrap layout.
128
- const subChunks = wordWrapLine(grapheme, maxWidth);
128
+ const subSegments = [...baseSegmenter.segment(grapheme)];
129
+ if (subSegments.length <= 1) {
130
+ // Nothing left to split: a single grapheme wider than the editor
131
+ // (a double-width character or emoji in a very narrow terminal).
132
+ // Emit it on its own chunk and let it overflow — recursing here
133
+ // would re-enter with identical arguments and never terminate.
134
+ const end = charIndex + grapheme.length;
135
+ if (chunkStart < charIndex) {
136
+ chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex });
137
+ }
138
+ chunks.push({ text: grapheme, startIndex: charIndex, endIndex: end });
139
+ chunkStart = end;
140
+ currentWidth = 0;
141
+ wrapOppIndex = -1;
142
+ continue;
143
+ }
144
+ const subChunks = wordWrapLine(grapheme, maxWidth, subSegments);
129
145
  for (let j = 0; j < subChunks.length - 1; j++) {
130
146
  const sc = subChunks[j];
131
147
  chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex });
@@ -147,10 +163,21 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
147
163
  wrapOppWidth = currentWidth;
148
164
  }
149
165
  }
150
- // Push final chunk.
151
- chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });
166
+ // Push final chunk, unless the line was already fully consumed (an atomic
167
+ // oversized grapheme ends exactly on the line end and needs no empty tail).
168
+ if (chunkStart < line.length || chunks.length === 0) {
169
+ chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });
170
+ }
152
171
  return chunks;
153
172
  }
173
+ export const DEFAULT_EDITOR_BORDER_CHARS = {
174
+ horizontal: "─",
175
+ vertical: "│",
176
+ topLeft: "┌",
177
+ topRight: "┐",
178
+ bottomLeft: "└",
179
+ bottomRight: "┘",
180
+ };
154
181
  const SLASH_COMMAND_SELECT_LIST_LAYOUT = {
155
182
  minPrimaryColumnWidth: 12,
156
183
  maxPrimaryColumnWidth: 32,
@@ -167,6 +194,8 @@ export class Editor {
167
194
  tui;
168
195
  theme;
169
196
  paddingX = 0;
197
+ border = "rule";
198
+ borderChars;
170
199
  // Store last render width for cursor navigation
171
200
  lastWidth = 80;
172
201
  // Vertical scrolling support
@@ -219,6 +248,8 @@ export class Editor {
219
248
  this.tui = tui;
220
249
  this.theme = theme;
221
250
  this.borderColor = theme.borderColor;
251
+ this.borderChars = { ...DEFAULT_EDITOR_BORDER_CHARS, ...theme.borderChars };
252
+ this.border = options.border ?? "rule";
222
253
  const paddingX = options.paddingX ?? 0;
223
254
  this.paddingX = Number.isFinite(paddingX) ? Math.max(0, Math.floor(paddingX)) : 0;
224
255
  const maxVisible = options.autocompleteMaxVisible ?? 5;
@@ -242,6 +273,15 @@ export class Editor {
242
273
  this.tui.requestRender();
243
274
  }
244
275
  }
276
+ getBorder() {
277
+ return this.border;
278
+ }
279
+ setBorder(border) {
280
+ if (this.border !== border) {
281
+ this.border = border;
282
+ this.tui.requestRender();
283
+ }
284
+ }
245
285
  getAutocompleteMaxVisible() {
246
286
  return this.autocompleteMaxVisible;
247
287
  }
@@ -321,19 +361,55 @@ export class Editor {
321
361
  invalidate() {
322
362
  // No cached state to invalidate currently
323
363
  }
364
+ /**
365
+ * Render a horizontal border, optionally carrying a scroll indicator.
366
+ * In box mode the bar is flanked by corners and padded so they stay aligned.
367
+ * @param edge - Which border to draw (selects arrow direction and corners)
368
+ * @param hidden - Number of layout lines hidden past this edge (0 = no indicator)
369
+ * @param barWidth - Width of the horizontal bar, excluding corners
370
+ * @param box - Whether to draw corners
371
+ */
372
+ renderBorder(edge, hidden, barWidth, box) {
373
+ const chars = this.borderChars;
374
+ let bar;
375
+ if (hidden > 0) {
376
+ const arrow = edge === "top" ? "↑" : "↓";
377
+ const indicator = `${chars.horizontal.repeat(3)} ${arrow} ${hidden} more `;
378
+ const remaining = barWidth - visibleWidth(indicator);
379
+ bar = remaining >= 0 ? indicator + chars.horizontal.repeat(remaining) : truncateToWidth(indicator, barWidth);
380
+ }
381
+ else {
382
+ bar = chars.horizontal.repeat(barWidth);
383
+ }
384
+ if (!box)
385
+ return this.borderColor(bar);
386
+ // Corners must stay aligned, so pad back any width lost to truncation.
387
+ bar += chars.horizontal.repeat(Math.max(0, barWidth - visibleWidth(bar)));
388
+ const left = edge === "top" ? chars.topLeft : chars.bottomLeft;
389
+ const right = edge === "top" ? chars.topRight : chars.bottomRight;
390
+ return this.borderColor(`${left}${bar}${right}`);
391
+ }
324
392
  render(width) {
325
- const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
393
+ // Box mode needs 2 columns for the side borders plus at least 1 content
394
+ // column; below that it degrades to rule mode rather than overflowing.
395
+ const box = this.border === "box" && width >= 4;
396
+ const borderWidth = box ? 1 : 0;
397
+ const innerWidth = Math.max(1, width - borderWidth * 2);
398
+ const maxPadding = Math.max(0, Math.floor((innerWidth - 1) / 2));
326
399
  const paddingX = Math.min(this.paddingX, maxPadding);
327
- const contentWidth = Math.max(1, width - paddingX * 2);
400
+ const contentWidth = Math.max(1, innerWidth - paddingX * 2);
328
401
  // Prompt prefix reserves space on the first line
329
402
  const promptPrefixWidth = this.promptPrefix ? visibleWidth(`${this.promptPrefix} `) : 0;
330
- // Layout width: with padding the cursor can overflow into it,
331
- // without padding we reserve 1 column for the cursor.
403
+ // Layout width: in rule mode with padding the cursor can overflow into the
404
+ // padding, so no column is reserved. Without padding, or in box mode where
405
+ // the cursor must never overwrite the side border, we reserve one.
332
406
  // Also reserve space for prompt prefix on the first line.
333
- const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1) - promptPrefixWidth);
407
+ const cursorColumn = box || !paddingX ? 1 : 0;
408
+ const layoutWidth = Math.max(1, contentWidth - cursorColumn - promptPrefixWidth);
334
409
  // Store for cursor navigation (must match wrapping width)
335
410
  this.lastWidth = layoutWidth;
336
- const horizontal = this.borderColor("─");
411
+ const barWidth = box ? width - 2 : width;
412
+ const vertical = box ? this.borderColor(this.borderChars.vertical) : "";
337
413
  // Layout the text
338
414
  const layoutLines = this.layoutText(layoutWidth);
339
415
  // Calculate max visible lines: 30% of terminal height, minimum 5 lines
@@ -359,19 +435,7 @@ export class Editor {
359
435
  const leftPadding = " ".repeat(paddingX);
360
436
  const rightPadding = leftPadding;
361
437
  // Render top border (with scroll indicator if scrolled down)
362
- if (this.scrollOffset > 0) {
363
- const indicator = `─── ↑ ${this.scrollOffset} more `;
364
- const remaining = width - visibleWidth(indicator);
365
- if (remaining >= 0) {
366
- result.push(this.borderColor(indicator + "─".repeat(remaining)));
367
- }
368
- else {
369
- result.push(this.borderColor(truncateToWidth(indicator, width)));
370
- }
371
- }
372
- else {
373
- result.push(horizontal.repeat(width));
374
- }
438
+ result.push(this.renderBorder("top", this.scrollOffset, barWidth, box));
375
439
  // Render each visible layout line
376
440
  // Emit hardware cursor marker only when focused and not showing autocomplete
377
441
  const emitCursorMarker = this.focused && !this.autocompleteState;
@@ -402,7 +466,7 @@ export class Editor {
402
466
  displayText = before + marker + cursor;
403
467
  lineVisibleWidth = lineVisibleWidth + 1;
404
468
  // If cursor overflows content width into the padding, flag it
405
- if (lineVisibleWidth > contentWidth && paddingX > 0) {
469
+ if (!box && lineVisibleWidth > contentWidth && paddingX > 0) {
406
470
  cursorInPadding = true;
407
471
  }
408
472
  }
@@ -416,26 +480,21 @@ export class Editor {
416
480
  // Calculate padding based on actual visible width
417
481
  const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth));
418
482
  const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding;
419
- // Render the line (no side borders, just horizontal lines above and below)
420
- result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`);
483
+ // Render the line (side borders only in box mode)
484
+ result.push(`${vertical}${leftPadding}${displayText}${padding}${lineRightPadding}${vertical}`);
421
485
  }
422
486
  // Render bottom border (with scroll indicator if more content below)
423
487
  const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length);
424
- if (linesBelow > 0) {
425
- const indicator = `─── ↓ ${linesBelow} more `;
426
- const remaining = width - visibleWidth(indicator);
427
- result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
428
- }
429
- else {
430
- result.push(horizontal.repeat(width));
431
- }
488
+ result.push(this.renderBorder("bottom", linesBelow, barWidth, box));
432
489
  // Add autocomplete list if active
433
490
  if (this.autocompleteState && this.autocompleteList) {
434
491
  const autocompleteResult = this.autocompleteList.render(contentWidth);
492
+ // Align the dropdown to the inner text edge, not the outer box edge.
493
+ const acPadding = " ".repeat(borderWidth + paddingX);
435
494
  for (const line of autocompleteResult) {
436
495
  const lineWidth = visibleWidth(line);
437
496
  const linePadding = " ".repeat(Math.max(0, contentWidth - lineWidth));
438
- result.push(`${leftPadding}${line}${linePadding}${rightPadding}`);
497
+ result.push(`${acPadding}${line}${linePadding}${acPadding}`);
439
498
  }
440
499
  }
441
500
  return result;