SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
4e23b01 verified
Raw History Blame Contribute Delete
91 kB
import type { AutocompleteProvider, AutocompleteSuggestions } from "../autocomplete.ts";
import { getKeybindings } from "../keybindings.ts";
import { decodePrintableKey, matchesKey } from "../keys.ts";
import { KillRing } from "../kill-ring.ts";
import { PasteBurst } from "../paste-burst.ts";
import {
type Component,
CURSOR_MARKER,
type Focusable,
type TUI,
type TuiMouseEvent,
type TuiMouseEventResult,
} from "../tui.ts";
import { UndoStack } from "../undo-stack.ts";
import {
cjkBreakRegex,
getGraphemeSegmenter,
getWordSegmenter,
isWhitespaceChar,
sliceByColumn,
visibleWidth,
} from "../utils.ts";
import { findWordBackward, findWordForward } from "../word-navigation.ts";
import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list.ts";
const graphemeSegmenter = getGraphemeSegmenter();
const wordSegmenter = getWordSegmenter();
/** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
/** Non-global version for single-segment testing. */
const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;
/** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */
function isPasteMarker(segment: string): boolean {
return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment);
}
/**
* A segmenter that wraps Intl.Segmenter and merges graphemes that fall
* within paste markers into single atomic segments. This makes cursor
* movement, deletion, word-wrap, etc. treat paste markers as single units.
*
* Only markers whose numeric ID exists in `validIds` are merged.
*/
function segmentWithMarkers(
text: string,
baseSegmenter: Intl.Segmenter,
validIds: Set<number>,
): Iterable<Intl.SegmentData> {
// Fast path: no paste markers in the text or no valid IDs.
if (validIds.size === 0 || !text.includes("[paste #")) {
return baseSegmenter.segment(text);
}
// Find all marker spans with valid IDs.
const markers: Array<{ start: number; end: number }> = [];
for (const m of text.matchAll(PASTE_MARKER_REGEX)) {
const id = Number.parseInt(m[1]!, 10);
if (!validIds.has(id)) continue;
markers.push({ start: m.index, end: m.index + m[0].length });
}
if (markers.length === 0) {
return baseSegmenter.segment(text);
}
// Build merged segment list.
const baseSegments = baseSegmenter.segment(text);
const result: Intl.SegmentData[] = [];
let markerIdx = 0;
for (const seg of baseSegments) {
// Skip past markers that are entirely before this segment.
while (markerIdx < markers.length && markers[markerIdx]!.end <= seg.index) {
markerIdx++;
}
const marker = markerIdx < markers.length ? markers[markerIdx]! : null;
if (marker && seg.index >= marker.start && seg.index < marker.end) {
// This segment falls inside a marker.
// If this is the first segment of the marker, emit a merged segment.
if (seg.index === marker.start) {
const markerText = text.slice(marker.start, marker.end);
result.push({
segment: markerText,
index: marker.start,
input: text,
});
}
// Otherwise skip (already merged into the first segment).
} else {
result.push(seg);
}
}
return result;
}
/**
* Represents a chunk of text for word-wrap layout.
* Tracks both the text content and its position in the original line.
*/
export interface TextChunk {
text: string;
startIndex: number;
endIndex: number;
}
/**
* Split a line into word-wrapped chunks.
* Wraps at word boundaries when possible, falling back to character-level
* wrapping for words longer than the available width.
*
* @param line - The text line to wrap
* @param maxWidth - Maximum visible width per chunk
* @param preSegmented - Optional pre-segmented graphemes (e.g. with paste-marker awareness).
* When omitted the default Intl.Segmenter is used.
* @returns Array of chunks with text and position information
*/
export function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[]): TextChunk[] {
if (!line || maxWidth <= 0) {
return [{ text: "", startIndex: 0, endIndex: 0 }];
}
const lineWidth = visibleWidth(line);
if (lineWidth <= maxWidth) {
return [{ text: line, startIndex: 0, endIndex: line.length }];
}
const chunks: TextChunk[] = [];
const segments = preSegmented ?? [...graphemeSegmenter.segment(line)];
let currentWidth = 0;
let chunkStart = 0;
// Wrap opportunity: the position after the last whitespace before a non-whitespace
// grapheme, i.e. where a line break is allowed.
let wrapOppIndex = -1;
let wrapOppWidth = 0;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]!;
const grapheme = seg.segment;
const gWidth = visibleWidth(grapheme);
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) {
// 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 });
chunkStart = wrapOppIndex;
currentWidth -= wrapOppWidth;
} else if (chunkStart < charIndex) {
// No viable wrap opportunity: force-break at current position.
// This also handles the case where backtracking to a word
// boundary wouldn't help because the remaining content plus
// the current grapheme (e.g. a wide character) still exceeds
// maxWidth.
chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex });
chunkStart = charIndex;
currentWidth = 0;
}
wrapOppIndex = -1;
}
if (gWidth > maxWidth) {
// Single atomic segment wider than maxWidth (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 subSegments = [...graphemeSegmenter.segment(grapheme)];
if (subSegments.length <= 1) {
// An indivisible grapheme wider than maxWidth (e.g. a CJK
// character at maxWidth 1) cannot be split further —
// re-wrapping it would recurse forever. Keep it as the
// current open chunk and let it overflow by one column;
// the TUI paint layer truncates overwide lines.
currentWidth = gWidth;
wrapOppIndex = -1;
continue;
}
const subChunks = wordWrapLine(grapheme, maxWidth, subSegments);
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]!;
chunkStart = charIndex + last.startIndex;
currentWidth = visibleWidth(last.text);
wrapOppIndex = -1;
continue;
}
// Advance.
currentWidth += gWidth;
// Record wrap opportunity: whitespace followed by non-whitespace
// (multiple spaces join; the break point is after the last space),
// or at a boundary where either side is CJK (CJK allows breaking
// between any adjacent characters).
const next = segments[i + 1];
if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
wrapOppIndex = next.index;
wrapOppWidth = currentWidth;
} else if (!isWs && next && !isWhitespaceChar(next.segment)) {
const isCjk = !isPasteMarker(grapheme) && cjkBreakRegex.test(grapheme);
const nextIsCjk = !isPasteMarker(next.segment) && cjkBreakRegex.test(next.segment);
if (isCjk || nextIsCjk) {
wrapOppIndex = next.index;
wrapOppWidth = currentWidth;
}
}
}
// Push final chunk.
chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length });
return chunks;
}
// Kitty CSI-u sequences for printable keys, including optional shifted/base codepoints.
interface EditorState {
lines: string[];
cursorLine: number;
cursorCol: number;
}
/** Undo snapshot: editor text state plus the paste registry. */
interface EditorSnapshot {
state: EditorState;
pastes: Map<number, string>;
pasteCounter: number;
}
interface LayoutLine {
text: string;
hasCursor: boolean;
cursorPos?: number;
}
export interface EditorTheme {
borderColor: (str: string) => string;
selectList: SelectListTheme;
}
export interface EditorOptions {
paddingX?: number;
autocompleteMaxVisible?: number;
disablePasteBurst?: boolean;
/**
* When true, typing `/` after whitespace mid-input also auto-triggers
* autocomplete, so providers can offer inline completions (e.g. inline
* skill selection). The default providers treat such `/` as a path prefix,
* so the default is false to avoid surprising other consumers.
*/
inlineSlashTrigger?: boolean;
}
const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
minPrimaryColumnWidth: 12,
maxPrimaryColumnWidth: 32,
};
const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"];
function escapeCharacterClass(value: string): string {
return value.replace(/[\\^$.*+?()[\]{}|-]/g, "\\$&");
}
function buildTriggerPattern(triggerCharacters: string[]): RegExp {
return new RegExp(`(?:^|[\\s])[${triggerCharacters.map(escapeCharacterClass).join("")}][^\\s]*$`);
}
function buildDebouncePattern(triggerCharacters: string[]): RegExp {
const escapedWithoutAt = triggerCharacters.filter((character) => character !== "@").map(escapeCharacterClass);
return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`);
}
function createScrollBorder(direction: "↑" | "↓", hiddenLineCount: number, width: number): string {
const availableWidth = Math.max(0, width);
const label = ` ${direction} ${hiddenLineCount} more `;
const labelWidth = visibleWidth(label);
if (labelWidth + 2 <= availableWidth) {
const leftWidth = Math.floor((availableWidth - labelWidth) / 2);
return "─".repeat(leftWidth) + label + "─".repeat(availableWidth - leftWidth - labelWidth);
}
const indicator = `─── ${direction} ${hiddenLineCount} more `;
const remaining = availableWidth - visibleWidth(indicator);
if (remaining >= 0) return indicator + "─".repeat(remaining);
const ellipsis = "...".slice(0, availableWidth);
const indicatorWidth = availableWidth - visibleWidth(ellipsis);
return sliceByColumn(indicator, 0, indicatorWidth, true) + ellipsis;
}
export class Editor implements Component, Focusable {
private state: EditorState = {
lines: [""],
cursorLine: 0,
cursorCol: 0,
};
/** Focusable interface - set by TUI when focus changes */
focused: boolean = false;
protected tui: TUI;
private theme: EditorTheme;
private paddingX: number = 0;
// Store last render geometry for cursor navigation and mouse hit-testing.
private lastWidth: number = 80;
private renderedVisibleLineCount = 1;
private renderedAutocompleteHeight = 0;
// Vertical scrolling support
private scrollOffset: number = 0;
// Border color (can be changed dynamically)
public borderColor: (str: string) => string;
// Autocomplete support
private autocompleteProvider?: AutocompleteProvider;
private autocompleteTriggerCharacters = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
private autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters);
private autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters);
private autocompleteList?: SelectList;
private autocompleteState: "regular" | "force" | null = null;
private autocompletePrefix: string = "";
private autocompleteMaxVisible: number = 5;
private autocompleteAbort?: AbortController;
private autocompleteDebounceTimer?: ReturnType<typeof setTimeout>;
private autocompleteRequestTask: Promise<void> = Promise.resolve();
private autocompleteStartToken: number = 0;
private autocompleteRequestId: number = 0;
// Paste tracking for large pastes
private pastes: Map<number, string> = new Map();
private pasteCounter: number = 0;
// Bracketed paste mode buffering
private pasteBuffer: string = "";
private isInPaste: boolean = false;
// Non-bracketed paste-burst fallback
private pasteBurst = new PasteBurst();
private disablePasteBurst: boolean = false;
private inlineSlashTrigger: boolean = false;
// Prompt history for up/down navigation
private history: string[] = [];
private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
private historyDraft: EditorState | null = null;
private hostHistoryDraft: unknown = undefined;
private historyFilter: ((entry: string) => boolean) | null = null;
// Kill ring for Emacs-style kill/yank operations
private killRing = new KillRing();
private lastAction: "kill" | "yank" | "type-word" | null = null;
// Character jump mode
private jumpMode: "forward" | "backward" | null = null;
// Preferred visual column for vertical cursor movement (sticky column)
private preferredVisualCol: number | null = null;
// When the cursor is snapped to the start of an atomic segment, e.g. a
// paste marker, cursorCol no longer reflects where the cursor would have
// landed. This field stores the pre-snap cursorCol so that the next
// vertical move can resolve it to a visual column on whatever VL it belongs
// to.
private snappedFromCursorCol: number | null = null;
// Undo support
private undoStack = new UndoStack<EditorSnapshot>();
public onSubmit?: (text: string) => void;
public onChange?: (text: string) => void;
/**
* Called when a history entry is recalled, before it is put into the buffer.
* Return the text to display, or `undefined` to use the entry as-is. Lets the
* host decorate entries (e.g. strip a marker) and react to recalls (e.g.
* switch input mode) without touching editor internals.
*/
public onRecall?: (entry: string, direction: 1 | -1) => string | undefined;
/**
* Called when entering history browsing, to capture host state that should be
* saved alongside the editor draft. The returned value is passed to
* `onHistoryDraftRestore` when the user navigates back to the draft, so the
* host can restore state the editor does not own (e.g. an input mode).
*/
public onHistoryDraftSave?: () => unknown;
/** Called with the value from `onHistoryDraftSave` when the draft is restored. */
public onHistoryDraftRestore?: (state: unknown) => void;
public disableSubmit: boolean = false;
constructor(tui: TUI, theme: EditorTheme, options: EditorOptions = {}) {
this.tui = tui;
this.theme = theme;
this.borderColor = theme.borderColor;
const paddingX = options.paddingX ?? 0;
this.paddingX = Number.isFinite(paddingX) ? Math.max(0, Math.floor(paddingX)) : 0;
const maxVisible = options.autocompleteMaxVisible ?? 5;
this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
this.disablePasteBurst = options.disablePasteBurst ?? false;
this.inlineSlashTrigger = options.inlineSlashTrigger ?? false;
}
/** Set of currently valid paste IDs, for marker-aware segmentation. */
private validPasteIds(): Set<number> {
return new Set(this.pastes.keys());
}
/** Segment text with paste-marker awareness, only merging markers with valid IDs. */
private segment(text: string, mode: "word" | "grapheme"): Iterable<Intl.SegmentData> {
return segmentWithMarkers(text, mode === "word" ? wordSegmenter : graphemeSegmenter, this.validPasteIds());
}
getPaddingX(): number {
return this.paddingX;
}
setPaddingX(padding: number): void {
const newPadding = Number.isFinite(padding) ? Math.max(0, Math.floor(padding)) : 0;
if (this.paddingX !== newPadding) {
this.paddingX = newPadding;
this.tui.requestRender();
}
}
getAutocompleteMaxVisible(): number {
return this.autocompleteMaxVisible;
}
setAutocompleteMaxVisible(maxVisible: number): void {
const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
if (this.autocompleteMaxVisible !== newMaxVisible) {
this.autocompleteMaxVisible = newMaxVisible;
this.tui.requestRender();
}
}
setDisablePasteBurst(disabled: boolean): void {
this.disablePasteBurst = disabled;
if (disabled) {
this.pasteBurst.reset();
}
}
setAutocompleteProvider(provider: AutocompleteProvider): void {
this.cancelAutocomplete();
this.autocompleteProvider = provider;
this.setAutocompleteTriggerCharacters(provider.triggerCharacters ?? []);
}
/**
* Limit which history entries ↑/↓ navigate. `null` (default) visits every
* entry. The filter is evaluated against each stored entry as-is.
*/
setHistoryFilter(filter: ((entry: string) => boolean) | null): void {
this.historyFilter = filter;
}
/**
* Add a prompt to history for up/down arrow navigation.
* Called after successful submission.
*/
addToHistory(text: string): void {
const trimmed = text.trim();
if (!trimmed) return;
// Don't add consecutive duplicates
if (this.history.length > 0 && this.history[0] === trimmed) return;
this.history.unshift(trimmed);
// Limit history size
if (this.history.length > 100) {
this.history.pop();
}
}
private isEditorEmpty(): boolean {
return this.state.lines.length === 1 && this.state.lines[0] === "";
}
private isOnFirstVisualLine(): boolean {
const visualLines = this.buildVisualLineMap(this.lastWidth);
const currentVisualLine = this.findCurrentVisualLine(visualLines);
return currentVisualLine === 0;
}
private isOnLastVisualLine(): boolean {
const visualLines = this.buildVisualLineMap(this.lastWidth);
const currentVisualLine = this.findCurrentVisualLine(visualLines);
return currentVisualLine === visualLines.length - 1;
}
private navigateHistory(direction: 1 | -1): void {
this.lastAction = null;
if (this.history.length === 0) return;
// When entering browse, capture host state up front — before the filter
// runs — so the host's filter can read the browse-entry mode rather than a
// mode that changes as entries are recalled. The captured value is only
// committed to hostHistoryDraft once a matching entry is actually found.
const entering = this.historyIndex === -1;
const pendingHostDraft = entering ? this.onHistoryDraftSave?.() : undefined;
// Find the next index that passes the filter. Up(-1) increases index,
// Down(1) decreases. The draft (-1) is always reachable; stepping past
// either end is a no-op.
let newIndex = this.historyIndex;
let found = false;
while (true) {
newIndex = newIndex - direction;
if (newIndex === -1) {
found = true;
break;
}
if (newIndex < -1 || newIndex >= this.history.length) {
found = false;
break;
}
const candidate = this.history[newIndex];
if (!this.historyFilter || (candidate !== undefined && this.historyFilter(candidate))) {
found = true;
break;
}
}
if (!found) return;
// Capture state when first entering history browsing mode
if (entering && newIndex >= 0) {
this.pushUndoSnapshot();
this.historyDraft = structuredClone(this.state);
this.hostHistoryDraft = pendingHostDraft;
}
this.historyIndex = newIndex;
if (this.historyIndex === -1) {
const draft = this.historyDraft;
this.historyDraft = null;
if (draft) {
this.state = draft;
this.preferredVisualCol = null;
this.snappedFromCursorCol = null;
this.scrollOffset = 0;
if (this.hostHistoryDraft !== undefined) {
this.onHistoryDraftRestore?.(this.hostHistoryDraft);
this.hostHistoryDraft = undefined;
}
if (this.onChange) this.onChange(this.getText());
} else {
this.setTextInternal("");
}
} else {
const rawEntry = this.history[this.historyIndex] || "";
const entry = this.onRecall ? this.onRecall(rawEntry, direction) ?? rawEntry : rawEntry;
this.setTextInternal(entry, direction === -1 ? "start" : "end");
}
}
private exitHistoryBrowsing(): void {
this.historyIndex = -1;
this.historyDraft = null;
this.hostHistoryDraft = undefined;
}
/** Internal setText that doesn't reset history state - used by navigateHistory */
private setTextInternal(text: string, cursorPlacement: "start" | "end" = "end"): void {
const lines = text.split("\n");
this.state.lines = lines.length === 0 ? [""] : lines;
this.state.cursorLine = cursorPlacement === "start" ? 0 : this.state.lines.length - 1;
this.setCursorCol(cursorPlacement === "start" ? 0 : this.state.lines[this.state.cursorLine]?.length || 0);
// Reset scroll - render() will adjust to show cursor
this.scrollOffset = 0;
if (this.onChange) {
this.onChange(this.getText());
}
}
invalidate(): void {
// No cached state to invalidate currently
}
protected renderTopBorder(width: number, hiddenLineCount: number): string {
const border = hiddenLineCount > 0 ? createScrollBorder("↑", hiddenLineCount, width) : "─".repeat(Math.max(0, width));
return this.borderColor(border);
}
protected renderBottomBorder(width: number, hiddenLineCount: number): string {
const border = hiddenLineCount > 0 ? createScrollBorder("↓", hiddenLineCount, width) : "─".repeat(Math.max(0, width));
return this.borderColor(border);
}
render(width: number): string[] {
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);
// 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)
this.lastWidth = layoutWidth;
// Layout the text
const layoutLines = this.layoutText(layoutWidth);
// 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));
// Find the cursor line index in layoutLines
let cursorLineIndex = layoutLines.findIndex((line) => line.hasCursor);
if (cursorLineIndex === -1) cursorLineIndex = 0;
// Adjust scroll offset to keep cursor visible
if (cursorLineIndex < this.scrollOffset) {
this.scrollOffset = cursorLineIndex;
} else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) {
this.scrollOffset = cursorLineIndex - maxVisibleLines + 1;
}
// Clamp scroll offset to valid range
const maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines);
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset));
// Get visible lines slice
const visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);
this.renderedVisibleLineCount = visibleLines.length;
const result: string[] = [];
const leftPadding = " ".repeat(paddingX);
const rightPadding = leftPadding;
// Render top border (with scroll indicator if scrolled down)
result.push(this.renderTopBorder(width, this.scrollOffset));
// Render each visible layout line
// Emit hardware cursor marker when focused so TUI can position the
// 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) {
let displayText = layoutLine.text;
let lineVisibleWidth = visibleWidth(layoutLine.text);
let cursorInPadding = false;
// Add cursor if this line has it
if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) {
const before = displayText.slice(0, layoutLine.cursorPos);
const after = displayText.slice(layoutLine.cursorPos);
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
const marker = emitCursorMarker ? CURSOR_MARKER : "";
if (after.length > 0) {
// Cursor is on a character (grapheme) - replace it with highlighted version
// Get the first grapheme from 'after'
const afterGraphemes = [...this.segment(after, "grapheme")];
const firstGrapheme = afterGraphemes[0]?.segment || "";
const restAfter = after.slice(firstGrapheme.length);
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
displayText = before + marker + cursor + restAfter;
// lineVisibleWidth stays the same - we're replacing, not adding
} else {
// Cursor is at the end - add highlighted space
const cursor = "\x1b[7m \x1b[0m";
displayText = before + marker + cursor;
lineVisibleWidth = lineVisibleWidth + 1;
// If cursor overflows content width into the padding, flag it
if (lineVisibleWidth > contentWidth && paddingX > 0) {
cursorInPadding = true;
}
}
}
// Calculate padding based on actual visible width
const padding = " ".repeat(Math.max(0, contentWidth - 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}`);
}
// Render bottom border (with scroll indicator if more content below)
const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length);
result.push(this.renderBottomBorder(width, linesBelow));
// Add autocomplete list if active
this.renderedAutocompleteHeight = 0;
if (this.autocompleteState && this.autocompleteList) {
const autocompleteResult = this.autocompleteList.render(contentWidth);
this.renderedAutocompleteHeight = autocompleteResult.length;
for (const line of autocompleteResult) {
const lineWidth = visibleWidth(line);
const linePadding = " ".repeat(Math.max(0, contentWidth - lineWidth));
result.push(`${leftPadding}${line}${linePadding}${rightPadding}`);
}
}
return result;
}
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
const autocompleteStartRow = this.renderedVisibleLineCount + 2;
if (
this.autocompleteState &&
this.autocompleteList &&
event.y >= autocompleteStartRow &&
event.y < autocompleteStartRow + this.renderedAutocompleteHeight
) {
const maxPadding = Math.max(0, Math.floor((event.width - 1) / 2));
const paddingX = Math.min(this.paddingX, maxPadding);
const contentWidth = Math.max(1, event.width - paddingX * 2);
const result = this.autocompleteList.handleMouse?.({
...event,
x: event.x - paddingX,
y: event.y - autocompleteStartRow,
width: contentWidth,
height: this.renderedAutocompleteHeight,
});
return result ? { ...result, focus: true } : undefined;
}
// Leave press/drag/release unhandled so the renderer's screen-level text
// selection can run over the editor rows (drag to select, release to copy).
// The renderer synthesizes a click when press and release land on the same
// cell without movement, which is the gesture that positions the cursor.
if (event.type !== "click" || event.button !== "left") return undefined;
if (event.y <= 0 || event.y > this.renderedVisibleLineCount) return { handled: true, focus: true };
const visualLines = this.buildVisualLineMap(this.lastWidth);
const visualLineIndex = this.scrollOffset + event.y - 1;
const visualLine = visualLines[visualLineIndex];
if (!visualLine) return { handled: true, focus: true };
const logicalLine = this.state.lines[visualLine.logicalLine] ?? "";
const chunkEnd = visualLine.startCol + visualLine.length;
const chunk = logicalLine.slice(visualLine.startCol, chunkEnd);
const maxPadding = Math.max(0, Math.floor((event.width - 1) / 2));
const paddingX = Math.min(this.paddingX, maxPadding);
const targetColumn = Math.max(0, event.x - paddingX);
let visibleColumn = 0;
let targetIndex = chunk.length;
let lastGraphemeIndex = 0;
for (const grapheme of this.segment(chunk, "grapheme")) {
const nextColumn = visibleColumn + visibleWidth(grapheme.segment);
lastGraphemeIndex = grapheme.index;
if (targetColumn < nextColumn) {
targetIndex = grapheme.index;
break;
}
visibleColumn = nextColumn;
}
const isLastSegment =
visualLineIndex === visualLines.length - 1 ||
visualLines[visualLineIndex + 1]?.logicalLine !== visualLine.logicalLine;
if (!isLastSegment && targetIndex === chunk.length && chunk.length > 0) targetIndex = lastGraphemeIndex;
this.state.cursorLine = visualLine.logicalLine;
this.setCursorCol(visualLine.startCol + targetIndex);
this.lastAction = null;
this.exitHistoryBrowsing();
if (this.autocompleteState) this.updateAutocomplete();
return { handled: true, focus: true };
}
handleInput(data: string): void {
const kb = getKeybindings();
// Handle character jump mode (awaiting next character to jump to)
if (this.jumpMode !== null) {
// Cancel if the hotkey is pressed again
if (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) {
this.jumpMode = null;
return;
}
const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined);
if (printable !== undefined) {
// Printable character - perform the jump
const direction = this.jumpMode;
this.jumpMode = null;
this.jumpToChar(printable, direction);
return;
}
// Control character - cancel and fall through to normal handling
this.jumpMode = null;
}
// Handle bracketed paste mode
if (data.includes("\x1b[200~")) {
this.isInPaste = true;
this.pasteBuffer = "";
this.pasteBurst.reset();
data = data.replace("\x1b[200~", "");
}
if (this.isInPaste) {
this.pasteBuffer += data;
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
if (endIndex !== -1) {
const pasteContent = this.pasteBuffer.substring(0, endIndex);
if (pasteContent.length > 0) {
this.handlePaste(pasteContent);
}
this.isInPaste = false;
const remaining = this.pasteBuffer.substring(endIndex + 6);
this.pasteBuffer = "";
this.pasteBurst.reset();
if (remaining.length > 0) {
this.handleInput(remaining);
}
return;
}
return;
}
const isEnterKey = data !== "\n" && kb.matches(data, "tui.input.submit");
const charCode = data.charCodeAt(0);
const printableForBurst = decodePrintableKey(data) ??
(data.length === 1 && charCode >= 32 && charCode !== 0x7f ? data : undefined);
if (!this.disablePasteBurst && !isEnterKey && printableForBurst === undefined) {
this.pasteBurst.reset();
}
// Ctrl+C - let parent handle (exit/clear)
if (kb.matches(data, "tui.input.copy")) {
return;
}
// Undo
if (kb.matches(data, "tui.editor.undo")) {
this.undo();
return;
}
// Handle autocomplete mode
if (this.autocompleteState && this.autocompleteList) {
if (kb.matches(data, "tui.select.cancel")) {
this.cancelAutocomplete();
return;
}
if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) {
this.autocompleteList.handleInput(data);
return;
}
if (kb.matches(data, "tui.input.tab")) {
const selected = this.autocompleteList.getSelectedItem();
if (selected && this.autocompleteProvider) {
this.pushUndoSnapshot();
this.lastAction = null;
const result = this.autocompleteProvider.applyCompletion(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
selected,
this.autocompletePrefix,
);
this.state.lines = result.lines;
this.state.cursorLine = result.cursorLine;
this.setCursorCol(result.cursorCol);
this.cancelAutocomplete();
if (this.onChange) this.onChange(this.getText());
}
return;
}
if (kb.matches(data, "tui.select.confirm")) {
const selected = this.autocompleteList.getSelectedItem();
if (selected && this.autocompleteProvider) {
this.pushUndoSnapshot();
this.lastAction = null;
const result = this.autocompleteProvider.applyCompletion(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
selected,
this.autocompletePrefix,
);
this.state.lines = result.lines;
this.state.cursorLine = result.cursorLine;
this.setCursorCol(result.cursorCol);
// Slash-command completions submit on confirm (Enter runs the
// command); inline completions marked by the provider (e.g. an
// inline skill token mid-prompt) are content edits, so confirm
// behaves like Tab and never submits.
if (this.autocompletePrefix.startsWith("/") && selected.data?.["inlineSkill"] !== true) {
this.cancelAutocomplete();
// Fall through to submit
} else {
this.cancelAutocomplete();
if (this.onChange) this.onChange(this.getText());
return;
}
}
}
}
// Tab - trigger completion
if (kb.matches(data, "tui.input.tab") && !this.autocompleteState) {
this.handleTabCompletion();
return;
}
// Deletion actions
if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
this.deleteToEndOfLine();
return;
}
if (kb.matches(data, "tui.editor.deleteToLineStart")) {
this.deleteToStartOfLine();
return;
}
if (kb.matches(data, "tui.editor.deleteWordBackward")) {
this.deleteWordBackwards();
return;
}
if (kb.matches(data, "tui.editor.deleteWordForward")) {
this.deleteWordForward();
return;
}
if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
this.handleBackspace();
return;
}
if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
this.handleForwardDelete();
return;
}
// Kill ring actions
if (kb.matches(data, "tui.editor.yank")) {
this.yank();
return;
}
if (kb.matches(data, "tui.editor.yankPop")) {
this.yankPop();
return;
}
// Dedicated history actions always browse entries instead of moving the cursor.
if (kb.matches(data, "tui.editor.historyPrevious")) {
this.cancelAutocomplete();
this.navigateHistory(-1);
return;
}
if (kb.matches(data, "tui.editor.historyNext")) {
this.cancelAutocomplete();
this.navigateHistory(1);
return;
}
// Cursor movement actions
if (kb.matches(data, "tui.editor.cursorLineStart")) {
this.moveToLineStart();
return;
}
if (kb.matches(data, "tui.editor.cursorLineEnd")) {
this.moveToLineEnd();
return;
}
if (kb.matches(data, "tui.editor.cursorWordLeft")) {
this.moveWordBackwards();
return;
}
if (kb.matches(data, "tui.editor.cursorWordRight")) {
this.moveWordForwards();
return;
}
// New line
if (
kb.matches(data, "tui.input.newLine") ||
(data.charCodeAt(0) === 10 && data.length > 1) ||
data === "\x1b\r" ||
data === "\x1b[13;2~" ||
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
(data === "\n" && data.length === 1)
) {
if (this.shouldSubmitOnBackslashEnter(data, kb)) {
this.handleBackspace();
this.submitValue();
return;
}
this.addNewLine();
return;
}
// Submit (Enter)
if (kb.matches(data, "tui.input.submit")) {
if (this.disableSubmit) return;
// Workaround for terminals without Shift+Enter support:
// If char before cursor is \, delete it and insert newline instead of submitting.
const currentLine = this.state.lines[this.state.cursorLine] || "";
if (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\") {
this.handleBackspace();
this.addNewLine();
return;
}
if (!this.disablePasteBurst && this.pasteBurst.shouldInsertNewlineInsteadOfSubmit(Date.now())) {
this.addNewLine();
this.pasteBurst.extendWindow(Date.now());
return;
}
this.submitValue();
return;
}
// Arrow key navigation (with history support)
if (kb.matches(data, "tui.editor.cursorUp")) {
if (
this.isOnFirstVisualLine() &&
(this.isEditorEmpty() || this.historyIndex > -1 || this.state.cursorCol === 0)
) {
this.navigateHistory(-1);
} else if (this.isOnFirstVisualLine()) {
// Already at top - jump to start of line
this.moveToLineStart();
} else {
this.moveCursor(-1, 0);
}
return;
}
if (kb.matches(data, "tui.editor.cursorDown")) {
if (this.historyIndex > -1 && this.isOnLastVisualLine()) {
this.navigateHistory(1);
} else if (this.isOnLastVisualLine()) {
// Already at bottom - jump to end of line
this.moveToLineEnd();
} else {
this.moveCursor(1, 0);
}
return;
}
if (kb.matches(data, "tui.editor.cursorRight")) {
this.moveCursor(0, 1);
return;
}
if (kb.matches(data, "tui.editor.cursorLeft")) {
this.moveCursor(0, -1);
return;
}
// Page up/down - scroll by page and move cursor
if (kb.matches(data, "tui.editor.pageUp")) {
this.pageScroll(-1);
return;
}
if (kb.matches(data, "tui.editor.pageDown")) {
this.pageScroll(1);
return;
}
// Character jump mode triggers
if (kb.matches(data, "tui.editor.jumpForward")) {
this.jumpMode = "forward";
return;
}
if (kb.matches(data, "tui.editor.jumpBackward")) {
this.jumpMode = "backward";
return;
}
// Shift+Space - insert regular space
if (matchesKey(data, "shift+space")) {
this.insertCharacter(" ");
return;
}
const printable = decodePrintableKey(data);
if (printable !== undefined) {
if (!this.disablePasteBurst) {
this.pasteBurst.onPlainChar(Date.now());
}
this.insertCharacter(printable);
return;
}
// Regular characters
if (data.charCodeAt(0) >= 32) {
if (!this.disablePasteBurst) {
this.pasteBurst.onPlainChar(Date.now());
}
this.insertCharacter(data);
}
}
private layoutText(contentWidth: number): LayoutLine[] {
const layoutLines: LayoutLine[] = [];
if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
// Empty editor
layoutLines.push({
text: "",
hasCursor: true,
cursorPos: 0,
});
return layoutLines;
}
// Process each logical line
for (let i = 0; i < this.state.lines.length; i++) {
const line = this.state.lines[i] || "";
const isCurrentLine = i === this.state.cursorLine;
const lineVisibleWidth = visibleWidth(line);
if (lineVisibleWidth <= contentWidth) {
// Line fits in one layout line
if (isCurrentLine) {
layoutLines.push({
text: line,
hasCursor: true,
cursorPos: this.state.cursorCol,
});
} else {
layoutLines.push({
text: line,
hasCursor: false,
});
}
} else {
// Line needs wrapping - use word-aware wrapping
const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]);
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
const chunk = chunks[chunkIndex];
if (!chunk) continue;
const cursorPos = this.state.cursorCol;
const isLastChunk = chunkIndex === chunks.length - 1;
// Determine if cursor is in this chunk
// For word-wrapped chunks, we need to handle the case where
// cursor might be in trimmed whitespace at end of chunk
let hasCursorInChunk = false;
let adjustedCursorPos = 0;
if (isCurrentLine) {
if (isLastChunk) {
// Last chunk: cursor belongs here if >= startIndex
hasCursorInChunk = cursorPos >= chunk.startIndex;
adjustedCursorPos = cursorPos - chunk.startIndex;
} else {
// Non-last chunk: cursor belongs here if in range [startIndex, endIndex)
// But we need to handle the visual position in the trimmed text
hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex;
if (hasCursorInChunk) {
adjustedCursorPos = cursorPos - chunk.startIndex;
// Clamp to text length (in case cursor was in trimmed whitespace)
if (adjustedCursorPos > chunk.text.length) {
adjustedCursorPos = chunk.text.length;
}
}
}
}
if (hasCursorInChunk) {
layoutLines.push({
text: chunk.text,
hasCursor: true,
cursorPos: adjustedCursorPos,
});
} else {
layoutLines.push({
text: chunk.text,
hasCursor: false,
});
}
}
}
}
return layoutLines;
}
getText(): string {
return this.state.lines.join("\n");
}
private expandPasteMarkers(text: string): string {
let result = text;
for (const [pasteId, pasteContent] of this.pastes) {
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
result = result.replace(markerRegex, () => pasteContent);
}
return result;
}
/**
* Get text with paste markers expanded to their actual content.
* Use this when you need the full content (e.g., for external editor).
*/
getExpandedText(): string {
return this.expandPasteMarkers(this.state.lines.join("\n"));
}
getLines(): string[] {
return [...this.state.lines];
}
getCursor(): { line: number; col: number } {
return { line: this.state.cursorLine, col: this.state.cursorCol };
}
setText(text: string, options?: { preservePasteRegistry?: boolean }): void {
this.cancelAutocomplete();
this.lastAction = null;
this.exitHistoryBrowsing();
const normalized = this.normalizeText(text);
// Push undo snapshot if content differs (makes programmatic changes undoable)
if (this.getText() !== normalized) {
this.pushUndoSnapshot();
}
if (!options?.preservePasteRegistry) {
this.pastes.clear();
this.pasteCounter = 0;
}
this.setTextInternal(normalized);
}
/**
* Insert text at the current cursor position.
* Used for programmatic insertion (e.g., clipboard image markers).
* This is atomic for undo - single undo restores entire pre-insert state.
*/
insertTextAtCursor(text: string): void {
if (!text) return;
this.cancelAutocomplete();
this.pushUndoSnapshot();
this.lastAction = null;
this.exitHistoryBrowsing();
this.insertTextAtCursorInternal(text);
}
/**
* Normalize text for editor storage:
* - Normalize line endings (\r\n and \r -> \n)
* - Expand tabs to 4 spaces
*/
private normalizeText(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
}
/**
* Internal text insertion at cursor. Handles single and multi-line text.
* Does not push undo snapshots or trigger autocomplete - caller is responsible.
* Normalizes line endings and calls onChange once at the end.
*/
private insertTextAtCursorInternal(text: string): void {
if (!text) return;
// Normalize line endings and tabs
const normalized = this.normalizeText(text);
const insertedLines = normalized.split("\n");
const currentLine = this.state.lines[this.state.cursorLine] || "";
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
const afterCursor = currentLine.slice(this.state.cursorCol);
if (insertedLines.length === 1) {
// Single line - insert at cursor position
this.state.lines[this.state.cursorLine] = beforeCursor + normalized + afterCursor;
this.setCursorCol(this.state.cursorCol + normalized.length);
} else {
// Multi-line insertion
this.state.lines = [
// All lines before current line
...this.state.lines.slice(0, this.state.cursorLine),
// The first inserted line merged with text before cursor
beforeCursor + insertedLines[0],
// All middle inserted lines
...insertedLines.slice(1, -1),
// The last inserted line with text after cursor
insertedLines[insertedLines.length - 1] + afterCursor,
// All lines after current line
...this.state.lines.slice(this.state.cursorLine + 1),
];
this.state.cursorLine += insertedLines.length - 1;
this.setCursorCol((insertedLines[insertedLines.length - 1] || "").length);
}
if (this.onChange) {
this.onChange(this.getText());
}
}
// All the editor methods from before...
private insertCharacter(char: string, skipUndoCoalescing?: boolean): void {
this.exitHistoryBrowsing();
// Undo coalescing (fish-style):
// - Consecutive word chars coalesce into one undo unit
// - Space captures state before itself (so undo removes space+following word together)
// - Each space is separately undoable
// Skip coalescing when called from atomic operations (e.g., handlePaste)
if (!skipUndoCoalescing) {
if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
this.pushUndoSnapshot();
}
this.lastAction = "type-word";
}
const line = this.state.lines[this.state.cursorLine] || "";
const before = line.slice(0, this.state.cursorCol);
const after = line.slice(this.state.cursorCol);
this.state.lines[this.state.cursorLine] = before + char + after;
this.setCursorCol(this.state.cursorCol + char.length);
if (this.onChange) {
this.onChange(this.getText());
}
// Check if we should trigger or update autocomplete
if (!this.autocompleteState) {
// Auto-trigger for "/" at the start of a line (slash commands). When
// the editor opts in, also trigger "/" after whitespace mid-input so
// the provider can offer inline completions; ordinary prose slashes
// (paths, fractions) are left untouched.
if (char === "/" && (this.isAtStartOfMessage() || (this.inlineSlashTrigger && this.isAtInlineSlashTrigger()))) {
this.tryTriggerAutocomplete();
}
// Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries
else if (this.autocompleteTriggerCharacters.includes(char)) {
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
this.tryTriggerAutocomplete();
}
}
// Also auto-trigger when typing letters in a slash command or symbol completion context
else if (/[a-zA-Z0-9.:\-_]/.test(char)) {
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
// Check if we're in a slash command (with or without space for arguments)
if (this.isInSlashCommandContext(textBeforeCursor)) {
this.tryTriggerAutocomplete();
}
// Check if we're in a symbol-based completion context like @, #, or provider triggers
else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
this.tryTriggerAutocomplete();
}
// Check if we're typing an inline slash token (opt-in trigger):
// without this the slash's in-flight request goes stale as the
// token grows and no fresh request replaces it.
else if (this.isInInlineSlashContext(textBeforeCursor)) {
this.tryTriggerAutocomplete();
}
}
} else {
this.updateAutocomplete();
}
}
private handlePaste(pastedText: string): void {
this.cancelAutocomplete();
this.exitHistoryBrowsing();
this.lastAction = null;
this.pushUndoSnapshot();
// Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
// control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
// (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
// per-char filter below preserves newlines instead of stripping ESC and
// leaking the printable tail (e.g. "[106;5u") into the editor.
const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
const cp = Number(code);
if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
return match;
});
// Clean the pasted text: normalize line endings, expand tabs
const cleanText = this.normalizeText(decodedText);
// Filter out non-printable characters except newlines
let filteredText = cleanText
.split("")
.filter((char) => char === "\n" || char.charCodeAt(0) >= 32)
.join("");
// If pasting a file path (starts with /, ~, or .) and the character before
// the cursor is a word character, prepend a space for better readability
if (/^[/~.]/.test(filteredText)) {
const currentLine = this.state.lines[this.state.cursorLine] || "";
const charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : "";
if (charBeforeCursor && /\w/.test(charBeforeCursor)) {
filteredText = ` ${filteredText}`;
}
}
// Split into lines to check for large paste
const pastedLines = filteredText.split("\n");
// Check if this is a large paste (> 10 lines or > 1000 characters)
const totalChars = filteredText.length;
if (pastedLines.length > 10 || totalChars > 1000) {
// Store the paste and insert a marker
this.pasteCounter++;
const pasteId = this.pasteCounter;
this.pastes.set(pasteId, filteredText);
// Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]"
const marker =
pastedLines.length > 10
? `[paste #${pasteId} +${pastedLines.length} lines]`
: `[paste #${pasteId} ${totalChars} chars]`;
this.insertTextAtCursorInternal(marker);
return;
}
if (pastedLines.length === 1) {
// Single line - insert atomically (do not trigger autocomplete during paste)
this.insertTextAtCursorInternal(filteredText);
return;
}
// Multi-line paste - use direct state manipulation
this.insertTextAtCursorInternal(filteredText);
}
private addNewLine(): void {
this.cancelAutocomplete();
this.exitHistoryBrowsing();
this.lastAction = null;
this.pushUndoSnapshot();
const currentLine = this.state.lines[this.state.cursorLine] || "";
const before = currentLine.slice(0, this.state.cursorCol);
const after = currentLine.slice(this.state.cursorCol);
// Split current line
this.state.lines[this.state.cursorLine] = before;
this.state.lines.splice(this.state.cursorLine + 1, 0, after);
// Move cursor to start of new line
this.state.cursorLine++;
this.setCursorCol(0);
if (this.onChange) {
this.onChange(this.getText());
}
}
private shouldSubmitOnBackslashEnter(data: string, kb: ReturnType<typeof getKeybindings>): boolean {
if (this.disableSubmit) return false;
if (!matchesKey(data, "enter")) return false;
const submitKeys = kb.getKeys("tui.input.submit");
const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
if (!hasShiftEnter) return false;
const currentLine = this.state.lines[this.state.cursorLine] || "";
return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\";
}
private submitValue(): void {
this.cancelAutocomplete();
const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim();
this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
this.pastes.clear();
this.pasteCounter = 0;
this.exitHistoryBrowsing();
this.scrollOffset = 0;
this.undoStack.clear();
this.lastAction = null;
if (this.onChange) this.onChange("");
if (this.onSubmit) this.onSubmit(result);
}
private handleBackspace(): void {
this.exitHistoryBrowsing();
this.lastAction = null;
if (this.state.cursorCol > 0) {
this.pushUndoSnapshot();
// Delete grapheme before cursor (handles emojis, combining characters, etc.)
let line = this.state.lines[this.state.cursorLine] || "";
const beforeCursor = line.slice(0, this.state.cursorCol);
// Find the last grapheme in the text before cursor
const graphemes = [...this.segment(beforeCursor, "grapheme")];
const lastGrapheme = graphemes[graphemes.length - 1];
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
const isPastedSegmented = PASTE_MARKER_SINGLE.exec(lastGrapheme!.segment);
if (isPastedSegmented) {
// This contains the id part e.g 4 from [paste #4 +123 lines]
const targetId = Number(isPastedSegmented[1]);
this.pastes.delete(targetId);
this.pasteCounter--;
// Shift registry entries down in ascending id order, independent
// of marker order in the text ([paste #3] becomes [paste #2] when
// [paste #1] is removed).
const higherIds = [...this.pastes.keys()].filter((id) => id > targetId).sort((a, b) => a - b);
for (const id of higherIds) {
this.pastes.set(id - 1, this.pastes.get(id)!);
this.pastes.delete(id);
}
// Renumber markers with ids greater than the removed one.
this.state.lines = this.state.lines.map((line) =>
line.replace(PASTE_MARKER_REGEX, (fullMatch, idGroup, suffixGroup) => {
const x = Number(idGroup);
if (x <= targetId) return fullMatch;
return `[paste #${x - 1}${suffixGroup}]`;
}),
);
}
line = this.state.lines[this.state.cursorLine] || "";
const before = line.slice(0, this.state.cursorCol - graphemeLength);
const after = line.slice(this.state.cursorCol);
this.state.lines[this.state.cursorLine] = before + after;
this.setCursorCol(this.state.cursorCol - graphemeLength);
} else if (this.state.cursorLine > 0) {
this.pushUndoSnapshot();
// Merge with previous line
const currentLine = this.state.lines[this.state.cursorLine] || "";
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
this.state.lines.splice(this.state.cursorLine, 1);
this.state.cursorLine--;
this.setCursorCol(previousLine.length);
}
if (this.onChange) {
this.onChange(this.getText());
}
// Update or re-trigger autocomplete after backspace
if (this.autocompleteState) {
this.updateAutocomplete();
} else {
// If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
// Slash command context
if (this.isInSlashCommandContext(textBeforeCursor)) {
this.tryTriggerAutocomplete();
}
// Symbol-based completion context like @, #, or provider triggers
else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
this.tryTriggerAutocomplete();
}
}
}
/**
* Set cursor column and clear preferredVisualCol.
* Use this for all non-vertical cursor movements to reset sticky column behavior.
*/
private setCursorCol(col: number): void {
this.state.cursorCol = col;
this.preferredVisualCol = null;
this.snappedFromCursorCol = null;
}
/**
* Move cursor to a target visual line, applying sticky column logic.
* Shared by moveCursor() and pageScroll().
*/
private moveToVisualLine(
visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
currentVisualLine: number,
targetVisualLine: number,
): void {
const currentVL = visualLines[currentVisualLine];
const targetVL = visualLines[targetVisualLine];
if (!(currentVL && targetVL)) return;
// When the cursor was snapped to a segment start, resolve the pre-snap
// position against the VL it belongs to. This gives the correct visual
// column even after a resize reshuffles VLs.
let currentVisualCol: number;
if (this.snappedFromCursorCol !== null) {
const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol);
currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex]!.startCol;
} else {
currentVisualCol = this.state.cursorCol - currentVL.startCol;
}
// For non-last segments, clamp to length-1 to stay within the segment
const isLastSourceSegment =
currentVisualLine === visualLines.length - 1 ||
visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
const isLastTargetSegment =
targetVisualLine === visualLines.length - 1 ||
visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol);
// Set cursor position
this.state.cursorLine = targetVL.logicalLine;
const targetCol = targetVL.startCol + moveToVisualCol;
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
// Snap cursor to atomic segment boundary (e.g. paste markers)
// so the cursor never lands in the middle of a multi-grapheme unit.
// Single-grapheme segments don't need snapping.
const segments = [...this.segment(logicalLine, "grapheme")];
for (const seg of segments) {
if (seg.index > this.state.cursorCol) break;
if (seg.segment.length <= 1) continue;
if (this.state.cursorCol < seg.index + seg.segment.length) {
const isContinuation = seg.index < targetVL.startCol;
const isMovingDown = targetVisualLine > currentVisualLine;
if (isContinuation && isMovingDown) {
// The segment started on a previous visual line, and we
// already visited it on the way down. Skip all remaining
// continuation VLs and land on the first VL past it.
const segEnd = seg.index + seg.segment.length;
let next = targetVisualLine + 1;
while (
next < visualLines.length &&
visualLines[next]!.logicalLine === targetVL.logicalLine &&
visualLines[next]!.startCol < segEnd
) {
next++;
}
if (next < visualLines.length) {
this.moveToVisualLine(visualLines, currentVisualLine, next);
return;
}
}
// Snap to the start of the segment so it gets highlighted.
// Store the pre-snap position so the next vertical move can
// resolve it to the correct visual column.
this.snappedFromCursorCol = this.state.cursorCol;
this.state.cursorCol = seg.index;
return;
}
}
// No snap occurred – we moved out of the atomic segment.
this.snappedFromCursorCol = null;
}
/**
* Compute the target visual column for vertical cursor movement.
* Implements the sticky column decision table:
*
* | P | S | T | U | Scenario | Set Preferred | Move To |
* |---|---|---|---| ---------------------------------------------------- |---------------|-------------|
* | 0 | * | 0 | - | Start nav, target fits | null | current |
* | 0 | * | 1 | - | Start nav, target shorter | current | target end |
* | 1 | 0 | 0 | 0 | Clamped, target fits preferred | null | preferred |
* | 1 | 0 | 0 | 1 | Clamped, target longer but still can't fit preferred | keep | target end |
* | 1 | 0 | 1 | - | Clamped, target even shorter | keep | target end |
* | 1 | 1 | 0 | - | Rewrapped, target fits current | null | current |
* | 1 | 1 | 1 | - | Rewrapped, target shorter than current | current | target end |
*
* Where:
* - P = preferred col is set
* - S = cursor in middle of source line (not clamped to end)
* - T = target line shorter than current visual col
* - U = target line shorter than preferred col
*/
private computeVerticalMoveColumn(
currentVisualCol: number,
sourceMaxVisualCol: number,
targetMaxVisualCol: number,
): number {
const hasPreferred = this.preferredVisualCol !== null; // P
const cursorInMiddle = currentVisualCol < sourceMaxVisualCol; // S
const targetTooShort = targetMaxVisualCol < currentVisualCol; // T
if (!hasPreferred || cursorInMiddle) {
if (targetTooShort) {
// Cases 2 and 7
this.preferredVisualCol = currentVisualCol;
return targetMaxVisualCol;
}
// Cases 1 and 6
this.preferredVisualCol = null;
return currentVisualCol;
}
const targetCantFitPreferred = targetMaxVisualCol < this.preferredVisualCol!; // U
if (targetTooShort || targetCantFitPreferred) {
// Cases 4 and 5
return targetMaxVisualCol;
}
// Case 3
const result = this.preferredVisualCol!;
this.preferredVisualCol = null;
return result;
}
private moveToLineStart(): void {
this.lastAction = null;
this.setCursorCol(0);
}
private moveToLineEnd(): void {
this.lastAction = null;
const currentLine = this.state.lines[this.state.cursorLine] || "";
this.setCursorCol(currentLine.length);
}
private deleteToStartOfLine(): void {
this.exitHistoryBrowsing();
const currentLine = this.state.lines[this.state.cursorLine] || "";
if (this.state.cursorCol > 0) {
this.pushUndoSnapshot();
// Calculate text to be deleted and save to kill ring (backward deletion = prepend)
const deletedText = currentLine.slice(0, this.state.cursorCol);
this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" });
this.lastAction = "kill";
// Delete from start of line up to cursor
this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);
this.setCursorCol(0);
} else if (this.state.cursorLine > 0) {
this.pushUndoSnapshot();
// At start of line - merge with previous line, treating newline as deleted text
this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" });
this.lastAction = "kill";
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
this.state.lines.splice(this.state.cursorLine, 1);
this.state.cursorLine--;
this.setCursorCol(previousLine.length);
}
if (this.onChange) {
this.onChange(this.getText());
}
}
private deleteToEndOfLine(): void {
this.exitHistoryBrowsing();
const currentLine = this.state.lines[this.state.cursorLine] || "";
if (this.state.cursorCol < currentLine.length) {
this.pushUndoSnapshot();
// Calculate text to be deleted and save to kill ring (forward deletion = append)
const deletedText = currentLine.slice(this.state.cursorCol);
this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" });
this.lastAction = "kill";
// Delete from cursor to end of line
this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol);
} else if (this.state.cursorLine < this.state.lines.length - 1) {
this.pushUndoSnapshot();
// At end of line - merge with next line, treating newline as deleted text
this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" });
this.lastAction = "kill";
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
this.state.lines.splice(this.state.cursorLine + 1, 1);
}
if (this.onChange) {
this.onChange(this.getText());
}
}
private deleteWordBackwards(): void {
this.exitHistoryBrowsing();
const currentLine = this.state.lines[this.state.cursorLine] || "";
// If at start of line, behave like backspace at column 0 (merge with previous line)
if (this.state.cursorCol === 0) {
if (this.state.cursorLine > 0) {
this.pushUndoSnapshot();
// Treat newline as deleted text (backward deletion = prepend)
this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" });
this.lastAction = "kill";
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
this.state.lines.splice(this.state.cursorLine, 1);
this.state.cursorLine--;
this.setCursorCol(previousLine.length);
}
} else {
this.pushUndoSnapshot();
// Save lastAction before cursor movement (moveWordBackwards resets it)
const wasKill = this.lastAction === "kill";
const oldCursorCol = this.state.cursorCol;
this.moveWordBackwards();
const deleteFrom = this.state.cursorCol;
this.setCursorCol(oldCursorCol);
const deletedText = currentLine.slice(deleteFrom, this.state.cursorCol);
this.killRing.push(deletedText, { prepend: true, accumulate: wasKill });
this.lastAction = "kill";
this.state.lines[this.state.cursorLine] =
currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);
this.setCursorCol(deleteFrom);
}
if (this.onChange) {
this.onChange(this.getText());
}
}
private deleteWordForward(): void {
this.exitHistoryBrowsing();
const currentLine = this.state.lines[this.state.cursorLine] || "";
// If at end of line, merge with next line (delete the newline)
if (this.state.cursorCol >= currentLine.length) {
if (this.state.cursorLine < this.state.lines.length - 1) {
this.pushUndoSnapshot();
// Treat newline as deleted text (forward deletion = append)
this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" });
this.lastAction = "kill";
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
this.state.lines.splice(this.state.cursorLine + 1, 1);
}
} else {
this.pushUndoSnapshot();
// Save lastAction before cursor movement (moveWordForwards resets it)
const wasKill = this.lastAction === "kill";
const oldCursorCol = this.state.cursorCol;
this.moveWordForwards();
const deleteTo = this.state.cursorCol;
this.setCursorCol(oldCursorCol);
const deletedText = currentLine.slice(this.state.cursorCol, deleteTo);
this.killRing.push(deletedText, { prepend: false, accumulate: wasKill });
this.lastAction = "kill";
this.state.lines[this.state.cursorLine] =
currentLine.slice(0, this.state.cursorCol) + currentLine.slice(deleteTo);
}
if (this.onChange) {
this.onChange(this.getText());
}
}
private handleForwardDelete(): void {
this.exitHistoryBrowsing();
this.lastAction = null;
const currentLine = this.state.lines[this.state.cursorLine] || "";
if (this.state.cursorCol < currentLine.length) {
this.pushUndoSnapshot();
// Delete grapheme at cursor position (handles emojis, combining characters, etc.)
const afterCursor = currentLine.slice(this.state.cursorCol);
// Find the first grapheme at cursor
const graphemes = [...this.segment(afterCursor, "grapheme")];
const firstGrapheme = graphemes[0];
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
const before = currentLine.slice(0, this.state.cursorCol);
const after = currentLine.slice(this.state.cursorCol + graphemeLength);
this.state.lines[this.state.cursorLine] = before + after;
} else if (this.state.cursorLine < this.state.lines.length - 1) {
this.pushUndoSnapshot();
// At end of line - merge with next line
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
this.state.lines.splice(this.state.cursorLine + 1, 1);
}
if (this.onChange) {
this.onChange(this.getText());
}
// Update or re-trigger autocomplete after forward delete
if (this.autocompleteState) {
this.updateAutocomplete();
} else {
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
// Slash command context
if (this.isInSlashCommandContext(textBeforeCursor)) {
this.tryTriggerAutocomplete();
}
// Symbol-based completion context like @, #, or provider triggers
else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
this.tryTriggerAutocomplete();
}
}
}
/**
* Build a mapping from visual lines to logical positions.
* Returns an array where each element represents a visual line with:
* - logicalLine: index into this.state.lines
* - startCol: starting column in the logical line
* - length: length of this visual line segment
*/
private buildVisualLineMap(width: number): Array<{ logicalLine: number; startCol: number; length: number }> {
const visualLines: Array<{ logicalLine: number; startCol: number; length: number }> = [];
for (let i = 0; i < this.state.lines.length; i++) {
const line = this.state.lines[i] || "";
const lineVisWidth = visibleWidth(line);
if (line.length === 0) {
// Empty line still takes one visual line
visualLines.push({ logicalLine: i, startCol: 0, length: 0 });
} else if (lineVisWidth <= width) {
visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
} else {
// Line needs wrapping - use word-aware wrapping
const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]);
for (const chunk of chunks) {
visualLines.push({
logicalLine: i,
startCol: chunk.startIndex,
length: chunk.endIndex - chunk.startIndex,
});
}
}
}
return visualLines;
}
/**
* Find the visual line index that contains the given logical position.
*/
private findVisualLineAt(
visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
line: number,
col: number,
): number {
for (let i = 0; i < visualLines.length; i++) {
const vl = visualLines[i];
if (!vl || vl.logicalLine !== line) continue;
const offset = col - vl.startCol;
// Cursor is in this segment if it's within range. For the last
// segment of a logical line, cursor can be at length (end position)
const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine;
if (offset >= 0 && (offset < vl.length || (isLastSegmentOfLine && offset === vl.length))) {
return i;
}
}
return visualLines.length - 1;
}
/**
* Find the visual line index for the current cursor position.
*/
private findCurrentVisualLine(
visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
): number {
return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol);
}
private moveCursor(deltaLine: number, deltaCol: number): void {
this.lastAction = null;
const visualLines = this.buildVisualLineMap(this.lastWidth);
const currentVisualLine = this.findCurrentVisualLine(visualLines);
if (deltaLine !== 0) {
const targetVisualLine = currentVisualLine + deltaLine;
if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
}
}
if (deltaCol !== 0) {
const currentLine = this.state.lines[this.state.cursorLine] || "";
if (deltaCol > 0) {
// Moving right - move by one grapheme (handles emojis, combining characters, etc.)
if (this.state.cursorCol < currentLine.length) {
const afterCursor = currentLine.slice(this.state.cursorCol);
const graphemes = [...this.segment(afterCursor, "grapheme")];
const firstGrapheme = graphemes[0];
this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
} else if (this.state.cursorLine < this.state.lines.length - 1) {
// Wrap to start of next logical line
this.state.cursorLine++;
this.setCursorCol(0);
} else {
// At end of last line - can't move, but set preferredVisualCol for up/down navigation
const currentVL = visualLines[currentVisualLine];
if (currentVL) {
this.preferredVisualCol = this.state.cursorCol - currentVL.startCol;
}
}
} else {
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
if (this.state.cursorCol > 0) {
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
const graphemes = [...this.segment(beforeCursor, "grapheme")];
const lastGrapheme = graphemes[graphemes.length - 1];
this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
} else if (this.state.cursorLine > 0) {
// Wrap to end of previous logical line
this.state.cursorLine--;
const prevLine = this.state.lines[this.state.cursorLine] || "";
this.setCursorCol(prevLine.length);
}
}
}
// Keep an open autocomplete picker in sync with the new cursor
// position: cursor movement changes the text before the cursor, so a
// picker computed for the old position is stale. Re-query so it
// refreshes — or closes when the new position yields no suggestions —
// mirroring insertCharacter()/handleBackspace(). Without this, arrowing
// left from `/cmd ` back into the command name leaves the argument
// picker showing against a `/cmd` prefix (and a Tab there would
// concatenate the stale suggestion onto the partial command name).
if (this.autocompleteState) {
this.updateAutocomplete();
}
}
/**
* Scroll by a page (direction: -1 for up, 1 for down).
* Moves cursor by the page size while keeping it in bounds.
*/
private pageScroll(direction: -1 | 1): void {
this.lastAction = null;
const terminalRows = this.tui.terminal.rows;
const pageSize = Math.max(5, Math.floor(terminalRows * 0.3));
const visualLines = this.buildVisualLineMap(this.lastWidth);
const currentVisualLine = this.findCurrentVisualLine(visualLines);
const targetVisualLine = Math.max(0, Math.min(visualLines.length - 1, currentVisualLine + direction * pageSize));
this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
}
private moveWordBackwards(): void {
this.lastAction = null;
const currentLine = this.state.lines[this.state.cursorLine] || "";
// If at start of line, move to end of previous line
if (this.state.cursorCol === 0) {
if (this.state.cursorLine > 0) {
this.state.cursorLine--;
const prevLine = this.state.lines[this.state.cursorLine] || "";
this.setCursorCol(prevLine.length);
}
return;
}
this.setCursorCol(
findWordBackward(currentLine, this.state.cursorCol, {
segment: (text) => this.segment(text, "word"),
isAtomicSegment: isPasteMarker,
}),
);
}
/**
* Yank (paste) the most recent kill ring entry at cursor position.
*/
private yank(): void {
if (this.killRing.length === 0) return;
this.pushUndoSnapshot();
const text = this.killRing.peek()!;
this.insertYankedText(text);
this.lastAction = "yank";
}
/**
* Cycle through kill ring (only works immediately after yank or yank-pop).
* Replaces the last yanked text with the previous entry in the ring.
*/
private yankPop(): void {
// Only works if we just yanked and have more than one entry
if (this.lastAction !== "yank" || this.killRing.length <= 1) return;
this.pushUndoSnapshot();
// Delete the previously yanked text (still at end of ring before rotation)
this.deleteYankedText();
// Rotate the ring: move end to front
this.killRing.rotate();
// Insert the new most recent entry (now at end after rotation)
const text = this.killRing.peek()!;
this.insertYankedText(text);
this.lastAction = "yank";
}
/**
* Insert text at cursor position (used by yank operations).
*/
private insertYankedText(text: string): void {
this.exitHistoryBrowsing();
const lines = text.split("\n");
if (lines.length === 1) {
// Single line - insert at cursor
const currentLine = this.state.lines[this.state.cursorLine] || "";
const before = currentLine.slice(0, this.state.cursorCol);
const after = currentLine.slice(this.state.cursorCol);
this.state.lines[this.state.cursorLine] = before + text + after;
this.setCursorCol(this.state.cursorCol + text.length);
} else {
// Multi-line insert
const currentLine = this.state.lines[this.state.cursorLine] || "";
const before = currentLine.slice(0, this.state.cursorCol);
const after = currentLine.slice(this.state.cursorCol);
// First line merges with text before cursor
this.state.lines[this.state.cursorLine] = before + (lines[0] || "");
// Insert middle lines
for (let i = 1; i < lines.length - 1; i++) {
this.state.lines.splice(this.state.cursorLine + i, 0, lines[i] || "");
}
// Last line merges with text after cursor
const lastLineIndex = this.state.cursorLine + lines.length - 1;
this.state.lines.splice(lastLineIndex, 0, (lines[lines.length - 1] || "") + after);
// Update cursor position
this.state.cursorLine = lastLineIndex;
this.setCursorCol((lines[lines.length - 1] || "").length);
}
if (this.onChange) {
this.onChange(this.getText());
}
}
/**
* Delete the previously yanked text (used by yank-pop).
* The yanked text is derived from killRing[end] since it hasn't been rotated yet.
*/
private deleteYankedText(): void {
const yankedText = this.killRing.peek();
if (!yankedText) return;
const yankLines = yankedText.split("\n");
if (yankLines.length === 1) {
// Single line - delete backward from cursor
const currentLine = this.state.lines[this.state.cursorLine] || "";
const deleteLen = yankedText.length;
const before = currentLine.slice(0, this.state.cursorCol - deleteLen);
const after = currentLine.slice(this.state.cursorCol);
this.state.lines[this.state.cursorLine] = before + after;
this.setCursorCol(this.state.cursorCol - deleteLen);
} else {
// Multi-line delete - cursor is at end of last yanked line
const startLine = this.state.cursorLine - (yankLines.length - 1);
const startCol = (this.state.lines[startLine] || "").length - (yankLines[0] || "").length;
// Get text after cursor on current line
const afterCursor = (this.state.lines[this.state.cursorLine] || "").slice(this.state.cursorCol);
// Get text before yank start position
const beforeYank = (this.state.lines[startLine] || "").slice(0, startCol);
// Remove all lines from startLine to cursorLine and replace with merged line
this.state.lines.splice(startLine, yankLines.length, beforeYank + afterCursor);
// Update cursor
this.state.cursorLine = startLine;
this.setCursorCol(startCol);
}
if (this.onChange) {
this.onChange(this.getText());
}
}
private pushUndoSnapshot(): void {
this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter });
}
private undo(): void {
this.exitHistoryBrowsing();
const snapshot = this.undoStack.pop();
if (!snapshot) return;
Object.assign(this.state, snapshot.state);
this.pastes = snapshot.pastes;
this.pasteCounter = snapshot.pasteCounter;
this.lastAction = null;
this.preferredVisualCol = null;
if (this.onChange) {
this.onChange(this.getText());
}
}
/**
* Jump to the first occurrence of a character in the specified direction.
* Multi-line search. Case-sensitive. Skips the current cursor position.
*/
private jumpToChar(char: string, direction: "forward" | "backward"): void {
this.lastAction = null;
const isForward = direction === "forward";
const lines = this.state.lines;
const end = isForward ? lines.length : -1;
const step = isForward ? 1 : -1;
for (let lineIdx = this.state.cursorLine; lineIdx !== end; lineIdx += step) {
const line = lines[lineIdx] || "";
const isCurrentLine = lineIdx === this.state.cursorLine;
// Current line: start after/before cursor; other lines: search full line
const searchFrom = isCurrentLine
? isForward
? this.state.cursorCol + 1
: this.state.cursorCol - 1
: undefined;
const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom);
if (idx !== -1) {
this.state.cursorLine = lineIdx;
this.setCursorCol(idx);
return;
}
}
// No match found - cursor stays in place
}
private moveWordForwards(): void {
this.lastAction = null;
const currentLine = this.state.lines[this.state.cursorLine] || "";
// If at end of line, move to start of next line
if (this.state.cursorCol >= currentLine.length) {
if (this.state.cursorLine < this.state.lines.length - 1) {
this.state.cursorLine++;
this.setCursorCol(0);
}
return;
}
this.setCursorCol(
findWordForward(currentLine, this.state.cursorCol, {
segment: (text) => this.segment(text, "word"),
isAtomicSegment: isPasteMarker,
}),
);
}
// Slash menu only allowed on the first line of the editor
private isSlashMenuAllowed(): boolean {
return this.state.cursorLine === 0;
}
// Helper method to check if cursor is at start of message (for slash command detection)
private isAtStartOfMessage(): boolean {
if (!this.isSlashMenuAllowed()) return false;
const currentLine = this.state.lines[this.state.cursorLine] || "";
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
}
// Helper method to check if "/" was typed after whitespace mid-input, which
// providers can use for inline completions while leaving ordinary prose
// slashes (e.g. paths, fractions) untouched. Also covers "/" at the start
// of a non-first line, so inline completion works across multi-line input.
private isAtInlineSlashTrigger(): boolean {
if (this.isAtStartOfMessage()) return false;
const currentLine = this.state.lines[this.state.cursorLine] || "";
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
if (!beforeCursor.endsWith("/")) return false;
// "/" at the start of a subsequent line.
if (this.state.cursorLine > 0 && beforeCursor.trim() === "/") {
return true;
}
const charBeforeSlash = beforeCursor[beforeCursor.length - 2];
return charBeforeSlash === " " || charBeforeSlash === "\t";
}
// Whether the token being typed is an inline slash token (opt-in trigger):
// a "/" after whitespace mid-input, or a "/" opening a subsequent line —
// isSlashMenuAllowed confines the slash-command menu to the first line,
// while the inline trigger deliberately covers later lines too. The
// leading slash-command context is owned by isInSlashCommandContext.
private isInInlineSlashContext(textBeforeCursor: string): boolean {
if (!this.inlineSlashTrigger) return false;
if (this.isInSlashCommandContext(textBeforeCursor)) return false;
if (/[ \t]\/[a-zA-Z0-9.:\-_]*$/.test(textBeforeCursor)) return true;
return this.state.cursorLine > 0 && /^\/[a-zA-Z0-9.:\-_]*$/.test(textBeforeCursor);
}
private isInSlashCommandContext(textBeforeCursor: string): boolean {
return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/");
}
// Autocomplete methods
/**
* Find the best autocomplete item index for the given prefix.
* Returns -1 if no match is found.
*
* Match priority:
* 1. Exact match (prefix === item.value) -> always selected
* 2. Prefix match -> first item whose value starts with prefix
* 3. No match -> -1 (keep default highlight)
*
* Matching is case-sensitive and checks item.value only.
*/
private getBestAutocompleteMatchIndex(items: Array<{ value: string; label: string }>, prefix: string): number {
if (!prefix) return -1;
let firstPrefixIndex = -1;
for (let i = 0; i < items.length; i++) {
const value = items[i]!.value;
if (value === prefix) {
return i; // Exact match always wins
}
if (firstPrefixIndex === -1 && value.startsWith(prefix)) {
firstPrefixIndex = i;
}
}
return firstPrefixIndex;
}
private createAutocompleteList(
prefix: string,
items: Array<{ value: string; label: string; description?: string }>,
): SelectList {
const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined;
const list = new SelectList(items, this.autocompleteMaxVisible, this.theme.selectList, layout);
list.onSelect = (selected) => {
if (!this.autocompleteProvider) return;
this.pushUndoSnapshot();
this.lastAction = null;
const result = this.autocompleteProvider.applyCompletion(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
selected,
this.autocompletePrefix,
);
this.state.lines = result.lines;
this.state.cursorLine = result.cursorLine;
this.setCursorCol(result.cursorCol);
this.cancelAutocomplete();
this.onChange?.(this.getText());
};
return list;
}
private tryTriggerAutocomplete(explicitTab: boolean = false): void {
this.requestAutocomplete({ force: false, explicitTab });
}
private handleTabCompletion(): void {
if (!this.autocompleteProvider) return;
const currentLine = this.state.lines[this.state.cursorLine] || "";
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) {
this.handleSlashCommandCompletion();
} else {
this.forceFileAutocomplete(true);
}
}
private handleSlashCommandCompletion(): void {
this.requestAutocomplete({ force: false, explicitTab: true });
}
private forceFileAutocomplete(explicitTab: boolean = false): void {
this.requestAutocomplete({ force: true, explicitTab });
}
private requestAutocomplete(options: { force: boolean; explicitTab: boolean }): void {
if (!this.autocompleteProvider) return;
if (options.force) {
const shouldTrigger =
!this.autocompleteProvider.shouldTriggerFileCompletion ||
this.autocompleteProvider.shouldTriggerFileCompletion(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
);
if (!shouldTrigger) {
return;
}
}
this.cancelAutocompleteRequest();
const startToken = ++this.autocompleteStartToken;
const debounceMs = this.getAutocompleteDebounceMs(options);
if (debounceMs > 0) {
this.autocompleteDebounceTimer = setTimeout(() => {
this.autocompleteDebounceTimer = undefined;
void this.startAutocompleteRequest(startToken, options);
}, debounceMs);
return;
}
void this.startAutocompleteRequest(startToken, options);
}
private async startAutocompleteRequest(
startToken: number,
options: { force: boolean; explicitTab: boolean },
): Promise<void> {
const previousTask = this.autocompleteRequestTask;
this.autocompleteRequestTask = (async () => {
await previousTask;
if (startToken !== this.autocompleteStartToken || !this.autocompleteProvider) {
return;
}
const controller = new AbortController();
this.autocompleteAbort = controller;
const requestId = ++this.autocompleteRequestId;
const snapshotText = this.getText();
const snapshotLine = this.state.cursorLine;
const snapshotCol = this.state.cursorCol;
await this.runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options);
})();
await this.autocompleteRequestTask;
}
private setAutocompleteTriggerCharacters(triggerCharacters: string[]): void {
const next = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS];
for (const character of triggerCharacters) {
if (character.length !== 1 || character === "/" || isWhitespaceChar(character) || next.includes(character)) {
continue;
}
next.push(character);
}
this.autocompleteTriggerCharacters = next;
this.autocompleteTriggerPattern = buildTriggerPattern(next);
this.autocompleteDebouncePattern = buildDebouncePattern(next);
}
private getAutocompleteDebounceMs(options: { force: boolean; explicitTab: boolean }): number {
if (options.explicitTab || options.force) {
return 0;
}
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
return this.autocompleteDebouncePattern.test(textBeforeCursor) ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
}
private async runAutocompleteRequest(
requestId: number,
controller: AbortController,
snapshotText: string,
snapshotLine: number,
snapshotCol: number,
options: { force: boolean; explicitTab: boolean },
): Promise<void> {
if (!this.autocompleteProvider) return;
const suggestions = await this.autocompleteProvider.getSuggestions(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
{ signal: controller.signal, force: options.force },
);
if (!this.isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol)) {
return;
}
this.autocompleteAbort = undefined;
if (!suggestions || !Array.isArray(suggestions.items) || suggestions.items.length === 0) {
this.cancelAutocomplete();
this.tui.requestRender();
return;
}
if (options.force && options.explicitTab && suggestions.items.length === 1) {
const item = suggestions.items[0]!;
this.pushUndoSnapshot();
this.lastAction = null;
const result = this.autocompleteProvider.applyCompletion(
this.state.lines,
this.state.cursorLine,
this.state.cursorCol,
item,
suggestions.prefix,
);
this.state.lines = result.lines;
this.state.cursorLine = result.cursorLine;
this.setCursorCol(result.cursorCol);
if (this.onChange) this.onChange(this.getText());
this.tui.requestRender();
return;
}
this.applyAutocompleteSuggestions(suggestions, options.force ? "force" : "regular");
this.tui.requestRender();
}
private isAutocompleteRequestCurrent(
requestId: number,
controller: AbortController,
snapshotText: string,
snapshotLine: number,
snapshotCol: number,
): boolean {
return (
!controller.signal.aborted &&
requestId === this.autocompleteRequestId &&
this.getText() === snapshotText &&
this.state.cursorLine === snapshotLine &&
this.state.cursorCol === snapshotCol
);
}
private applyAutocompleteSuggestions(suggestions: AutocompleteSuggestions, state: "regular" | "force"): void {
this.autocompletePrefix = suggestions.prefix;
this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
if (bestMatchIndex >= 0) {
this.autocompleteList.setSelectedIndex(bestMatchIndex);
}
this.autocompleteState = state;
}
private cancelAutocompleteRequest(): void {
this.autocompleteStartToken += 1;
if (this.autocompleteDebounceTimer) {
clearTimeout(this.autocompleteDebounceTimer);
this.autocompleteDebounceTimer = undefined;
}
this.autocompleteAbort?.abort();
this.autocompleteAbort = undefined;
}
private clearAutocompleteUi(): void {
this.autocompleteState = null;
this.autocompleteList = undefined;
this.autocompletePrefix = "";
}
private cancelAutocomplete(): void {
this.cancelAutocompleteRequest();
this.clearAutocompleteUi();
}
public isShowingAutocomplete(): boolean {
return this.autocompleteState !== null;
}
private updateAutocomplete(): void {
if (!this.autocompleteState || !this.autocompleteProvider) return;
this.requestAutocomplete({ force: this.autocompleteState === "force", explicitTab: false });
}
}