diff --git a/dist/components/editor.d.ts b/dist/components/editor.d.ts index a6fedc9e3b36d066e34860d040db6df47d88c432..f0b20eb87686215d1d1a54274a7b1ebdf4030ef1 100644 --- a/dist/components/editor.d.ts +++ b/dist/components/editor.d.ts @@ -21,7 +21,7 @@ export interface TextChunk { * When omitted the default Intl.Segmenter is used. * @returns Array of chunks with text and position information */ -export declare function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[]): TextChunk[]; +export declare function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[], continuationWidth?: number): TextChunk[]; export interface EditorTheme { borderColor: (str: string) => string; selectList: SelectListTheme; @@ -29,6 +29,13 @@ export interface EditorTheme { export interface EditorOptions { paddingX?: number; autocompleteMaxVisible?: number; + /** Omit the editor's horizontal frame. */ + frame?: "horizontal" | "none"; + /** Fixed-width prefixes for the first input row and explicit newlines. Wrapped rows start at the editor edge. */ + prompt?: { + first: string; + continuation: string; + }; } export declare class Editor implements Component, Focusable { private state; @@ -79,6 +86,11 @@ export declare class Editor implements Component, Focusable { getAutocompleteMaxVisible(): number; setAutocompleteMaxVisible(maxVisible: number): void; setAutocompleteProvider(provider: AutocompleteProvider): void; + /** Replace fixed-width first and continuation input prefixes. */ + setPrompt(prompt: { + first: string; + continuation: string; + }): void; /** * Add a prompt to history for up/down arrow navigation. * Called after successful submission. diff --git a/dist/components/editor.js b/dist/components/editor.js index 6c03aeec4148571558713e885ac7f7df18a511bc..0111317ccab31a1e973b92272d8a668b75a29174 100644 --- a/dist/components/editor.js +++ b/dist/components/editor.js @@ -79,7 +79,7 @@ function segmentWithMarkers(text, baseSegmenter, validIds) { * When omitted the default Intl.Segmenter is used. * @returns Array of chunks with text and position information */ -export function wordWrapLine(line, maxWidth, preSegmented) { +export function wordWrapLine(line, maxWidth, preSegmented, continuationWidth = maxWidth) { if (!line || maxWidth <= 0) { return [{ text: "", startIndex: 0, endIndex: 0 }]; } @@ -90,6 +90,7 @@ export function wordWrapLine(line, maxWidth, preSegmented) { const chunks = []; const segments = preSegmented ?? [...graphemeSegmenter.segment(line)]; let currentWidth = 0; + let currentMaxWidth = maxWidth; let chunkStart = 0; // Wrap opportunity: the position after the last whitespace before a non-whitespace // grapheme, i.e. where a line break is allowed. @@ -102,11 +103,12 @@ export function wordWrapLine(line, maxWidth, preSegmented) { const charIndex = seg.index; const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme); // Overflow check before advancing. - if (currentWidth + gWidth > maxWidth) { - if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) { + if (currentWidth + gWidth > currentMaxWidth) { + if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= continuationWidth) { // Backtrack to last wrap opportunity (the remaining content // plus the current grapheme still fits within maxWidth). chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex }); + currentMaxWidth = continuationWidth; chunkStart = wrapOppIndex; currentWidth -= wrapOppWidth; } @@ -117,22 +119,29 @@ export function wordWrapLine(line, maxWidth, preSegmented) { // the current grapheme (e.g. a wide character) still exceeds // maxWidth. chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex }); + currentMaxWidth = continuationWidth; chunkStart = charIndex; currentWidth = 0; } wrapOppIndex = -1; } - if (gWidth > maxWidth) { - // Single atomic segment wider than maxWidth (e.g. paste marker + if (gWidth > currentMaxWidth) { + if (segments.length === 1) { + chunks.push({ text: grapheme, startIndex: charIndex, endIndex: charIndex + grapheme.length }); + return chunks; + } + // Single atomic segment wider than the current line width (e.g. paste marker // in a narrow terminal). Re-wrap it at grapheme granularity. // The segment remains logically atomic for cursor // movement / editing — the split is purely visual for word-wrap layout. - const subChunks = wordWrapLine(grapheme, maxWidth); + const subChunks = wordWrapLine(grapheme, currentMaxWidth, undefined, continuationWidth); for (let j = 0; j < subChunks.length - 1; j++) { const sc = subChunks[j]; chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex }); } const last = subChunks[subChunks.length - 1]; + if (subChunks.length > 1) + currentMaxWidth = continuationWidth; chunkStart = charIndex + last.startIndex; currentWidth = visibleWidth(last.text); wrapOppIndex = -1; @@ -189,8 +198,12 @@ export class Editor { tui; theme; paddingX = 0; + frame = "horizontal"; + prompt; + promptWidth = 0; // Store last render width for cursor navigation lastWidth = 80; + lastContinuationWidth = 80; // Vertical scrolling support scrollOffset = 0; // Border color (can be changed dynamically) @@ -243,9 +256,29 @@ export class Editor { this.borderColor = theme.borderColor; const paddingX = options.paddingX ?? 0; this.paddingX = Number.isFinite(paddingX) ? Math.max(0, Math.floor(paddingX)) : 0; + this.frame = options.frame ?? "horizontal"; + this.prompt = options.prompt; + if (this.prompt) { + const firstWidth = visibleWidth(this.prompt.first); + const continuationWidth = visibleWidth(this.prompt.continuation); + if (firstWidth !== continuationWidth) { + throw new Error("Editor prompt prefixes must have equal visible widths"); + } + this.promptWidth = firstWidth; + } const maxVisible = options.autocompleteMaxVisible ?? 5; this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5; } + setPrompt(prompt) { + const firstWidth = visibleWidth(prompt.first); + const continuationWidth = visibleWidth(prompt.continuation); + if (firstWidth !== continuationWidth) { + throw new Error("Editor prompt prefixes must have equal visible widths"); + } + this.prompt = prompt; + this.promptWidth = firstWidth; + this.invalidate(); + } /** Set of currently valid paste IDs, for marker-aware segmentation. */ validPasteIds() { return new Set(this.pastes.keys()); @@ -364,14 +397,17 @@ export class Editor { const maxPadding = Math.max(0, Math.floor((width - 1) / 2)); const paddingX = Math.min(this.paddingX, maxPadding); const contentWidth = Math.max(1, width - paddingX * 2); + const inputWidth = Math.max(1, contentWidth - this.promptWidth); // Layout width: with padding the cursor can overflow into it, // without padding we reserve 1 column for the cursor. - const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1)); - // Store for cursor navigation (must match wrapping width) + const layoutWidth = Math.max(1, inputWidth - (paddingX ? 0 : 1)); + const continuationLayoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1)); + // Store for cursor navigation (must match wrapping widths) this.lastWidth = layoutWidth; + this.lastContinuationWidth = continuationLayoutWidth; const horizontal = this.borderColor("─"); // Layout the text - const layoutLines = this.layoutText(layoutWidth); + const layoutLines = this.layoutText(layoutWidth, continuationLayoutWidth); // Calculate max visible lines: 30% of terminal height, minimum 5 lines const terminalRows = this.tui.terminal.rows; const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3)); @@ -396,16 +432,22 @@ export class Editor { const rightPadding = leftPadding; // Render top border (with scroll indicator if scrolled down) if (this.scrollOffset > 0) { - const indicator = `─── ↑ ${this.scrollOffset} more `; - const remaining = width - visibleWidth(indicator); - if (remaining >= 0) { - result.push(this.borderColor(indicator + "─".repeat(remaining))); + if (this.frame === "none") { + const indicator = `${" ".repeat(this.promptWidth)}↑ ${this.scrollOffset} more`; + result.push(`${leftPadding}${this.borderColor(indicator)}${" ".repeat(Math.max(0, contentWidth - visibleWidth(indicator)))}${rightPadding}`); } else { - result.push(this.borderColor(truncateToWidth(indicator, width))); + const indicator = `─── ↑ ${this.scrollOffset} more `; + const remaining = width - visibleWidth(indicator); + if (remaining >= 0) { + result.push(this.borderColor(indicator + "─".repeat(remaining))); + } + else { + result.push(this.borderColor(truncateToWidth(indicator, width))); + } } } - else { + else if (this.frame === "horizontal") { result.push(horizontal.repeat(width)); } // Render each visible layout line @@ -413,7 +455,19 @@ export class Editor { // hardware cursor for IME candidate-window placement even while // autocomplete (e.g. slash-command menu) is visible. const emitCursorMarker = this.focused; - for (const layoutLine of visibleLines) { + for (let visibleIndex = 0; visibleIndex < visibleLines.length; visibleIndex++) { + const layoutLine = visibleLines[visibleIndex]; + if (!layoutLine) + continue; + const absoluteIndex = this.scrollOffset + visibleIndex; + const prefix = this.prompt + ? (absoluteIndex === 0 + ? this.prompt.first + : layoutLine.isContinuation + ? "" + : this.prompt.continuation) + : ""; + const lineContentWidth = inputWidth + (layoutLine.isContinuation ? this.promptWidth : 0); let displayText = layoutLine.text; let lineVisibleWidth = visibleWidth(layoutLine.text); let cursorInPadding = false; @@ -439,34 +493,41 @@ export class Editor { displayText = before + marker + cursor; lineVisibleWidth = lineVisibleWidth + 1; // If cursor overflows content width into the padding, flag it - if (lineVisibleWidth > contentWidth && paddingX > 0) { + if (lineVisibleWidth > lineContentWidth && paddingX > 0) { cursorInPadding = true; } } } // Calculate padding based on actual visible width - const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth)); + const padding = " ".repeat(Math.max(0, lineContentWidth - lineVisibleWidth)); const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding; // Render the line (no side borders, just horizontal lines above and below) - result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`); + result.push(`${leftPadding}${prefix}${displayText}${padding}${lineRightPadding}`); } // Render bottom border (with scroll indicator if more content below) const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length); if (linesBelow > 0) { - const indicator = `─── ↓ ${linesBelow} more `; - const remaining = width - visibleWidth(indicator); - result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); + if (this.frame === "none") { + const indicator = `${" ".repeat(this.promptWidth)}↓ ${linesBelow} more`; + result.push(`${leftPadding}${this.borderColor(indicator)}${" ".repeat(Math.max(0, contentWidth - visibleWidth(indicator)))}${rightPadding}`); + } + else { + const indicator = `─── ↓ ${linesBelow} more `; + const remaining = width - visibleWidth(indicator); + result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); + } } - else { + else if (this.frame === "horizontal") { result.push(horizontal.repeat(width)); } // Add autocomplete list if active if (this.autocompleteState && this.autocompleteList) { - const autocompleteResult = this.autocompleteList.render(contentWidth); + const autocompleteResult = this.autocompleteList.render(inputWidth); + const autocompletePrefix = " ".repeat(this.promptWidth); for (const line of autocompleteResult) { const lineWidth = visibleWidth(line); - const linePadding = " ".repeat(Math.max(0, contentWidth - lineWidth)); - result.push(`${leftPadding}${line}${linePadding}${rightPadding}`); + const linePadding = " ".repeat(Math.max(0, inputWidth - lineWidth)); + result.push(`${leftPadding}${autocompletePrefix}${line}${linePadding}${rightPadding}`); } } return result; @@ -726,7 +787,7 @@ export class Editor { this.insertCharacter(data); } } - layoutText(contentWidth) { + layoutText(contentWidth, continuationWidth) { const layoutLines = []; if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) { // Empty editor @@ -734,6 +795,7 @@ export class Editor { text: "", hasCursor: true, cursorPos: 0, + isContinuation: false, }); return layoutLines; } @@ -749,18 +811,20 @@ export class Editor { text: line, hasCursor: true, cursorPos: this.state.cursorCol, + isContinuation: false, }); } else { layoutLines.push({ text: line, hasCursor: false, + isContinuation: false, }); } } else { // Line needs wrapping - use word-aware wrapping - const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]); + const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")], continuationWidth); for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex]; if (!chunk) @@ -796,12 +860,14 @@ export class Editor { text: chunk.text, hasCursor: true, cursorPos: adjustedCursorPos, + isContinuation: chunkIndex > 0, }); } else { layoutLines.push({ text: chunk.text, hasCursor: false, + isContinuation: chunkIndex > 0, }); } } @@ -1439,7 +1505,7 @@ export class Editor { * - startCol: starting column in the logical line * - length: length of this visual line segment */ - buildVisualLineMap(width) { + buildVisualLineMap(width, continuationWidth = this.lastContinuationWidth) { const visualLines = []; for (let i = 0; i < this.state.lines.length; i++) { const line = this.state.lines[i] || ""; @@ -1453,7 +1519,7 @@ export class Editor { } else { // Line needs wrapping - use word-aware wrapping - const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]); + const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")], continuationWidth); for (const chunk of chunks) { visualLines.push({ logicalLine: i,