feat: 第一个可以用的 ink 组件抽象 (#158)

This commit is contained in:
claude-code-best
2026-04-06 23:56:45 +08:00
committed by GitHub
parent 3ea64eeb0f
commit c445f43f8d
645 changed files with 7255 additions and 1214 deletions

View File

@@ -0,0 +1,53 @@
/**
* Shared Intl object instances with lazy initialization.
*
* Intl constructors are expensive (~0.05-0.1ms each), so we cache instances
* for reuse across the codebase instead of creating new ones each time.
* Lazy initialization ensures we only pay the cost when actually needed.
*
* Vendored from src/utils/intl.ts for package independence.
*/
// Segmenters for Unicode text processing (lazily initialized)
let graphemeSegmenter: Intl.Segmenter | null = null
let wordSegmenter: Intl.Segmenter | null = null
export function getGraphemeSegmenter(): Intl.Segmenter {
if (!graphemeSegmenter) {
graphemeSegmenter = new Intl.Segmenter(undefined, {
granularity: 'grapheme',
})
}
return graphemeSegmenter
}
/**
* Extract the first grapheme cluster from a string.
* Returns '' for empty strings.
*/
export function firstGrapheme(text: string): string {
if (!text) return ''
const segments = getGraphemeSegmenter().segment(text)
const first = segments[Symbol.iterator]().next().value
return first?.segment ?? ''
}
/**
* Extract the last grapheme cluster from a string.
* Returns '' for empty strings.
*/
export function lastGrapheme(text: string): string {
if (!text) return ''
let last = ''
for (const { segment } of getGraphemeSegmenter().segment(text)) {
last = segment
}
return last
}
export function getWordSegmenter(): Intl.Segmenter {
if (!wordSegmenter) {
wordSegmenter = new Intl.Segmenter(undefined, { granularity: 'word' })
}
return wordSegmenter
}

View File

@@ -0,0 +1,97 @@
/**
* Slice a string containing ANSI escape codes.
*
* Vendored from src/utils/sliceAnsi.ts for package independence.
* The only external dependency is stringWidth from the core package.
*/
import {
type AnsiCode,
ansiCodesToString,
reduceAnsiCodes,
tokenize,
undoAnsiCodes,
} from '@alcalzone/ansi-tokenize'
import { stringWidth } from '../stringWidth.js'
// A code is an "end code" if its code equals its endCode (e.g., hyperlink close)
function isEndCode(code: AnsiCode): boolean {
return code.code === code.endCode
}
// Filter to only include "start codes" (not end codes)
function filterStartCodes(codes: AnsiCode[]): AnsiCode[] {
return codes.filter(c => !isEndCode(c))
}
/**
* Slice a string containing ANSI escape codes.
*
* Unlike the slice-ansi package, this properly handles OSC 8 hyperlink
* sequences because @alcalzone/ansi-tokenize tokenizes them correctly.
*/
export default function sliceAnsi(
str: string,
start: number,
end?: number,
): string {
// Don't pass `end` to tokenize — it counts code units, not display cells,
// so it drops tokens early for text with zero-width combining marks.
const tokens = tokenize(str)
let activeCodes: AnsiCode[] = []
let position = 0
let result = ''
let include = false
for (const token of tokens) {
// Advance by display width, not code units. Combining marks (Devanagari
// matras, virama, diacritics) are width 0 — counting them via .length
// advanced position past `end` early and truncated the slice. Callers
// pass start/end in display cells (via stringWidth), so position must
// track the same units.
const width =
token.type === 'ansi' ? 0 : token.type === 'char' ? (token.fullWidth ? 2 : stringWidth(token.value)) : 0
// Break AFTER trailing zero-width marks — a combining mark attaches to
// the preceding base char, so "भा" (भ + ा, 1 display cell) sliced at
// end=1 must include the ा. Breaking on position >= end BEFORE the
// zero-width check would drop it and render भ bare. ANSI codes are
// width 0 but must NOT be included past end (they open new style runs
// that leak into the undo sequence), so gate on char type too. The
// !include guard ensures empty slices (start===end) stay empty even
// when the string starts with a zero-width char (BOM, ZWJ).
if (end !== undefined && position >= end) {
if (token.type === 'ansi' || width > 0 || !include) break
}
if (token.type === 'ansi') {
activeCodes.push(token)
if (include) {
// Emit all ANSI codes during the slice
result += token.code
}
} else {
if (!include && position >= start) {
// Skip leading zero-width marks at the start boundary — they belong
// to the preceding base char in the left half. Without this, the
// mark appears in BOTH halves: left+right ≠ original. Only applies
// when start > 0 (otherwise there's no preceding char to own it).
if (start > 0 && width === 0) continue
include = true
// Reduce and filter to only active start codes
activeCodes = filterStartCodes(reduceAnsiCodes(activeCodes))
result = ansiCodesToString(activeCodes)
}
if (include) {
result += (token as any).value
}
position += width
}
}
// Only undo start codes that are still active
const activeStartCodes = filterStartCodes(reduceAnsiCodes(activeCodes))
result += ansiCodesToString(undoAnsiCodes(activeStartCodes))
return result
}