mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 16:01:05 +00:00
Merge PR #382: word wrapping in Editor component
This commit is contained in:
commit
2b054cdb7c
150 changed files with 10122 additions and 4285 deletions
39
packages/tui/src/components/cancellable-loader.ts
Normal file
39
packages/tui/src/components/cancellable-loader.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { isEscape } from "../keys.js";
|
||||
import { Loader } from "./loader.js";
|
||||
|
||||
/**
|
||||
* Loader that can be cancelled with Escape.
|
||||
* Extends Loader with an AbortSignal for cancelling async operations.
|
||||
*
|
||||
* @example
|
||||
* const loader = new CancellableLoader(tui, cyan, dim, "Working...");
|
||||
* loader.onAbort = () => done(null);
|
||||
* doWork(loader.signal).then(done);
|
||||
*/
|
||||
export class CancellableLoader extends Loader {
|
||||
private abortController = new AbortController();
|
||||
|
||||
/** Called when user presses Escape */
|
||||
onAbort?: () => void;
|
||||
|
||||
/** AbortSignal that is aborted when user presses Escape */
|
||||
get signal(): AbortSignal {
|
||||
return this.abortController.signal;
|
||||
}
|
||||
|
||||
/** Whether the loader was aborted */
|
||||
get aborted(): boolean {
|
||||
return this.abortController.signal.aborted;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (isEscape(data)) {
|
||||
this.abortController.abort();
|
||||
this.onAbort?.();
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,10 @@ import {
|
|||
isEnter,
|
||||
isEscape,
|
||||
isHome,
|
||||
isShiftBackspace,
|
||||
isShiftDelete,
|
||||
isShiftEnter,
|
||||
isShiftSpace,
|
||||
isTab,
|
||||
} from "../keys.js";
|
||||
import type { Component } from "../tui.js";
|
||||
|
|
@ -637,8 +640,8 @@ export class Editor implements Component {
|
|||
this.onSubmit(result);
|
||||
}
|
||||
}
|
||||
// Backspace
|
||||
else if (isBackspace(data)) {
|
||||
// Backspace (including Shift+Backspace)
|
||||
else if (isBackspace(data) || isShiftBackspace(data)) {
|
||||
this.handleBackspace();
|
||||
}
|
||||
// Line navigation shortcuts (Home/End keys)
|
||||
|
|
@ -647,8 +650,8 @@ export class Editor implements Component {
|
|||
} else if (isEnd(data)) {
|
||||
this.moveToLineEnd();
|
||||
}
|
||||
// Forward delete (Fn+Backspace or Delete key)
|
||||
else if (isDelete(data)) {
|
||||
// Forward delete (Fn+Backspace or Delete key, including Shift+Delete)
|
||||
else if (isDelete(data) || isShiftDelete(data)) {
|
||||
this.handleForwardDelete();
|
||||
}
|
||||
// Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
|
||||
|
|
@ -683,6 +686,10 @@ export class Editor implements Component {
|
|||
// Left
|
||||
this.moveCursor(0, -1);
|
||||
}
|
||||
// Shift+Space - insert regular space (Kitty protocol sends escape sequence)
|
||||
else if (isShiftSpace(data)) {
|
||||
this.insertCharacter(" ");
|
||||
}
|
||||
// Regular characters (printable characters and unicode, but not control characters)
|
||||
else if (data.charCodeAt(0) >= 32) {
|
||||
this.insertCharacter(data);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape } from "../keys.js";
|
||||
import type { Component } from "../tui.js";
|
||||
import { truncateToWidth, visibleWidth } from "../utils.js";
|
||||
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js";
|
||||
|
||||
export interface SettingItem {
|
||||
/** Unique identifier for this setting */
|
||||
|
|
@ -123,7 +123,10 @@ export class SettingsList implements Component {
|
|||
const selectedItem = this.items[this.selectedIndex];
|
||||
if (selectedItem?.description) {
|
||||
lines.push("");
|
||||
lines.push(this.theme.description(` ${truncateToWidth(selectedItem.description, width - 4, "")}`));
|
||||
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
|
||||
for (const line of wrappedDesc) {
|
||||
lines.push(this.theme.description(` ${line}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Add hint
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export {
|
|||
} from "./autocomplete.js";
|
||||
// Components
|
||||
export { Box } from "./components/box.js";
|
||||
export { CancellableLoader } from "./components/cancellable-loader.js";
|
||||
export { Editor, type EditorTheme } from "./components/editor.js";
|
||||
export { Image, type ImageOptions, type ImageTheme } from "./components/image.js";
|
||||
export { Input } from "./components/input.js";
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ const CODEPOINTS = {
|
|||
escape: 27,
|
||||
tab: 9,
|
||||
enter: 13,
|
||||
space: 32,
|
||||
backspace: 127,
|
||||
} as const;
|
||||
|
||||
|
|
@ -464,6 +465,15 @@ export function isBackspace(data: string): boolean {
|
|||
return data === "\x7f" || data === "\x08" || matchesKittySequence(data, CODEPOINTS.backspace, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if input matches Shift+Backspace (Kitty protocol).
|
||||
* Returns true so caller can treat it as regular backspace.
|
||||
* Ignores lock key bits.
|
||||
*/
|
||||
export function isShiftBackspace(data: string): boolean {
|
||||
return matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.shift);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if input matches Shift+Enter.
|
||||
* Ignores lock key bits.
|
||||
|
|
@ -480,6 +490,15 @@ export function isAltEnter(data: string): boolean {
|
|||
return data === Keys.ALT_ENTER || data === "\x1b\r" || matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if input matches Shift+Space (Kitty protocol).
|
||||
* Returns true so caller can insert a regular space.
|
||||
* Ignores lock key bits.
|
||||
*/
|
||||
export function isShiftSpace(data: string): boolean {
|
||||
return matchesKittySequence(data, CODEPOINTS.space, MODIFIERS.shift);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if input matches Option/Alt+Left (word navigation).
|
||||
* Handles multiple formats including Kitty protocol.
|
||||
|
|
@ -545,3 +564,12 @@ export function isEnd(data: string): boolean {
|
|||
export function isDelete(data: string): boolean {
|
||||
return data === "\x1b[3~" || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if input matches Shift+Delete (Kitty protocol).
|
||||
* Returns true so caller can treat it as regular delete.
|
||||
* Ignores lock key bits.
|
||||
*/
|
||||
export function isShiftDelete(data: string): boolean {
|
||||
return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, MODIFIERS.shift);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ export interface Terminal {
|
|||
clearLine(): void; // Clear current line
|
||||
clearFromCursor(): void; // Clear from cursor to end of screen
|
||||
clearScreen(): void; // Clear entire screen and move cursor to (0,0)
|
||||
|
||||
// Title operations
|
||||
setTitle(title: string): void; // Set terminal window title
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -127,4 +130,9 @@ export class ProcessTerminal implements Terminal {
|
|||
clearScreen(): void {
|
||||
process.stdout.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
|
||||
}
|
||||
|
||||
setTitle(title: string): void {
|
||||
// OSC 0;title BEL - set terminal window title
|
||||
process.stdout.write(`\x1b]0;${title}\x07`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,135 @@
|
|||
import stringWidth from "string-width";
|
||||
import { eastAsianWidth } from "get-east-asian-width";
|
||||
|
||||
// Grapheme segmenter (shared instance)
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
/**
|
||||
* Get the shared grapheme segmenter instance.
|
||||
*/
|
||||
export function getSegmenter(): Intl.Segmenter {
|
||||
return segmenter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a grapheme cluster (after segmentation) could possibly be an RGI emoji.
|
||||
* This is a fast heuristic to avoid the expensive rgiEmojiRegex test.
|
||||
* The tested Unicode blocks are deliberately broad to account for future
|
||||
* Unicode additions.
|
||||
*/
|
||||
function couldBeEmoji(segment: string): boolean {
|
||||
const cp = segment.codePointAt(0)!;
|
||||
return (
|
||||
(cp >= 0x1f000 && cp <= 0x1fbff) || // Emoji and Pictograph
|
||||
(cp >= 0x2300 && cp <= 0x23ff) || // Misc technical
|
||||
(cp >= 0x2600 && cp <= 0x27bf) || // Misc symbols, dingbats
|
||||
(cp >= 0x2b50 && cp <= 0x2b55) || // Specific stars/circles
|
||||
segment.includes("\uFE0F") || // Contains VS16 (emoji presentation selector)
|
||||
segment.length > 2 // Multi-codepoint sequences (ZWJ, skin tones, etc.)
|
||||
);
|
||||
}
|
||||
|
||||
// Regexes for character classification (same as string-width library)
|
||||
const zeroWidthRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
|
||||
const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
|
||||
const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
|
||||
|
||||
// Cache for non-ASCII strings
|
||||
const WIDTH_CACHE_SIZE = 512;
|
||||
const widthCache = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Calculate the terminal width of a single grapheme cluster.
|
||||
* Based on code from the string-width library, but includes a possible-emoji
|
||||
* check to avoid running the RGI_Emoji regex unnecessarily.
|
||||
*/
|
||||
function graphemeWidth(segment: string): number {
|
||||
// Zero-width clusters
|
||||
if (zeroWidthRegex.test(segment)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Emoji check with pre-filter
|
||||
if (couldBeEmoji(segment) && rgiEmojiRegex.test(segment)) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Get base visible codepoint
|
||||
const base = segment.replace(leadingNonPrintingRegex, "");
|
||||
const cp = base.codePointAt(0);
|
||||
if (cp === undefined) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let width = eastAsianWidth(cp);
|
||||
|
||||
// Trailing halfwidth/fullwidth forms
|
||||
if (segment.length > 1) {
|
||||
for (const char of segment.slice(1)) {
|
||||
const c = char.codePointAt(0)!;
|
||||
if (c >= 0xff00 && c <= 0xffef) {
|
||||
width += eastAsianWidth(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the visible width of a string in terminal columns.
|
||||
*/
|
||||
export function visibleWidth(str: string): number {
|
||||
const normalized = str.replace(/\t/g, " ");
|
||||
return stringWidth(normalized);
|
||||
if (str.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Fast path: pure ASCII printable
|
||||
let isPureAscii = true;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i);
|
||||
if (code < 0x20 || code > 0x7e) {
|
||||
isPureAscii = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isPureAscii) {
|
||||
return str.length;
|
||||
}
|
||||
|
||||
// Check cache
|
||||
const cached = widthCache.get(str);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Normalize: tabs to 3 spaces, strip ANSI escape codes
|
||||
let clean = str;
|
||||
if (str.includes("\t")) {
|
||||
clean = clean.replace(/\t/g, " ");
|
||||
}
|
||||
if (clean.includes("\x1b")) {
|
||||
// Strip SGR codes (\x1b[...m) and cursor codes (\x1b[...G/K/H/J)
|
||||
clean = clean.replace(/\x1b\[[0-9;]*[mGKHJ]/g, "");
|
||||
// Strip OSC 8 hyperlinks: \x1b]8;;URL\x07 and \x1b]8;;\x07
|
||||
clean = clean.replace(/\x1b\]8;;[^\x07]*\x07/g, "");
|
||||
}
|
||||
|
||||
// Calculate width
|
||||
let width = 0;
|
||||
for (const { segment } of segmenter.segment(clean)) {
|
||||
width += graphemeWidth(segment);
|
||||
}
|
||||
|
||||
// Cache result
|
||||
if (widthCache.size >= WIDTH_CACHE_SIZE) {
|
||||
const firstKey = widthCache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
widthCache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
widthCache.set(str, width);
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -406,15 +530,6 @@ function wrapSingleLine(line: string, width: number): string[] {
|
|||
return wrapped.length > 0 ? wrapped : [""];
|
||||
}
|
||||
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
/**
|
||||
* Get the shared grapheme segmenter instance.
|
||||
*/
|
||||
export function getSegmenter(): Intl.Segmenter {
|
||||
return segmenter;
|
||||
}
|
||||
|
||||
const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
|
||||
|
||||
/**
|
||||
|
|
@ -472,6 +587,9 @@ function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): s
|
|||
}
|
||||
|
||||
const grapheme = seg.value;
|
||||
// Skip empty graphemes to avoid issues with string-width calculation
|
||||
if (!grapheme) continue;
|
||||
|
||||
const graphemeWidth = visibleWidth(grapheme);
|
||||
|
||||
if (currentWidth + graphemeWidth > width) {
|
||||
|
|
@ -576,6 +694,9 @@ export function truncateToWidth(text: string, maxWidth: number, ellipsis: string
|
|||
}
|
||||
|
||||
const grapheme = seg.value;
|
||||
// Skip empty graphemes to avoid issues with string-width calculation
|
||||
if (!grapheme) continue;
|
||||
|
||||
const graphemeWidth = visibleWidth(grapheme);
|
||||
|
||||
if (currentWidth + graphemeWidth > targetWidth) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue