export interface JLineCellStyle { foreground?: string background?: string bold?: boolean italic?: boolean underline?: boolean dim?: boolean inverse?: boolean } export interface JLineCell { text: string width: number style: JLineCellStyle } export interface JLineRow { index: number wrapped: boolean cells: JLineCell[] } export interface JLinePrompt { value: string cursor: number rows: JLineRow[] } export interface JLineCandidate { text: string row: number column: number selected: boolean style: JLineCellStyle } export interface JLineCandidateConfirmation { lineCount: number } export interface JLineCandidateInsertion { deleteBefore: number deleteAfter: number text: string } export interface JLineInputEdit { value: string cursor: number } export const JLINE_KEY_SEQUENCES: Record = { Tab: '\t', ArrowUp: '\x1b[A', ArrowDown: '\x1b[B', ArrowRight: '\x1b[C', ArrowLeft: '\x1b[D', Home: '\x1b[H', End: '\x1b[F', Delete: '\x1b[3~', Backspace: '\x7f', Enter: '\r', Escape: '\x1b', } export function jlineKeySequence(key: string, applicationCursorKeys = false): string | undefined { if (applicationCursorKeys) { const applicationSequences: Record = { ArrowUp: '\x1bOA', ArrowDown: '\x1bOB', ArrowRight: '\x1bOC', ArrowLeft: '\x1bOD', Home: '\x1bOH', End: '\x1bOF', } if (applicationSequences[key]) return applicationSequences[key] } return JLINE_KEY_SEQUENCES[key] } export function extractJLinePrompt( rows: JLineRow[], cursorRow: number, cursorColumn: number, ): JLinePrompt | null { const cursorIndex = rows.findIndex((row) => row.index === cursorRow) if (cursorIndex < 0) return null let start = cursorIndex while (start > 0 && rows[start]?.wrapped) start-- const logicalRows = rows.slice(start, cursorIndex + 1) const logicalText = logicalRows.map(rowText).join('') if (!logicalText.startsWith('> ')) return null const charactersBeforeCursor = logicalRows .slice(0, -1) .reduce( (total, row) => total + row.cells.reduce((sum, cell) => sum + cell.text.length, 0), 0, ) + (logicalRows.at(-1)?.cells ?? []) .slice(0, cursorColumn) .reduce((sum, cell) => sum + cell.text.length, 0) const cursor = Math.max(0, charactersBeforeCursor - 2) const commandText = logicalText.slice(2) const valueEnd = Math.max(cursor, commandText.trimEnd().length) return { value: commandText.slice(0, valueEnd), cursor, rows: logicalRows, } } export function createJLineCandidateInsertion( prompt: Pick, candidateText: string, ): JLineCandidateInsertion { const cursor = Math.max(0, Math.min(prompt.cursor, prompt.value.length)) let start = cursor while (start > 0 && !/\s/.test(prompt.value[start - 1]!)) start-- let end = cursor while (end < prompt.value.length && !/\s/.test(prompt.value[end]!)) end++ const needsTrailingSpace = end === prompt.value.length && !candidateText.endsWith(' ') return { deleteBefore: cursor - start, deleteAfter: end - cursor, text: `${candidateText}${needsTrailingSpace ? ' ' : ''}`, } } export function applyJLineCandidate( prompt: Pick, candidateText: string, ): JLineInputEdit { const insertion = createJLineCandidateInsertion(prompt, candidateText) const start = prompt.cursor - insertion.deleteBefore const end = prompt.cursor + insertion.deleteAfter return { value: `${prompt.value.slice(0, start)}${insertion.text}${prompt.value.slice(end)}`, cursor: start + insertion.text.length, } } export function replaceJLineSelection( prompt: Pick, selectionStart: number, selectionEnd: number, replacement: string, ): JLineInputEdit { const start = Math.max(0, Math.min(selectionStart, selectionEnd, prompt.value.length)) const end = Math.max(start, Math.min(Math.max(selectionStart, selectionEnd), prompt.value.length)) return { value: `${prompt.value.slice(0, start)}${replacement}${prompt.value.slice(end)}`, cursor: start + replacement.length, } } export function createJLineLineReplacementSequence( currentValue: string, next: JLineInputEdit, applicationCursorKeys = false, ): string { const cursor = Math.max(0, Math.min(next.cursor, next.value.length)) return ( (currentValue ? '\x01\x0b' : '') + next.value + jlineKeySequence('ArrowLeft', applicationCursorKeys)!.repeat(next.value.length - cursor) ) } export function parseJLineCandidates(rows: JLineRow[], cursorRow: number): JLineCandidate[] { const candidates: JLineCandidate[] = [] for (const row of rows) { if (row.index <= cursorRow) continue const text = rowText(row) if (!text.trim()) continue if (/--more--|\.\.\.$/i.test(text.trim())) return [] let start = 0 while (start < row.cells.length) { while (start < row.cells.length && !row.cells[start]?.text.trim()) start++ if (start >= row.cells.length) break let end = start let blankRun = 0 while (end < row.cells.length) { if (row.cells[end]?.text.trim()) { blankRun = 0 } else { blankRun++ if (blankRun >= 2) break } end++ } const contentEnd = Math.max(start, end - blankRun + 1) const candidateText = row.cells .slice(start, contentEnd) .map((cell) => cell.text) .join('') .trim() if (candidateText) { const first = row.cells[start]! candidates.push({ text: candidateText, row: row.index, column: start, selected: row.cells.slice(start, contentEnd).some((cell) => cell.style.inverse), style: first.style, }) } start = end + 1 } } return candidates } export function parseJLineCandidateConfirmation( rows: JLineRow[], ): JLineCandidateConfirmation | null { const text = rows.map(rowText).join(' ').replace(/\s+/g, ' ').trim() const match = text.match( /(?:do you wish to see all|display all)\s+\d+\s+possibilit(?:y|ies)\b.*?\((\d+)\s+lines?\)\?\s*$/i, ) if (!match) return null const lineCount = Number.parseInt(match[1] ?? '', 10) return { lineCount: Number.isFinite(lineCount) ? lineCount : 0 } } function rowText(row: JLineRow): string { return row.cells.map((cell) => cell.text).join('') }