diff --git a/.agents/skills/gen-changesets/SKILL.md b/.agents/skills/gen-changesets/SKILL.md index 15471a032..1c93e34c1 100644 --- a/.agents/skills/gen-changesets/SKILL.md +++ b/.agents/skills/gen-changesets/SKILL.md @@ -11,6 +11,8 @@ description: Use when generating changesets in the kimi-code repository, includi All other `@moonshot-ai/*` packages are treated as internal packages, including `@moonshot-ai/kimi-code-sdk`, `agent-core`, `kosong`, `kaos`, `kimi-code-oauth`, `kimi-telemetry`, and `migration-legacy`. +`@moonshot-ai/pi-tui` is a special internal package: it is a private fork (`private: true`) that is never published, but it keeps its own changelog through changesets. It is an exception to Core Rule 4 — see the dedicated section below. + ## Core Rules 1. **Inspect the actual changes first.** Use `git status` / `git diff --name-only` to identify which packages were actually changed. @@ -176,6 +178,41 @@ Add the server-hosted web UI, including chat layout and session list behaviors. Add the server REST and WebSocket APIs that power the web UI. ``` +## `@moonshot-ai/pi-tui` changes + +`@moonshot-ai/pi-tui` is a vendored fork that lives in `packages/pi-tui`. It is `private: true` and is never published, but it is **not** ignored by changesets: changesets versions it and writes `packages/pi-tui/CHANGELOG.md` so the fork keeps its own history. Because it is bundled into the CLI like other internal packages, it is an exception to Core Rule 4 — do **not** list `@moonshot-ai/kimi-code` for a change that only touches pi-tui. + +- Changes that only affect pi-tui (build, package, strict-mode cleanup, renderer fixes): list `@moonshot-ai/pi-tui` only. No CLI changeset. +- If the same change is also user-visible in the CLI (for example a terminal rendering fix that CLI users can see), add a **separate** changeset that lists `@moonshot-ai/kimi-code` with CLI-focused wording, in addition to the pi-tui changeset. Do not mix both packages in one frontmatter — the two changelogs need different wording. + +pi-tui-only change: + +```markdown +--- +"@moonshot-ai/pi-tui": patch +--- + +Export the package manifest so the bundled binary can locate its native assets. +``` + +pi-tui change that is also visible in the CLI (two separate changesets): + +```markdown +--- +"@moonshot-ai/pi-tui": patch +--- + +Clamp the differential render to the visible viewport so scrolling up during streaming no longer jumps to the top. +``` + +```markdown +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the transcript jumping to the top when scrolling up through history during streaming output. +``` + ## Red Flags - You are about to write `major` without asking the user. @@ -188,3 +225,4 @@ Add the server REST and WebSocket APIs that power the web UI. - The wording claims more than the diff actually did. - The CLI wording mentions internal package names, class names, or PR numbers. - The entry includes real internal identifiers instead of neutral placeholders. +- A change that only touches `@moonshot-ai/pi-tui` lists `@moonshot-ai/kimi-code` instead of `@moonshot-ai/pi-tui`, or mixes both packages in one frontmatter. diff --git a/.changeset/fix-streaming-scroll-jump.md b/.changeset/fix-streaming-scroll-jump.md new file mode 100644 index 000000000..00ce0f0b9 --- /dev/null +++ b/.changeset/fix-streaming-scroll-jump.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the transcript jumping to the top when scrolling up through history during streaming output. diff --git a/.changeset/google-genai-base-url.md b/.changeset/google-genai-base-url.md index d96181504..3638e062e 100644 --- a/.changeset/google-genai-base-url.md +++ b/.changeset/google-genai-base-url.md @@ -1,7 +1,6 @@ --- "@moonshot-ai/kosong": patch "@moonshot-ai/agent-core": patch -"kimi-code-docs": patch --- Honor `base_url` for the `google-genai` and `vertexai` providers. A configured base URL was previously ignored and requests always went to `generativelanguage.googleapis.com`; it is now forwarded to the Google GenAI SDK (with `GOOGLE_GEMINI_BASE_URL` / `GOOGLE_VERTEX_BASE_URL` env fallbacks), so Gemini-compatible proxies and gateways can be used. Give the host root only — the SDK appends the API version segment itself. diff --git a/.changeset/pi-tui-export-manifest.md b/.changeset/pi-tui-export-manifest.md new file mode 100644 index 000000000..382322220 --- /dev/null +++ b/.changeset/pi-tui-export-manifest.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": patch +--- + +Export the package manifest so the bundled binary can locate its native assets. diff --git a/.changeset/pi-tui-fork-integration.md b/.changeset/pi-tui-fork-integration.md new file mode 100644 index 000000000..6cadf1d7d --- /dev/null +++ b/.changeset/pi-tui-fork-integration.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": patch +--- + +Integrate the fork into the monorepo and load it directly from source. diff --git a/.changeset/pi-tui-viewport-clamp.md b/.changeset/pi-tui-viewport-clamp.md new file mode 100644 index 000000000..eee52f6fa --- /dev/null +++ b/.changeset/pi-tui-viewport-clamp.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": patch +--- + +Clamp the differential render to the visible viewport so scrolling up during streaming no longer jumps to the top. diff --git a/.oxlintrc.json b/.oxlintrc.json index 877553f49..003359f31 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,6 +150,7 @@ "node_modules/", "apps/*/scripts/", "docs/smoke-archive/", + "packages/pi-tui/", "*.generated.ts" ] } diff --git a/apps/kimi-code/.gitignore b/apps/kimi-code/.gitignore index c559b54c0..901b7a6d2 100644 --- a/apps/kimi-code/.gitignore +++ b/apps/kimi-code/.gitignore @@ -6,3 +6,6 @@ agents/ # next to it keeps `#/generated/vis-web-asset` type-resolvable on a fresh # clone (before any build has produced the `.ts`). src/generated/vis-web-asset.ts + +# Copied from packages/pi-tui/native at build time by scripts/copy-native-assets.mjs +native/ diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 21403feb3..ca96c6912 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -28,6 +28,7 @@ "files": [ "dist", "dist-web", + "native", "scripts/postinstall.mjs", "scripts/postinstall", "README.md" @@ -48,7 +49,7 @@ "provenance": true }, "scripts": { - "build": "pnpm -C ../kimi-web run build && tsdown && node scripts/copy-web-assets.mjs", + "build": "pnpm -C ../kimi-web run build && tsdown && node scripts/copy-native-assets.mjs && node scripts/copy-web-assets.mjs", "prebuild": "node scripts/build-vis-asset.mjs", "catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json", "smoke": "node scripts/smoke.mjs", @@ -75,17 +76,16 @@ }, "optionalDependencies": { "@mariozechner/clipboard": "^0.3.9", - "koffi": "^2.16.0", "node-pty": "^1.1.0" }, "devDependencies": { - "@earendil-works/pi-tui": "^0.74.0", "@moonshot-ai/acp-adapter": "workspace:^", "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/kimi-code-sdk": "workspace:^", "@moonshot-ai/kimi-telemetry": "workspace:^", "@moonshot-ai/kimi-web": "workspace:^", "@moonshot-ai/migration-legacy": "workspace:^", + "@moonshot-ai/pi-tui": "workspace:^", "@moonshot-ai/server": "workspace:^", "@moonshot-ai/vis-server": "workspace:^", "@moonshot-ai/vis-web": "workspace:*", diff --git a/apps/kimi-code/scripts/copy-native-assets.mjs b/apps/kimi-code/scripts/copy-native-assets.mjs new file mode 100644 index 000000000..dad365a06 --- /dev/null +++ b/apps/kimi-code/scripts/copy-native-assets.mjs @@ -0,0 +1,38 @@ +import { cp, mkdir, rm, stat } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const repoRoot = resolve(appRoot, '../..'); +const source = resolve(repoRoot, 'packages/pi-tui/native'); +const target = resolve(appRoot, 'native'); + +// pi-tui ships platform-specific native helpers only for darwin/win32; +// Linux has no native helper, so there is nothing to copy for it. +const PLATFORMS = ['darwin', 'win32']; + +async function assertPrebuilds(platform) { + const dir = resolve(source, platform, 'prebuilds'); + try { + const info = await stat(dir); + if (!info.isDirectory()) { + throw new Error('not a directory'); + } + } catch { + throw new Error( + `pi-tui native prebuilds were not found at ${dir}. Build or restore packages/pi-tui first.`, + ); + } + return dir; +} + +await rm(target, { recursive: true, force: true }); +await mkdir(target, { recursive: true }); + +for (const platform of PLATFORMS) { + const srcPrebuilds = await assertPrebuilds(platform); + const dstPrebuilds = resolve(target, platform, 'prebuilds'); + await cp(srcPrebuilds, dstPrebuilds, { recursive: true }); +} + +console.log(`Copied pi-tui native prebuilds to ${target}`); diff --git a/apps/kimi-code/scripts/native/assets.mjs b/apps/kimi-code/scripts/native/assets.mjs index 7b0560b69..859262449 100644 --- a/apps/kimi-code/scripts/native/assets.mjs +++ b/apps/kimi-code/scripts/native/assets.mjs @@ -17,9 +17,7 @@ export const NATIVE_TARGETS = Object.freeze( SUPPORTED_TARGETS.map((t) => { const deps = resolveTargetDeps(t); const clipboardTarget = deps.find((d) => d.id === 'clipboard-target')?.resolvedName; - const koffiNativeFile = deps.find((d) => d.id === 'koffi')?.nativeFileRelatives?.[0]; - const koffiTriplet = koffiNativeFile?.match(/koffi\/([^/]+)\/koffi\.node$/)?.[1] ?? null; - return [t, { clipboardPackage: clipboardTarget, koffiTriplet }]; + return [t, { clipboardPackage: clipboardTarget }]; }), ), ); @@ -161,16 +159,19 @@ async function collectPackageFiles({ packageName, packageRoot, includeNativeFiles, + includeEntryJs = true, nativeFileRelatives = [], }) { const packageJsonPath = join(packageRoot, 'package.json'); const packageJson = await readJson(packageJsonPath); const selected = new Set([packageJsonPath]); - const entry = resolvePackageEntry(packageRoot, packageJson); - if (entry !== null) { - selected.add(entry); - await addRuntimeDependencyFiles(packageRoot, entry, selected); + if (includeEntryJs) { + const entry = resolvePackageEntry(packageRoot, packageJson); + if (entry !== null) { + selected.add(entry); + await addRuntimeDependencyFiles(packageRoot, entry, selected); + } } for (const nativeFileRelative of nativeFileRelatives) { @@ -250,6 +251,7 @@ export async function collectNativeAssets({ appRoot, target }) { packageName: dep.resolvedName, packageRoot, includeNativeFiles: dep.collect === 'native-files', + includeEntryJs: dep.collect !== 'native-file-only', nativeFileRelatives: dep.nativeFileRelatives, }); const result = await packageManifestEntries({ diff --git a/apps/kimi-code/scripts/native/check-bundle.mjs b/apps/kimi-code/scripts/native/check-bundle.mjs index 1521d6716..39fd70b45 100644 --- a/apps/kimi-code/scripts/native/check-bundle.mjs +++ b/apps/kimi-code/scripts/native/check-bundle.mjs @@ -23,7 +23,7 @@ const optionalRuntimeRequires = new Set([ 'utf-8-validate', ]); const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']); -const handledNativeRuntimeRequires = new Set(['koffi']); +const handledNativeRuntimeRequires = new Set(); function isAllowedSpecifier(specifier) { if (builtins.has(specifier) || specifier.startsWith('node:')) return true; diff --git a/apps/kimi-code/scripts/native/native-deps.mjs b/apps/kimi-code/scripts/native/native-deps.mjs index f195a3cb1..8e26d9229 100644 --- a/apps/kimi-code/scripts/native/native-deps.mjs +++ b/apps/kimi-code/scripts/native/native-deps.mjs @@ -27,13 +27,16 @@ const clipboardSubpackageByTarget = Object.freeze({ 'win32-x64': '@mariozechner/clipboard-win32-x64-msvc', }); -const koffiTripletByTarget = Object.freeze({ - 'darwin-arm64': 'darwin_arm64', - 'darwin-x64': 'darwin_x64', - 'linux-arm64': 'linux_arm64', - 'linux-x64': 'linux_x64', - 'win32-arm64': 'win32_arm64', - 'win32-x64': 'win32_x64', +// pi-tui ships platform-specific native helpers (no Linux build): +// - darwin: Shift-modifier detection for Terminal.app Shift+Enter +// - win32: enable ENABLE_VIRTUAL_TERMINAL_INPUT so Shift+Tab is distinguishable +const piTuiNativeFileByTarget = Object.freeze({ + 'darwin-arm64': ['native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node'], + 'darwin-x64': ['native/darwin/prebuilds/darwin-x64/darwin-modifiers.node'], + 'linux-arm64': [], + 'linux-x64': [], + 'win32-arm64': ['native/win32/prebuilds/win32-arm64/win32-console-mode.node'], + 'win32-x64': ['native/win32/prebuilds/win32-x64/win32-console-mode.node'], }); export function isSupportedTarget(target) { @@ -45,13 +48,15 @@ export function isSupportedTarget(target) { * @property {string} id — stable internal id used for parent refs * @property {(target: string) => string} name * — npm package name (may depend on target) - * @property {'js-only'|'native-files'|'js-and-native-file'|'virtual'} collect + * @property {'js-only'|'native-files'|'js-and-native-file'|'native-file-only'|'virtual'} collect * @property {string|null} parent * — id of another registered dep this nests under (for pnpm), * or null for top-level (resolvable from app root) * @property {(target: string) => string[]} [nativeFileRelatives] * — explicit list of .node files relative to package root - * (used by 'js-and-native-file'; native-files mode auto-scans *.node) + * (used by 'js-and-native-file' and 'native-file-only'; + * native-files mode auto-scans *.node). 'native-file-only' collects + * package.json + these .node files but skips the package entry JS. */ /** @type {readonly NativeDepDescriptor[]} */ @@ -70,18 +75,14 @@ export const nativeDeps = Object.freeze([ }, { id: 'pi-tui', - name: () => '@earendil-works/pi-tui', - // pi-tui is bundled into main.cjs at build time — we don't collect it as - // a native dep, only register it so koffi can declare it as parent. - collect: 'virtual', + name: () => '@moonshot-ai/pi-tui', + // pi-tui's JS is bundled into main.cjs, so only the platform-specific + // native helper (.node under native/) ships alongside the binary — its + // dist/ JS is intentionally NOT collected (it stays in the bundle). This + // keeps the SEA native-asset payload small. Linux has no native helper. + collect: 'native-file-only', parent: null, - }, - { - id: 'koffi', - name: () => 'koffi', - collect: 'js-and-native-file', - parent: 'pi-tui', - nativeFileRelatives: (target) => [`build/koffi/${koffiTripletByTarget[target]}/koffi.node`], + nativeFileRelatives: (target) => piTuiNativeFileByTarget[target] ?? [], }, ]); diff --git a/apps/kimi-code/src/migration/migration-screen.ts b/apps/kimi-code/src/migration/migration-screen.ts index b1ebfecfd..d4c4fee7e 100644 --- a/apps/kimi-code/src/migration/migration-screen.ts +++ b/apps/kimi-code/src/migration/migration-screen.ts @@ -11,7 +11,7 @@ * This file implements the ask, progress, and result phases. `beginMigration` * drives the real runMigration flow (injectable for tests). */ -import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@earendil-works/pi-tui'; +import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import type { ColorPalette } from '#/tui/theme/colors'; diff --git a/apps/kimi-code/src/native/module-hook.ts b/apps/kimi-code/src/native/module-hook.ts index bbef5d4cb..bc8a1a67b 100644 --- a/apps/kimi-code/src/native/module-hook.ts +++ b/apps/kimi-code/src/native/module-hook.ts @@ -1,6 +1,8 @@ +import { existsSync } from 'node:fs'; import { createRequire } from 'node:module'; +import { join } from 'node:path'; -import { loadNativePackage } from './native-require'; +import { getNativePackageRoot } from './native-assets'; type ModuleLoad = (request: string, parent: unknown, isMain: boolean) => unknown; @@ -10,7 +12,16 @@ interface ModuleWithLoad { const nodeRequire = createRequire(import.meta.url); let installed = false; -let loadingNativePackage = false; + +// pi-tui loads its platform-specific native helpers via an absolute-path +// require() computed from import.meta.url / process.execPath +// (see pi-tui dist/terminal.js and dist/native-modifiers.js). In a SEA binary +// those .node files live in the native-asset cache, so redirect any absolute +// require of a pi-tui native helper to the cached copy. +// +// Path shape: native//prebuilds//.node — note the +// two path segments after "prebuilds", so ".+" (not "[^/]+") is required. +const PI_TUI_NATIVE_PATTERN = /native[\\/](?:win32|darwin)[\\/]prebuilds[\\/].+\.node$/; export function installNativeModuleHook(): void { if (installed) return; @@ -26,13 +37,18 @@ export function installNativeModuleHook(): void { parent: unknown, isMain: boolean, ): unknown { - if (request === 'koffi' && !loadingNativePackage) { - loadingNativePackage = true; - try { - const pkg = loadNativePackage('koffi'); - if (pkg !== null) return pkg; - } finally { - loadingNativePackage = false; + if ( + typeof request === 'string' && + PI_TUI_NATIVE_PATTERN.test(request) && + !existsSync(request) + ) { + const pkgRoot = getNativePackageRoot('@moonshot-ai/pi-tui'); + if (pkgRoot !== null) { + const match = request.match(PI_TUI_NATIVE_PATTERN); + if (match !== null) { + const redirected = join(pkgRoot, match[0]); + return originalLoad.call(this, redirected, parent, isMain); + } } } return originalLoad.call(this, request, parent, isMain); diff --git a/apps/kimi-code/src/native/smoke.ts b/apps/kimi-code/src/native/smoke.ts index 56d39253f..c77f1419d 100644 --- a/apps/kimi-code/src/native/smoke.ts +++ b/apps/kimi-code/src/native/smoke.ts @@ -1,6 +1,38 @@ +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + import { getEmbeddedNativeAssetManifest, getNativePackageRoot } from './native-assets'; -const smokePackages = ['@mariozechner/clipboard', 'koffi']; +const smokePackages = ['@mariozechner/clipboard', '@moonshot-ai/pi-tui']; + +// Verify pi-tui's native helper can actually be loaded through the module hook. +// pi-tui computes native helper paths from process.execPath and require()s them; +// those paths do not exist next to the SEA binary, so this only succeeds when +// installNativeModuleHook() redirects the require into the native-asset cache. +function smokePiTuiNativeLoad(): void { + const platform = process.platform; + const arch = process.arch; + let rel: string | undefined; + if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) { + rel = join('native', 'darwin', 'prebuilds', `darwin-${arch}`, 'darwin-modifiers.node'); + } else if (platform === 'win32' && (arch === 'x64' || arch === 'arm64')) { + rel = join('native', 'win32', 'prebuilds', `win32-${arch}`, 'win32-console-mode.node'); + } + if (rel === undefined) return; // Linux: no native helper, nothing to load. + + const req = createRequire(import.meta.url); + const bogusPath = join(dirname(process.execPath), rel); + const helper = req(bogusPath) as { + isModifierPressed?: unknown; + enableVirtualTerminalInput?: unknown; + }; + const ok = + typeof helper.isModifierPressed === 'function' || + typeof helper.enableVirtualTerminalInput === 'function'; + if (!ok) { + throw new Error(`pi-tui native helper loaded but exports are unexpected: ${rel}`); + } +} export function runNativeAssetSmokeIfRequested(): boolean { if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false; @@ -16,6 +48,7 @@ export function runNativeAssetSmokeIfRequested(): boolean { throw new Error(`Native package is not available: ${packageName}`); } } + smokePiTuiNativeLoad(); process.stdout.write(`Native asset smoke passed: ${manifest.target}\n`); process.exit(0); } catch (error) { diff --git a/apps/kimi-code/src/tui/commands/complete-args.ts b/apps/kimi-code/src/tui/commands/complete-args.ts index 75f76271d..e377c9bb0 100644 --- a/apps/kimi-code/src/tui/commands/complete-args.ts +++ b/apps/kimi-code/src/tui/commands/complete-args.ts @@ -1,4 +1,4 @@ -import type { AutocompleteItem } from '@earendil-works/pi-tui'; +import type { AutocompleteItem } from '@moonshot-ai/pi-tui'; /** * A completable token (subcommand or flag) for a slash command's argument diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 7d56fdb95..7508bc06a 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -1,4 +1,4 @@ -import type { Component, Focusable } from '@earendil-works/pi-tui'; +import type { Component, Focusable } from '@moonshot-ai/pi-tui'; import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 107335617..7654442b5 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -2,7 +2,7 @@ import { readdirSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { basename, dirname, join, relative, resolve } from 'pathe'; -import type { AutocompleteItem } from '@earendil-works/pi-tui'; +import type { AutocompleteItem } from '@moonshot-ai/pi-tui'; import { completeLeadingArg, type ArgCompletionSpec } from './complete-args'; import type { KimiSlashCommand, SlashCommandAvailability } from './types'; diff --git a/apps/kimi-code/src/tui/commands/types.ts b/apps/kimi-code/src/tui/commands/types.ts index 6ee0a172f..1ec3c6835 100644 --- a/apps/kimi-code/src/tui/commands/types.ts +++ b/apps/kimi-code/src/tui/commands/types.ts @@ -1,4 +1,4 @@ -import type { AutocompleteItem, SlashCommand } from '@earendil-works/pi-tui'; +import type { AutocompleteItem, SlashCommand } from '@moonshot-ai/pi-tui'; import type { FlagId } from '@moonshot-ai/kimi-code-sdk'; export type SlashCommandAvailability = 'always' | 'idle-only'; diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index 01a858f07..bd427277d 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import type { ContextMessage } from '@moonshot-ai/kimi-code-sdk'; import { isKimiError } from '@moonshot-ai/kimi-code-sdk'; diff --git a/apps/kimi-code/src/tui/components/chrome/banner.ts b/apps/kimi-code/src/tui/components/chrome/banner.ts index 265f0b02e..58b6faa58 100644 --- a/apps/kimi-code/src/tui/components/chrome/banner.ts +++ b/apps/kimi-code/src/tui/components/chrome/banner.ts @@ -1,5 +1,5 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { visibleWidth, wrapTextWithAnsi } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { BannerState } from '#/tui/types'; diff --git a/apps/kimi-code/src/tui/components/chrome/device-code-box.ts b/apps/kimi-code/src/tui/components/chrome/device-code-box.ts index 9cc7104b1..26ec211a0 100644 --- a/apps/kimi-code/src/tui/components/chrome/device-code-box.ts +++ b/apps/kimi-code/src/tui/components/chrome/device-code-box.ts @@ -6,8 +6,8 @@ * active palette so theme switches take effect on the next render. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index d20595466..3f3fc9b5a 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -6,8 +6,8 @@ * Line 2: context: XX.X% (tokens/max) */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk'; diff --git a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts index be90abf00..72b1e94b1 100644 --- a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts +++ b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts @@ -9,8 +9,8 @@ * the edge and adding them would just churn the diff renderer. */ -import { Container } from '@earendil-works/pi-tui'; -import type { Component } from '@earendil-works/pi-tui'; +import { Container } from '@moonshot-ai/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; diff --git a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts index 93ab6e7e7..ba19b0c2a 100644 --- a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts +++ b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts @@ -1,5 +1,5 @@ -import { Text, visibleWidth } from '@earendil-works/pi-tui'; -import type { TUI } from '@earendil-works/pi-tui'; +import { Text, visibleWidth } from '@moonshot-ai/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { BRAILLE_SPINNER_FRAMES, diff --git a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts index 733d5b8f8..b101b6d6c 100644 --- a/apps/kimi-code/src/tui/components/chrome/todo-panel.ts +++ b/apps/kimi-code/src/tui/components/chrome/todo-panel.ts @@ -9,8 +9,8 @@ * is issued. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/chrome/welcome.ts b/apps/kimi-code/src/tui/components/chrome/welcome.ts index ff3497450..10cdecbdb 100644 --- a/apps/kimi-code/src/tui/components/chrome/welcome.ts +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -3,8 +3,8 @@ * Renders a round-bordered box with the logo, session, model, and version. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk'; diff --git a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts index f837dc046..d95d9ff93 100644 --- a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts @@ -6,7 +6,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts index 670e612cf..e18f5709b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts @@ -14,7 +14,7 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts index 15974959f..b5be012e7 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts @@ -24,7 +24,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLines } from '#/tui/components/media/diff-preview'; diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts index 23e92c8f8..b6b74fe5b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -15,7 +15,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme, type ColorToken } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; diff --git a/apps/kimi-code/src/tui/components/dialogs/compaction.ts b/apps/kimi-code/src/tui/components/dialogs/compaction.ts index 90a924757..a20b947a0 100644 --- a/apps/kimi-code/src/tui/components/dialogs/compaction.ts +++ b/apps/kimi-code/src/tui/components/dialogs/compaction.ts @@ -13,8 +13,8 @@ * reads the same "work in progress" signal across the UI. */ -import { Container, Text, Spacer } from '@earendil-works/pi-tui'; -import type { TUI } from '@earendil-works/pi-tui'; +import { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts index 8a3150bf4..aa4aa910b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts +++ b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts @@ -17,7 +17,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts index 498c4191e..2678899ad 100644 --- a/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/effort-selector.ts @@ -4,7 +4,7 @@ import { matchesKey, truncateToWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { ThinkingEffort } from '@moonshot-ai/kimi-code-sdk'; diff --git a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts index 44042f057..1e466f873 100644 --- a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts @@ -5,7 +5,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { ExperimentalFeatureState } from '@moonshot-ai/kimi-code-sdk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts index 38ac3bd9e..c1f108dd7 100644 --- a/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/feedback-input-dialog.ts @@ -19,7 +19,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; export type FeedbackInputDialogResult = diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts index c35d55399..b5c2e7ac1 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts @@ -6,7 +6,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts index 1f931eb5a..e1ecfa7e8 100644 --- a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts @@ -15,7 +15,7 @@ import { decodeKittyPrintable, type Focusable, truncateToWidth, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; export interface KeyboardShortcut { diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 32fe08b88..82906d1d5 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -6,7 +6,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index fcc09941d..c5769def6 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -6,7 +6,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; import chalk from 'chalk'; diff --git a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts index 8805c24a9..7ae0da03d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts +++ b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts @@ -42,7 +42,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts index ba14033f7..6764b88d8 100644 --- a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts @@ -15,7 +15,7 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts index bb5ec512f..c8bd9017b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -9,7 +9,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { formatSessionLabel } from '#/migration/index'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts index 38c1da2bb..341ced723 100644 --- a/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/start-permission-prompt.ts @@ -5,7 +5,7 @@ import { visibleWidth, type Component, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts index 3680eecbf..9a986b096 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -20,7 +20,7 @@ import { matchesKey, truncateToWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import { renderTabStrip } from '#/tui/utils/tab-strip'; diff --git a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts index fdcd81aac..c0f647f67 100644 --- a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts +++ b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts @@ -17,7 +17,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts index 0a47a2f83..7902e81e1 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -21,7 +21,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; import { SELECT_POINTER } from '@/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts b/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts index 77d82ccdd..37320f92b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts @@ -5,7 +5,7 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { SELECT_POINTER } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 05bd81b15..ee005950c 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -11,7 +11,7 @@ import { visibleWidth, type SelectItem, type TUI, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index cb9029bb7..ff489a09f 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -8,7 +8,7 @@ import { type AutocompleteProvider, type AutocompleteSuggestions, type SlashCommand, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '=']); const MAX_FALLBACK_SCAN = 2000; diff --git a/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts b/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts index d9d16936d..b6969c630 100644 --- a/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts +++ b/apps/kimi-code/src/tui/components/editor/wrapping-select-list.ts @@ -6,7 +6,7 @@ import { type SelectItem, type SelectListLayoutOptions, type SelectListTheme, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; // Mirror pi-tui's private select-list layout constants // (dist/components/select-list.js); keep in sync when bumping pi-tui. diff --git a/apps/kimi-code/src/tui/components/media/image-thumbnail.ts b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts index cc8ef4d56..04e80f534 100644 --- a/apps/kimi-code/src/tui/components/media/image-thumbnail.ts +++ b/apps/kimi-code/src/tui/components/media/image-thumbnail.ts @@ -12,7 +12,7 @@ * the viewport; pi-tui handles proportional scaling internally. */ -import { Container, Image, Text, type ImageTheme, getCapabilities } from '@earendil-works/pi-tui'; +import { Container, Image, Text, type ImageTheme, getCapabilities } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index 71c53332e..6fd1624d5 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -15,8 +15,8 @@ * - Ungrouping is not implemented. Once formed, a group stays grouped. */ -import type { TUI } from '@earendil-works/pi-tui'; -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts index 173be9951..75c7266a2 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -1,4 +1,4 @@ -import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; +import { truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts index 721711801..c1b39537d 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -5,7 +5,7 @@ * to align after the bullet. */ -import { Container, Markdown, truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; +import { Container, Markdown, truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts index 3d968bad8..9c1a3d815 100644 --- a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts +++ b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts @@ -1,4 +1,4 @@ -import { Text, truncateToWidth, type Component } from '@earendil-works/pi-tui'; +import { Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/messages/cron-message.ts b/apps/kimi-code/src/tui/components/messages/cron-message.ts index 5ca2acf02..8cb81f5d3 100644 --- a/apps/kimi-code/src/tui/components/messages/cron-message.ts +++ b/apps/kimi-code/src/tui/components/messages/cron-message.ts @@ -1,5 +1,5 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Spacer, Text, visibleWidth } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/goal-markers.ts b/apps/kimi-code/src/tui/components/messages/goal-markers.ts index 0cff92d50..f4482941f 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-markers.ts @@ -7,7 +7,7 @@ * the richer completion card (the `/goal` box), not this marker. */ -import { truncateToWidth, type Component } from '@earendil-works/pi-tui'; +import { truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import type { GoalChange } from '@moonshot-ai/kimi-code-sdk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/messages/goal-panel.ts b/apps/kimi-code/src/tui/components/messages/goal-panel.ts index be9df81af..d01f580fa 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-panel.ts @@ -19,7 +19,7 @@ import { visibleWidth, wrapTextWithAnsi, type Component, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { GoalSnapshot, GoalStatus } from '@moonshot-ai/kimi-code-sdk'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; diff --git a/apps/kimi-code/src/tui/components/messages/plan-box.ts b/apps/kimi-code/src/tui/components/messages/plan-box.ts index d6b2c6207..d1eeec03c 100644 --- a/apps/kimi-code/src/tui/components/messages/plan-box.ts +++ b/apps/kimi-code/src/tui/components/messages/plan-box.ts @@ -7,7 +7,7 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { Markdown, truncateToWidth, visibleWidth, type Component, type MarkdownTheme } from '@earendil-works/pi-tui'; +import { Markdown, truncateToWidth, visibleWidth, type Component, type MarkdownTheme } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; diff --git a/apps/kimi-code/src/tui/components/messages/plugin-command.ts b/apps/kimi-code/src/tui/components/messages/plugin-command.ts index 230cf3e83..afbc2b444 100644 --- a/apps/kimi-code/src/tui/components/messages/plugin-command.ts +++ b/apps/kimi-code/src/tui/components/messages/plugin-command.ts @@ -11,7 +11,7 @@ * context; the TUI only consumes the `plugin_command.activated` event. */ -import { Container, Text, Spacer } from '@earendil-works/pi-tui'; +import { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/read-group.ts b/apps/kimi-code/src/tui/components/messages/read-group.ts index 3910be1ab..141562e4c 100644 --- a/apps/kimi-code/src/tui/components/messages/read-group.ts +++ b/apps/kimi-code/src/tui/components/messages/read-group.ts @@ -20,8 +20,8 @@ * src/missing.ts · failed */ -import type { TUI } from '@earendil-works/pi-tui'; -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/shell-execution.ts b/apps/kimi-code/src/tui/components/messages/shell-execution.ts index 1a28c7c64..5e10b84de 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-execution.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-execution.ts @@ -1,5 +1,5 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { Container, Text } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Container, Text } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; diff --git a/apps/kimi-code/src/tui/components/messages/shell-run.ts b/apps/kimi-code/src/tui/components/messages/shell-run.ts index 3c5fc20da..ca99f2e76 100644 --- a/apps/kimi-code/src/tui/components/messages/shell-run.ts +++ b/apps/kimi-code/src/tui/components/messages/shell-run.ts @@ -1,4 +1,4 @@ -import { Container, Text } from '@earendil-works/pi-tui'; +import { Container, Text } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/skill-activation.ts b/apps/kimi-code/src/tui/components/messages/skill-activation.ts index 907e91e9d..f977d21d9 100644 --- a/apps/kimi-code/src/tui/components/messages/skill-activation.ts +++ b/apps/kimi-code/src/tui/components/messages/skill-activation.ts @@ -12,7 +12,7 @@ * metadata. */ -import { Container, Text, Spacer } from '@earendil-works/pi-tui'; +import { Container, Text, Spacer } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { SkillActivationTrigger } from '#/tui/types'; diff --git a/apps/kimi-code/src/tui/components/messages/status-message.ts b/apps/kimi-code/src/tui/components/messages/status-message.ts index c23c356ce..f88c1861b 100644 --- a/apps/kimi-code/src/tui/components/messages/status-message.ts +++ b/apps/kimi-code/src/tui/components/messages/status-message.ts @@ -1,4 +1,4 @@ -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; +import { Container, Spacer, Text } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { ColorToken } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/step-summary.ts b/apps/kimi-code/src/tui/components/messages/step-summary.ts index 5a6885ac4..325ae6eb2 100644 --- a/apps/kimi-code/src/tui/components/messages/step-summary.ts +++ b/apps/kimi-code/src/tui/components/messages/step-summary.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/swarm-markers.ts b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts index 0308fada7..4fee9629f 100644 --- a/apps/kimi-code/src/tui/components/messages/swarm-markers.ts +++ b/apps/kimi-code/src/tui/components/messages/swarm-markers.ts @@ -1,4 +1,4 @@ -import { truncateToWidth, type Component } from '@earendil-works/pi-tui'; +import { truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/thinking.ts b/apps/kimi-code/src/tui/components/messages/thinking.ts index ca0847581..23a038c70 100644 --- a/apps/kimi-code/src/tui/components/messages/thinking.ts +++ b/apps/kimi-code/src/tui/components/messages/thinking.ts @@ -5,7 +5,7 @@ * Supports expand/collapse via Ctrl+O (shared with tool output). */ -import { Text, truncateToWidth, type Component, type TUI } from '@earendil-works/pi-tui'; +import { Text, truncateToWidth, type Component, type TUI } from '@moonshot-ai/pi-tui'; import { BRAILLE_SPINNER_FRAMES, diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index e1913213d..dd8d57189 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -5,8 +5,8 @@ import { isAbsolute, relative, sep } from 'node:path'; -import { Container, Spacer, Text, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; -import type { Component, TUI } from '@earendil-works/pi-tui'; +import { Container, Spacer, Text, truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; +import type { Component, TUI } from '@moonshot-ai/pi-tui'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; import { diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts index 04f20dc35..1b38fd278 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/goal.ts @@ -1,4 +1,4 @@ -import { Text } from '@earendil-works/pi-tui'; +import { Text } from '@moonshot-ai/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts index fd753cd27..100968daf 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/media.ts @@ -13,8 +13,8 @@ * message. */ -import type { Component } from '@earendil-works/pi-tui'; -import { Text } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Text } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import type { ChipProvider } from './chip'; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts index a3f929dcc..ac31cec8e 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/summary.ts @@ -10,8 +10,8 @@ * sees the actual error message, not a synthetic summary. */ -import type { Component } from '@earendil-works/pi-tui'; -import { Text } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { Text } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { renderTruncated } from './truncated'; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts index e619ff19d..036ae0a20 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -1,4 +1,4 @@ -import { Text, truncateToWidth, type Component } from '@earendil-works/pi-tui'; +import { Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts index 94161d1a8..da3dc3a5a 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-renderers/types.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { RESULT_PREVIEW_LINES } from '#/tui/constant/rendering'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 1eeba55e2..23cef0b27 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -4,8 +4,8 @@ * the pattern stays consistent across command-triggered panels. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import type { SessionUsage, TokenUsage } from '@moonshot-ai/kimi-code-sdk'; import { diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts index 0e9b2c32c..cec7fbd13 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -2,7 +2,7 @@ * Renders a user message in the transcript. */ -import { Spacer, Text, truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; +import { Spacer, Text, truncateToWidth, visibleWidth, type Component } from '@moonshot-ai/pi-tui'; import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols'; diff --git a/apps/kimi-code/src/tui/components/panes/activity-pane.ts b/apps/kimi-code/src/tui/components/panes/activity-pane.ts index 443f3aa74..22e6f3bc5 100644 --- a/apps/kimi-code/src/tui/components/panes/activity-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/activity-pane.ts @@ -1,4 +1,4 @@ -import { Container, Spacer } from '@earendil-works/pi-tui'; +import { Container, Spacer } from '@moonshot-ai/pi-tui'; import type { MoonLoader } from '#/tui/components/chrome/moon-loader'; diff --git a/apps/kimi-code/src/tui/components/panes/btw-panel.ts b/apps/kimi-code/src/tui/components/panes/btw-panel.ts index 9c4936fa2..f32aa9321 100644 --- a/apps/kimi-code/src/tui/components/panes/btw-panel.ts +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -1,10 +1,10 @@ -import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; +import type { Component, MarkdownTheme } from '@moonshot-ai/pi-tui'; import { Markdown, Text, truncateToWidth, visibleWidth, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { THINKING_PREVIEW_LINES } from '../../constant/rendering'; diff --git a/apps/kimi-code/src/tui/components/panes/queue-pane.ts b/apps/kimi-code/src/tui/components/panes/queue-pane.ts index a3c959a6a..1a2b26d07 100644 --- a/apps/kimi-code/src/tui/components/panes/queue-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/queue-pane.ts @@ -1,4 +1,4 @@ -import { Container, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import { Container, truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import { SELECT_POINTER } from '../../constant/symbols'; import type { QueuedMessage } from '../../types'; diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts index d984f7082..c29c76460 100644 --- a/apps/kimi-code/src/tui/controllers/btw-panel.ts +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -1,4 +1,4 @@ -import { Spacer } from '@earendil-works/pi-tui'; +import { Spacer } from '@moonshot-ai/pi-tui'; import type { Event, KimiHarness, diff --git a/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts index ac1fd8c11..48dd1f8b8 100644 --- a/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts +++ b/apps/kimi-code/src/tui/controllers/clipboard-image-hint.ts @@ -1,4 +1,4 @@ -import type { TUI } from '@earendil-works/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image'; diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 6abcba76a..7b73e7077 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -1,4 +1,4 @@ -import type { Component, Focusable } from '@earendil-works/pi-tui'; +import type { Component, Focusable } from '@moonshot-ai/pi-tui'; import type { AgentStatusUpdatedEvent, AssistantDeltaEvent, diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index 6d08330c5..deb407bfc 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -2,7 +2,7 @@ import type { BackgroundTaskInfo, Event, } from '@moonshot-ai/kimi-code-sdk'; -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { AgentSwarmProgressComponent, diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index 90ed3c99e..6994b13b8 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -1,5 +1,5 @@ import type { BackgroundTaskInfo, Session } from '@moonshot-ai/kimi-code-sdk'; -import type { Component, ProcessTerminal, TUI } from '@earendil-works/pi-tui'; +import type { Component, ProcessTerminal, TUI } from '@moonshot-ai/pi-tui'; import { TaskOutputViewer } from '../components/dialogs/task-output-viewer'; import { TasksBrowserApp, type TasksFilter } from '../components/dialogs/tasks-browser'; diff --git a/apps/kimi-code/src/tui/easter-eggs/dance.ts b/apps/kimi-code/src/tui/easter-eggs/dance.ts index 6f638aba0..15e3608f8 100644 --- a/apps/kimi-code/src/tui/easter-eggs/dance.ts +++ b/apps/kimi-code/src/tui/easter-eggs/dance.ts @@ -10,7 +10,7 @@ */ import chalk from 'chalk'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import { truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import type { SlashCommandHost } from '../commands/dispatch'; import type { ParsedSlashInput } from '../commands/types'; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 45493c1b4..9f9d0d27f 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -7,7 +7,7 @@ import { type Focusable, getCapabilities, Spacer, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; import type { ApprovalRequest, @@ -2233,6 +2233,10 @@ export class KimiTUI { case 'session': { this.stopActivitySpinner(); this.syncAgentSwarmActivitySpinner(undefined); + // Keep a placeholder row so the activity area does not fully shrink + // when the spinner is removed at the end of streaming; combined with + // pi-tui's clamp, this avoids a destructive full redraw (viewport jump). + this.state.activityContainer.addChild(new Spacer(1)); break; } } diff --git a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts index d03f309fa..1b53bce0c 100644 --- a/apps/kimi-code/src/tui/theme/pi-tui-theme.ts +++ b/apps/kimi-code/src/tui/theme/pi-tui-theme.ts @@ -8,7 +8,7 @@ * instances reads the *current* palette via the singleton. */ -import type { MarkdownTheme, EditorTheme } from '@earendil-works/pi-tui'; +import type { MarkdownTheme, EditorTheme } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { highlight, supportsLanguage } from 'cli-highlight'; diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 6a9594f01..5a554f2f5 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -2,7 +2,7 @@ import { Container, ProcessTerminal, TUI, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { FooterComponent } from './components/chrome/footer'; import { GutterContainer } from './components/chrome/gutter-container'; diff --git a/apps/kimi-code/src/tui/utils/printable-key.ts b/apps/kimi-code/src/tui/utils/printable-key.ts index 7daa36ab6..d165d52a4 100644 --- a/apps/kimi-code/src/tui/utils/printable-key.ts +++ b/apps/kimi-code/src/tui/utils/printable-key.ts @@ -20,7 +20,7 @@ * `tui/components/**` and rejects bare-literal comparisons. */ -import { decodeKittyPrintable } from '@earendil-works/pi-tui'; +import { decodeKittyPrintable } from '@moonshot-ai/pi-tui'; export function printableChar(data: string): string { return decodeKittyPrintable(data) ?? data; diff --git a/apps/kimi-code/src/tui/utils/searchable-list.ts b/apps/kimi-code/src/tui/utils/searchable-list.ts index b5b7343a7..00a920e1f 100644 --- a/apps/kimi-code/src/tui/utils/searchable-list.ts +++ b/apps/kimi-code/src/tui/utils/searchable-list.ts @@ -8,7 +8,7 @@ * everywhere: ↑/↓, PgUp/PgDn, and search editing. */ -import { fuzzyFilter, Key, matchesKey } from '@earendil-works/pi-tui'; +import { fuzzyFilter, Key, matchesKey } from '@moonshot-ai/pi-tui'; import { pageView, type PageView } from './paging'; import { isPrintableChar, printableChar } from './printable-key'; diff --git a/apps/kimi-code/src/tui/utils/tab-strip.ts b/apps/kimi-code/src/tui/utils/tab-strip.ts index 3cac6826b..a4b5099a7 100644 --- a/apps/kimi-code/src/tui/utils/tab-strip.ts +++ b/apps/kimi-code/src/tui/utils/tab-strip.ts @@ -8,7 +8,7 @@ * visible, framed by `<`/`>` markers. */ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import type { ColorPalette } from '#/tui/theme/colors'; diff --git a/apps/kimi-code/src/tui/utils/terminal-notification.ts b/apps/kimi-code/src/tui/utils/terminal-notification.ts index ab6f1bff7..c71f41df9 100644 --- a/apps/kimi-code/src/tui/utils/terminal-notification.ts +++ b/apps/kimi-code/src/tui/utils/terminal-notification.ts @@ -1,4 +1,4 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@moonshot-ai/pi-tui'; import { BEL, ESC, MAX_TERMINAL_NOTIFICATION_MESSAGE_LENGTH, ST } from '#/tui/constant/terminal'; import type { TUIState } from '#/tui/tui-state'; diff --git a/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts b/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts index 12151958a..94cb45693 100644 --- a/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts +++ b/apps/kimi-code/src/tui/utils/transcript-component-metadata.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import type { TranscriptEntry } from '../types'; diff --git a/apps/kimi-code/test/scripts/native/native-deps.test.ts b/apps/kimi-code/test/scripts/native/native-deps.test.ts index c96b642e6..980f1c771 100644 --- a/apps/kimi-code/test/scripts/native/native-deps.test.ts +++ b/apps/kimi-code/test/scripts/native/native-deps.test.ts @@ -41,7 +41,7 @@ describe('resolveTargetDeps', () => { const names = deps.map((d) => d.resolvedName); expect(names).toContain('@mariozechner/clipboard'); expect(names).toContain('@mariozechner/clipboard-darwin-arm64'); - expect(names).toContain('koffi'); + expect(names).toContain('@moonshot-ai/pi-tui'); }); it('picks the right clipboard subpackage per target', () => { @@ -56,13 +56,23 @@ describe('resolveTargetDeps', () => { ).toContain('@mariozechner/clipboard-win32-arm64-msvc'); }); - it('encodes koffi native file path with target triplet', () => { - const linuxKoffi = resolveTargetDeps('linux-arm64').find((d) => d.resolvedName === 'koffi'); - expect(linuxKoffi?.nativeFileRelatives).toEqual(['build/koffi/linux_arm64/koffi.node']); - const macKoffi = resolveTargetDeps('darwin-x64').find((d) => d.resolvedName === 'koffi'); - expect(macKoffi?.nativeFileRelatives).toEqual(['build/koffi/darwin_x64/koffi.node']); - const winArmKoffi = resolveTargetDeps('win32-arm64').find((d) => d.resolvedName === 'koffi'); - expect(winArmKoffi?.nativeFileRelatives).toEqual(['build/koffi/win32_arm64/koffi.node']); + it('encodes pi-tui native file path per target', () => { + const linuxPiTui = resolveTargetDeps('linux-arm64').find( + (d) => d.resolvedName === '@moonshot-ai/pi-tui', + ); + expect(linuxPiTui?.nativeFileRelatives).toEqual([]); + const macPiTui = resolveTargetDeps('darwin-x64').find( + (d) => d.resolvedName === '@moonshot-ai/pi-tui', + ); + expect(macPiTui?.nativeFileRelatives).toEqual([ + 'native/darwin/prebuilds/darwin-x64/darwin-modifiers.node', + ]); + const winArmPiTui = resolveTargetDeps('win32-arm64').find( + (d) => d.resolvedName === '@moonshot-ai/pi-tui', + ); + expect(winArmPiTui?.nativeFileRelatives).toEqual([ + 'native/win32/prebuilds/win32-arm64/win32-console-mode.node', + ]); }); it('throws on unsupported target', () => { @@ -82,9 +92,9 @@ describe('nativeDeps registry shape', () => { expect(target?.parent).toBe('clipboard-host'); }); - it('has koffi (collect=js-and-native-file, parent=pi-tui)', () => { - const koffi = nativeDeps.find((d) => d.id === 'koffi'); - expect(koffi?.collect).toBe('js-and-native-file'); - expect(koffi?.parent).toBe('pi-tui'); + it('has pi-tui (collect=native-file-only, no parent)', () => { + const piTui = nativeDeps.find((d) => d.id === 'pi-tui'); + expect(piTui?.collect).toBe('native-file-only'); + expect(piTui?.parent).toBe(null); }); }); diff --git a/apps/kimi-code/test/tui/components/chrome/banner.test.ts b/apps/kimi-code/test/tui/components/chrome/banner.test.ts index 8f5724e5f..aecf815d9 100644 --- a/apps/kimi-code/test/tui/components/chrome/banner.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/banner.test.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { BannerComponent } from '#/tui/components/chrome/banner'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts b/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts index 440a4ad06..c0d4e929f 100644 --- a/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/device-code-box.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { DeviceCodeBoxComponent } from '#/tui/components/chrome/device-code-box'; diff --git a/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts b/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts index c26e97a17..295363a74 100644 --- a/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { GutterContainer } from '#/tui/components/chrome/gutter-container'; diff --git a/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts b/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts index e8bea6504..ffdc5bcbe 100644 --- a/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts @@ -1,4 +1,4 @@ -import type { TUI } from '@earendil-works/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it } from 'vitest'; import { MoonLoader } from '#/tui/components/chrome/moon-loader'; diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index 20e4d112d..bc1b754fb 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; diff --git a/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts index 939242383..610ea23e2 100644 --- a/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { ApiKeyInputDialogComponent } from '#/tui/components/dialogs/api-key-input-dialog'; diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts index 612ec152f..d02df500e 100644 --- a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts @@ -1,4 +1,4 @@ -import { CURSOR_MARKER } from '@earendil-works/pi-tui'; +import { CURSOR_MARKER } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts index d4ba77fa9..a1b8b563e 100644 --- a/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts @@ -1,4 +1,4 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { diff --git a/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts b/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts index b46ba311c..68c79974d 100644 --- a/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/custom-registry-import.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { diff --git a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts index 068b7f00f..7ab1b3312 100644 --- a/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/feedback-input-dialog.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { beforeAll, describe, expect, it } from 'vitest'; diff --git a/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts index 547b30a66..ec0f9d03b 100644 --- a/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index b827b7dbe..ba1499e04 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -1,5 +1,5 @@ import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; diff --git a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts index 12ca62ed7..812ac0d94 100644 --- a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts @@ -1,4 +1,4 @@ -import { CURSOR_MARKER } from '@earendil-works/pi-tui'; +import { CURSOR_MARKER } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { beforeAll, describe, expect, it } from 'vitest'; diff --git a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts index 70f7dfe30..3c885488b 100644 --- a/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/session-picker.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { SessionPickerComponent } from '#/tui/components/dialogs/session-picker'; diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index a1f53b8d1..6ef871208 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -3,7 +3,7 @@ import type { AutocompleteProvider, AutocompleteSuggestions, TUI, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { CustomEditor } from '#/tui/components/editor/custom-editor'; diff --git a/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts b/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts index 8a8a7ba00..c4147ab40 100644 --- a/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts +++ b/apps/kimi-code/test/tui/components/editor/wrapping-select-list.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth, type SelectItem, type SelectListTheme } from '@earendil-works/pi-tui'; +import { visibleWidth, type SelectItem, type SelectListTheme } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { WrappingSelectList } from '#/tui/components/editor/wrapping-select-list'; diff --git a/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts b/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts index d2bac1561..b6378f389 100644 --- a/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts +++ b/apps/kimi-code/test/tui/components/media/image-thumbnail.test.ts @@ -1,4 +1,4 @@ -import { resetCapabilitiesCache, setCapabilities, visibleWidth } from '@earendil-works/pi-tui'; +import { resetCapabilitiesCache, setCapabilities, visibleWidth } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; diff --git a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts index fc0e0ba30..adf4d3b0b 100644 --- a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts +++ b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts @@ -1,4 +1,4 @@ -import type { TUI } from '@earendil-works/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; diff --git a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts index 485a171ba..ad6389418 100644 --- a/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts +++ b/apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { diff --git a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts index 8d42ca5e0..e078e6dd2 100644 --- a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts @@ -1,4 +1,4 @@ -import { Markdown, visibleWidth } from '@earendil-works/pi-tui'; +import { Markdown, visibleWidth } from '@moonshot-ai/pi-tui'; import * as cliHighlight from 'cli-highlight'; import { describe, expect, it, vi } from 'vitest'; diff --git a/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts index fbc2efbc5..e8f395ec8 100644 --- a/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts +++ b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { BackgroundAgentStatusComponent } from '#/tui/components/messages/background-agent-status'; diff --git a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts index 316c30af7..f098cc931 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-markers.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { SwarmModeMarkerComponent } from '#/tui/components/messages/swarm-markers'; diff --git a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts index b5b89ab0d..ed69d3a85 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; diff --git a/apps/kimi-code/test/tui/components/messages/notice.test.ts b/apps/kimi-code/test/tui/components/messages/notice.test.ts index 6e5e261d8..09f727556 100644 --- a/apps/kimi-code/test/tui/components/messages/notice.test.ts +++ b/apps/kimi-code/test/tui/components/messages/notice.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { CronMessageComponent } from '#/tui/components/messages/cron-message'; diff --git a/apps/kimi-code/test/tui/components/messages/thinking.test.ts b/apps/kimi-code/test/tui/components/messages/thinking.test.ts index 40f609be1..e615d7f5c 100644 --- a/apps/kimi-code/test/tui/components/messages/thinking.test.ts +++ b/apps/kimi-code/test/tui/components/messages/thinking.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth, type TUI } from '@earendil-works/pi-tui'; +import { visibleWidth, type TUI } from '@moonshot-ai/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { ThinkingComponent } from '#/tui/components/messages/thinking'; diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 3b7e82469..eabe7fbe4 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth, type TUI } from '@earendil-works/pi-tui'; +import { visibleWidth, type TUI } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; import { afterEach, describe, expect, it, vi } from 'vitest'; diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts index ab4a53fd6..e56cb8e0e 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/media.test.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts index 7dcd55bed..6570aac46 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { diff --git a/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts b/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts index bcab35a48..ecdf2a1f5 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-renderers/truncated.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { TruncatedOutputComponent } from '#/tui/components/messages/tool-renderers/truncated'; diff --git a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts index 5540b9b75..ff39cb7e6 100644 --- a/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/usage-panel.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it } from 'vitest'; import { buildUsageReportLines, UsagePanelComponent } from '#/tui/components/messages/usage-panel'; diff --git a/apps/kimi-code/test/tui/components/messages/user-message.test.ts b/apps/kimi-code/test/tui/components/messages/user-message.test.ts index 23ec702ae..e6a10a05c 100644 --- a/apps/kimi-code/test/tui/components/messages/user-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/user-message.test.ts @@ -1,4 +1,4 @@ -import { resetCapabilitiesCache, setCapabilities, visibleWidth } from '@earendil-works/pi-tui'; +import { resetCapabilitiesCache, setCapabilities, visibleWidth } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it } from 'vitest'; import { UserMessageComponent } from '#/tui/components/messages/user-message'; diff --git a/apps/kimi-code/test/tui/components/panels/plan-box.test.ts b/apps/kimi-code/test/tui/components/panels/plan-box.test.ts index 9f067b168..1d970bb80 100644 --- a/apps/kimi-code/test/tui/components/panels/plan-box.test.ts +++ b/apps/kimi-code/test/tui/components/panels/plan-box.test.ts @@ -1,6 +1,6 @@ import { pathToFileURL } from 'node:url'; -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { PlanBoxComponent } from '#/tui/components/messages/plan-box'; diff --git a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts index 56601592d..76acd438c 100644 --- a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts @@ -1,4 +1,4 @@ -import { Text, visibleWidth } from '@earendil-works/pi-tui'; +import { Text, visibleWidth } from '@moonshot-ai/pi-tui'; import { describe, expect, it } from 'vitest'; import { ActivityPaneComponent } from '#/tui/components/panes/activity-pane'; diff --git a/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts b/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts index 0c69ebac9..2bc4c5b4e 100644 --- a/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts +++ b/apps/kimi-code/test/tui/controllers/clipboard-image-hint.test.ts @@ -1,4 +1,4 @@ -import type { TUI } from '@earendil-works/pi-tui'; +import type { TUI } from '@moonshot-ai/pi-tui'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index bdc11ad85..b5243e894 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -7,7 +7,7 @@ import { deleteAllKittyImages, resetCapabilitiesCache, setCapabilities, -} from '@earendil-works/pi-tui'; +} from '@moonshot-ai/pi-tui'; import type { ApprovalRequest, ApprovalResponse, Event } from '@moonshot-ai/kimi-code-sdk'; import { afterEach, describe, expect, it, vi } from 'vitest'; diff --git a/apps/kimi-code/test/tui/render-memo.bench.ts b/apps/kimi-code/test/tui/render-memo.bench.ts index 2bfd246f0..63bf79df6 100644 --- a/apps/kimi-code/test/tui/render-memo.bench.ts +++ b/apps/kimi-code/test/tui/render-memo.bench.ts @@ -16,7 +16,7 @@ import { bench, describe } from 'vitest'; -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@moonshot-ai/pi-tui'; import { GutterContainer } from '#/tui/components/chrome/gutter-container'; import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; diff --git a/apps/kimi-code/test/tui/task-output-viewer.test.ts b/apps/kimi-code/test/tui/task-output-viewer.test.ts index 2a3880e3d..5948ec6c8 100644 --- a/apps/kimi-code/test/tui/task-output-viewer.test.ts +++ b/apps/kimi-code/test/tui/task-output-viewer.test.ts @@ -1,4 +1,4 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@moonshot-ai/pi-tui'; import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it, vi } from 'vitest'; diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts index 11bc06287..dacf75f5f 100644 --- a/apps/kimi-code/test/tui/tasks-browser.test.ts +++ b/apps/kimi-code/test/tui/tasks-browser.test.ts @@ -1,4 +1,4 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@moonshot-ai/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; import { describe, expect, it, vi } from 'vitest'; diff --git a/flake.nix b/flake.nix index 5a5e0469d..8af851005 100644 --- a/flake.nix +++ b/flake.nix @@ -72,6 +72,7 @@ ./packages/migration-legacy ./packages/node-sdk ./packages/oauth + ./packages/pi-tui ./packages/protocol ./packages/telemetry ./apps/kimi-code @@ -93,6 +94,7 @@ "@moonshot-ai/migration-legacy" "@moonshot-ai/kimi-code-sdk" "@moonshot-ai/kimi-code-oauth" + "@moonshot-ai/pi-tui" "@moonshot-ai/protocol" "@moonshot-ai/kimi-telemetry" "@moonshot-ai/kimi-code" @@ -152,7 +154,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-mqyi0VuPZwESZcdU5E8F3XUG99OH636knBfb8y6TQpw="; + hash = "sha256-o753txNKfBcn/fiJOWFU0lyrSqTUJ/GU1ed1DPVPZ2U="; }; nativeBuildInputs = [ diff --git a/packages/pi-tui/LICENSE b/packages/pi-tui/LICENSE new file mode 100644 index 000000000..b0a8e9b81 --- /dev/null +++ b/packages/pi-tui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Mario Zechner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/pi-tui/README.md b/packages/pi-tui/README.md new file mode 100644 index 000000000..01c388034 --- /dev/null +++ b/packages/pi-tui/README.md @@ -0,0 +1,791 @@ +# @moonshot-ai/pi-tui + +Minimal terminal UI framework with differential rendering and synchronized output for flicker-free interactive CLI applications. + +## Features + +- **Differential Rendering**: Three-strategy rendering system that only updates what changed +- **Synchronized Output**: Uses CSI 2026 for atomic screen updates (no flicker) +- **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes +- **Component-based**: Simple Component interface with render() method +- **Theme Support**: Components accept theme interfaces for customizable styling +- **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, Spacer, Image, Box, Container +- **Inline Images**: Renders images in terminals that support Kitty or iTerm2 graphics protocols +- **Autocomplete Support**: File paths and slash commands + +## Quick Start + +```typescript +import { TUI, Text, Editor, ProcessTerminal, matchesKey } from "@moonshot-ai/pi-tui"; + +// Create terminal +const terminal = new ProcessTerminal(); + +// Create TUI +const tui = new TUI(terminal); + +// Add components +tui.addChild(new Text("Welcome to my app!")); + +import { defaultEditorTheme as editorTheme } from './test/test-themes.ts'; +const editor = new Editor(tui, editorTheme); +editor.onSubmit = (text) => { + console.log("Submitted:", text); + tui.addChild(new Text(`You said: ${text}`)); +}; +tui.addChild(editor); + +// Focus the editor so it receives keyboard input +tui.setFocus(editor); + +// In raw mode Ctrl+C doesn't send SIGINT — intercept it here to allow exit +tui.addInputListener((data) => { + if (matchesKey(data, 'ctrl+c')) { + tui.stop(); + process.exit(0); + } +}); + +// Start +tui.start(); +``` + +## Core API + +### TUI + +Main container that manages components and rendering. + +```typescript +const tui = new TUI(terminal); +tui.addChild(component); +tui.removeChild(component); +tui.start(); +tui.stop(); +tui.requestRender(); // Request a re-render + +// Global debug key handler (Shift+Ctrl+D) +tui.onDebug = () => console.log("Debug triggered"); +``` + +### Overlays + +Overlays render components on top of existing content without replacing it. Useful for dialogs, menus, and modal UI. + +```typescript +// Show overlay with default options (centered, max 80 cols) +const handle = tui.showOverlay(component); + +// Show overlay with custom positioning and sizing +// Values can be numbers (absolute) or percentage strings (e.g., "50%") +const handle = tui.showOverlay(component, { + // Sizing + width: 60, // Fixed width in columns + width: "80%", // Width as percentage of terminal + minWidth: 40, // Minimum width floor + maxHeight: 20, // Maximum height in rows + maxHeight: "50%", // Maximum height as percentage of terminal + + // Anchor-based positioning (default: 'center') + anchor: 'bottom-right', // Position relative to anchor point + offsetX: 2, // Horizontal offset from anchor + offsetY: -1, // Vertical offset from anchor + + // Percentage-based positioning (alternative to anchor) + row: "25%", // Vertical position (0%=top, 100%=bottom) + col: "50%", // Horizontal position (0%=left, 100%=right) + + // Absolute positioning (overrides anchor/percent) + row: 5, // Exact row position + col: 10, // Exact column position + + // Margin from terminal edges + margin: 2, // All sides + margin: { top: 1, right: 2, bottom: 1, left: 2 }, + + // Responsive visibility + visible: (termWidth, termHeight) => termWidth >= 100 // Hide on narrow terminals + + // Focus behavior + nonCapturing: true // Don't auto-focus when shown +}); + +// OverlayHandle methods +handle.hide(); // Permanently remove the overlay +handle.setHidden(true); // Temporarily hide (can show again) +handle.setHidden(false); // Show again after hiding +handle.isHidden(); // Check if temporarily hidden +handle.focus(); // Focus and bring to visual front +handle.unfocus(); // Release focus to normal fallback +handle.unfocus({ target: baseComponent }); // Release this overlay to a specific component +handle.unfocus({ target: null }); // Release this overlay and leave focus empty +handle.isFocused(); // Check if overlay has focus + +handle.unfocus(); +// Overlay loses focus; TUI falls back to another visible capturing overlay or the previous focus target. + +handle.unfocus({ target: null }); +// Overlay loses focus; no component receives input until focus is set again. + +// A focused visible overlay reclaims keyboard input after temporary replacement UI +// releases focus. If you want a specific component to receive input while overlays remain +// visible, call handle.unfocus({ target: component }). + +// Hide topmost overlay +tui.hideOverlay(); + +// Check if any visible overlay is active +tui.hasOverlay(); +``` + +**Anchor values**: `'center'`, `'top-left'`, `'top-right'`, `'bottom-left'`, `'bottom-right'`, `'top-center'`, `'bottom-center'`, `'left-center'`, `'right-center'` + +**Resolution order**: +1. `minWidth` is applied as a floor after width calculation +2. For position: absolute `row`/`col` > percentage `row`/`col` > `anchor` +3. `margin` clamps final position to stay within terminal bounds +4. `visible` callback controls whether overlay renders (called each frame) + +### Component Interface + +All components implement: + +```typescript +interface Component { + render(width: number): string[]; + handleInput?(data: string): void; + invalidate?(): void; +} +``` + +| Method | Description | +|--------|-------------| +| `render(width)` | Returns an array of strings, one per line. Each line **must not exceed `width`** or the TUI will error. Use `truncateToWidth()` or manual wrapping to ensure this. | +| `handleInput?(data)` | Called when the component has focus and receives keyboard input. The `data` string contains raw terminal input (may include ANSI escape sequences). | +| `invalidate?()` | Called to clear any cached render state. Components should re-render from scratch on the next `render()` call. | + +The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use `wrapTextWithAnsi()` so styles are preserved for each wrapped line. + +### Focusable Interface (IME Support) + +Components that display a text cursor and need IME (Input Method Editor) support should implement the `Focusable` interface: + +```typescript +import { CURSOR_MARKER, type Component, type Focusable } from "@moonshot-ai/pi-tui"; + +class MyInput implements Component, Focusable { + focused: boolean = false; // Set by TUI when focus changes + + render(width: number): string[] { + const marker = this.focused ? CURSOR_MARKER : ""; + // Emit marker right before the fake cursor + return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`]; + } +} +``` + +When a `Focusable` component has focus, TUI: +1. Sets `focused = true` on the component +2. Scans rendered output for `CURSOR_MARKER` (a zero-width APC escape sequence) +3. Positions the hardware terminal cursor at that location +4. Shows the hardware cursor only when `showHardwareCursor` is enabled + +The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the `TUI` constructor option, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface. + +**Container components with embedded inputs:** When a container component (dialog, selector, etc.) contains an `Input` or `Editor` child, the container must implement `Focusable` and propagate the focus state to the child: + +```typescript +import { Container, type Focusable, Input } from "@moonshot-ai/pi-tui"; + +class SearchDialog extends Container implements Focusable { + private searchInput: Input; + + // Propagate focus to child input for IME cursor positioning + private _focused = false; + get focused(): boolean { return this._focused; } + set focused(value: boolean) { + this._focused = value; + this.searchInput.focused = value; + } + + constructor() { + super(); + this.searchInput = new Input(); + this.addChild(this.searchInput); + } +} +``` + +Without this propagation, typing with an IME (Chinese, Japanese, Korean, etc.) will show the candidate window in the wrong position. + +## Built-in Components + +### Container + +Groups child components. + +```typescript +const container = new Container(); +container.addChild(component); +container.removeChild(component); +``` + +### Box + +Container that applies padding and background color to all children. + +```typescript +const box = new Box( + 1, // paddingX (default: 1) + 1, // paddingY (default: 1) + (text) => chalk.bgGray(text) // optional background function +); +box.addChild(new Text("Content")); +box.setBgFn((text) => chalk.bgBlue(text)); // Change background dynamically +``` + +### Text + +Displays multi-line text with word wrapping and padding. + +```typescript +const text = new Text( + "Hello World", // text content + 1, // paddingX (default: 1) + 1, // paddingY (default: 1) + (text) => chalk.bgGray(text) // optional background function +); +text.setText("Updated text"); +text.setCustomBgFn((text) => chalk.bgBlue(text)); +``` + +### TruncatedText + +Single-line text that truncates to fit viewport width. Useful for status lines and headers. + +```typescript +const truncated = new TruncatedText( + "This is a very long line that will be truncated...", + 0, // paddingX (default: 0) + 0 // paddingY (default: 0) +); +``` + +### Input + +Single-line text input with horizontal scrolling. + +```typescript +const input = new Input(); +input.onSubmit = (value) => console.log(value); +input.setValue("initial"); +input.getValue(); +``` + +**Key Bindings:** +- `Enter` - Submit +- `Ctrl+A` / `Ctrl+E` - Line start/end +- `Ctrl+W` or `Alt+Backspace` - Delete word backwards +- `Ctrl+U` - Delete to start of line +- `Ctrl+K` - Delete to end of line +- `Ctrl+Left` / `Ctrl+Right` - Word navigation +- `Alt+Left` / `Alt+Right` - Word navigation +- Arrow keys, Backspace, Delete work as expected + +### Editor + +Multi-line text editor with autocomplete, file completion, paste handling, and vertical scrolling when content exceeds terminal height. + +```typescript +interface EditorTheme { + borderColor: (str: string) => string; + selectList: SelectListTheme; +} + +interface EditorOptions { + paddingX?: number; // Horizontal padding (default: 0) +} + +const editor = new Editor(tui, theme, options?); // tui is required for height-aware scrolling +editor.onSubmit = (text) => console.log(text); +editor.onChange = (text) => console.log("Changed:", text); +editor.disableSubmit = true; // Disable submit temporarily +editor.setAutocompleteProvider(provider); +editor.borderColor = (s) => chalk.blue(s); // Change border dynamically +editor.setPaddingX(1); // Update horizontal padding dynamically +editor.getPaddingX(); // Get current padding +``` + +**Features:** +- Multi-line editing with word wrap +- Slash command autocomplete (type `/`) +- File path autocomplete (press `Tab`) +- Large paste handling (>10 lines creates `[paste #1 +50 lines]` marker) +- Horizontal lines above/below editor +- Fake cursor rendering (hidden real cursor) + +**Key Bindings:** +- `Enter` - Submit +- `Shift+Enter`, `Ctrl+Enter`, or `Alt+Enter` - New line (terminal-dependent, Alt+Enter most reliable) +- `Tab` - Autocomplete +- `Ctrl+K` - Delete to end of line +- `Ctrl+U` - Delete to start of line +- `Ctrl+W` or `Alt+Backspace` - Delete word backwards +- `Alt+D` or `Alt+Delete` - Delete word forwards +- `Ctrl+A` / `Ctrl+E` - Line start/end +- `Ctrl+]` - Jump forward to character (awaits next keypress, then moves cursor to first occurrence) +- `Ctrl+Alt+]` - Jump backward to character +- Arrow keys, Backspace, Delete work as expected + +### Markdown + +Renders markdown with syntax highlighting and theming support. + +```typescript +interface MarkdownTheme { + heading: (text: string) => string; + link: (text: string) => string; + linkUrl: (text: string) => string; + code: (text: string) => string; + codeBlock: (text: string) => string; + codeBlockBorder: (text: string) => string; + quote: (text: string) => string; + quoteBorder: (text: string) => string; + hr: (text: string) => string; + listBullet: (text: string) => string; + bold: (text: string) => string; + italic: (text: string) => string; + strikethrough: (text: string) => string; + underline: (text: string) => string; + highlightCode?: (code: string, lang?: string) => string[]; +} + +interface DefaultTextStyle { + color?: (text: string) => string; + bgColor?: (text: string) => string; + bold?: boolean; + italic?: boolean; + strikethrough?: boolean; + underline?: boolean; +} + +const md = new Markdown( + "# Hello\n\nSome **bold** text", + 1, // paddingX + 1, // paddingY + theme, // MarkdownTheme + defaultStyle // optional DefaultTextStyle +); +md.setText("Updated markdown"); +``` + +**Features:** +- Headings, bold, italic, code blocks, lists, links, blockquotes +- HTML tags rendered as plain text +- Optional syntax highlighting via `highlightCode` +- Padding support +- Render caching for performance + +### Loader + +Animated loading spinner. + +```typescript +const loader = new Loader( + tui, // TUI instance for render updates + (s) => chalk.cyan(s), // spinner color function + (s) => chalk.gray(s), // message color function + "Loading..." // message (default: "Loading...") +); +loader.start(); +loader.setMessage("Still loading..."); +loader.stop(); +``` + +### CancellableLoader + +Extends Loader with Escape key handling and an AbortSignal for cancelling async operations. + +```typescript +const loader = new CancellableLoader( + tui, // TUI instance for render updates + (s) => chalk.cyan(s), // spinner color function + (s) => chalk.gray(s), // message color function + "Working..." // message +); +loader.onAbort = () => done(null); // Called when user presses Escape +doAsyncWork(loader.signal).then(done); +``` + +**Properties:** +- `signal: AbortSignal` - Aborted when user presses Escape +- `aborted: boolean` - Whether the loader was aborted +- `onAbort?: () => void` - Callback when user presses Escape + +### SelectList + +Interactive selection list with keyboard navigation. + +```typescript +interface SelectItem { + value: string; + label: string; + description?: string; +} + +interface SelectListTheme { + selectedPrefix: (text: string) => string; + selectedText: (text: string) => string; + description: (text: string) => string; + scrollInfo: (text: string) => string; + noMatch: (text: string) => string; +} + +const list = new SelectList( + [ + { value: "opt1", label: "Option 1", description: "First option" }, + { value: "opt2", label: "Option 2", description: "Second option" }, + ], + 5, // maxVisible + theme // SelectListTheme +); + +list.onSelect = (item) => console.log("Selected:", item); +list.onCancel = () => console.log("Cancelled"); +list.onSelectionChange = (item) => console.log("Highlighted:", item); +list.setFilter("opt"); // Filter items +``` + +**Controls:** +- Arrow keys: Navigate +- Enter: Select +- Escape: Cancel + +### SettingsList + +Settings panel with value cycling and submenus. + +```typescript +interface SettingItem { + id: string; + label: string; + description?: string; + currentValue: string; + values?: string[]; // If provided, Enter/Space cycles through these + submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component; +} + +interface SettingsListTheme { + label: (text: string, selected: boolean) => string; + value: (text: string, selected: boolean) => string; + description: (text: string) => string; + cursor: string; + hint: (text: string) => string; +} + +const settings = new SettingsList( + [ + { id: "theme", label: "Theme", currentValue: "dark", values: ["dark", "light"] }, + { id: "model", label: "Model", currentValue: "gpt-4", submenu: (val, done) => modelSelector }, + ], + 10, // maxVisible + theme, // SettingsListTheme + (id, newValue) => console.log(`${id} changed to ${newValue}`), + () => console.log("Cancelled") +); +settings.updateValue("theme", "light"); +``` + +**Controls:** +- Arrow keys: Navigate +- Enter/Space: Activate (cycle value or open submenu) +- Escape: Cancel + +### Spacer + +Empty lines for vertical spacing. + +```typescript +const spacer = new Spacer(2); // 2 empty lines (default: 1) +``` + +### Image + +Renders images inline for terminals that support the Kitty graphics protocol (Kitty, Ghostty, WezTerm) or iTerm2 inline images. Falls back to a text placeholder on unsupported terminals. + +```typescript +interface ImageTheme { + fallbackColor: (str: string) => string; +} + +interface ImageOptions { + maxWidthCells?: number; + maxHeightCells?: number; + filename?: string; +} + +const image = new Image( + base64Data, // base64-encoded image data + "image/png", // MIME type + theme, // ImageTheme + options // optional ImageOptions +); +tui.addChild(image); +``` + +Supported formats: PNG, JPEG, GIF, WebP. Dimensions are parsed from the image headers automatically. + +## Autocomplete + +### CombinedAutocompleteProvider + +Supports both slash commands and file paths. + +```typescript +import { CombinedAutocompleteProvider } from "@moonshot-ai/pi-tui"; + +const provider = new CombinedAutocompleteProvider( + [ + { name: "help", description: "Show help" }, + { name: "clear", description: "Clear screen" }, + { name: "delete", description: "Delete last message" }, + ], + process.cwd() // base path for file completion +); + +editor.setAutocompleteProvider(provider); +``` + +**Features:** +- Type `/` to see slash commands +- Press `Tab` for file path completion +- Works with `~/`, `./`, `../`, and `@` prefix +- Filters to attachable files for `@` prefix + +## Key Detection + +Use `matchesKey()` with the `Key` helper for detecting keyboard input (supports Kitty keyboard protocol): + +```typescript +import { matchesKey, Key } from "@moonshot-ai/pi-tui"; + +if (matchesKey(data, Key.ctrl("c"))) { + process.exit(0); +} + +if (matchesKey(data, Key.enter)) { + submit(); +} else if (matchesKey(data, Key.escape)) { + cancel(); +} else if (matchesKey(data, Key.up)) { + moveUp(); +} +``` + +**Key identifiers** (use `Key.*` for autocomplete, or string literals): +- Basic keys: `Key.enter`, `Key.escape`, `Key.tab`, `Key.space`, `Key.backspace`, `Key.delete`, `Key.home`, `Key.end` +- Arrow keys: `Key.up`, `Key.down`, `Key.left`, `Key.right` +- With modifiers: `Key.ctrl("c")`, `Key.shift("tab")`, `Key.alt("left")`, `Key.ctrlShift("p")` +- String format also works: `"enter"`, `"ctrl+c"`, `"shift+tab"`, `"ctrl+shift+p"` + +## Differential Rendering + +The TUI uses three rendering strategies: + +1. **First Render**: Output all lines without clearing scrollback +2. **Width Changed or Change Above Viewport**: Clear screen and full re-render +3. **Normal Update**: Move cursor to first changed line, clear to end, render changed lines + +All updates are wrapped in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering. + +## Terminal Interface + +The TUI works with any object implementing the `Terminal` interface: + +```typescript +interface Terminal { + start(onInput: (data: string) => void, onResize: () => void): void; + stop(): void; + write(data: string): void; + get columns(): number; + get rows(): number; + moveBy(lines: number): void; + hideCursor(): void; + showCursor(): void; + clearLine(): void; + clearFromCursor(): void; + clearScreen(): void; +} +``` + +**Built-in implementations:** +- `ProcessTerminal` - Uses `process.stdin/stdout` +- `VirtualTerminal` - For testing (uses `@xterm/headless`) + +## Utilities + +```typescript +import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@moonshot-ai/pi-tui"; + +// Get visible width of string (ignoring ANSI codes) +const width = visibleWidth("\x1b[31mHello\x1b[0m"); // 5 + +// Truncate string to width (preserving ANSI codes, adds ellipsis) +const truncated = truncateToWidth("Hello World", 8); // "Hello..." + +// Truncate without ellipsis +const truncatedNoEllipsis = truncateToWidth("Hello World", 8, ""); // "Hello Wo" + +// Wrap text to width (preserving ANSI codes across line breaks) +const lines = wrapTextWithAnsi("This is a long line that needs wrapping", 20); +// ["This is a long line", "that needs wrapping"] +``` + +## Creating Custom Components + +When creating custom components, **each line returned by `render()` must not exceed the `width` parameter**. The TUI will error if any line is wider than the terminal. + +### Handling Input + +Use `matchesKey()` with the `Key` helper for keyboard input: + +```typescript +import { matchesKey, Key, truncateToWidth } from "@moonshot-ai/pi-tui"; +import type { Component } from "@moonshot-ai/pi-tui"; + +class MyInteractiveComponent implements Component { + private selectedIndex = 0; + private items = ["Option 1", "Option 2", "Option 3"]; + + public onSelect?: (index: number) => void; + public onCancel?: () => void; + + handleInput(data: string): void { + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + } else if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1); + } else if (matchesKey(data, Key.enter)) { + this.onSelect?.(this.selectedIndex); + } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { + this.onCancel?.(); + } + } + + render(width: number): string[] { + return this.items.map((item, i) => { + const prefix = i === this.selectedIndex ? "> " : " "; + return truncateToWidth(prefix + item, width); + }); + } +} +``` + +### Handling Line Width + +Use the provided utilities to ensure lines fit: + +```typescript +import { visibleWidth, truncateToWidth } from "@moonshot-ai/pi-tui"; +import type { Component } from "@moonshot-ai/pi-tui"; + +class MyComponent implements Component { + private text: string; + + constructor(text: string) { + this.text = text; + } + + render(width: number): string[] { + // Option 1: Truncate long lines + return [truncateToWidth(this.text, width)]; + + // Option 2: Check and pad to exact width + const line = this.text; + const visible = visibleWidth(line); + if (visible > width) { + return [truncateToWidth(line, width)]; + } + // Pad to exact width (optional, for backgrounds) + return [line + " ".repeat(width - visible)]; + } +} +``` + +### ANSI Code Considerations + +Both `visibleWidth()` and `truncateToWidth()` correctly handle ANSI escape codes: + +- `visibleWidth()` ignores ANSI codes when calculating width +- `truncateToWidth()` preserves ANSI codes and properly closes them when truncating + +```typescript +import chalk from "chalk"; + +const styled = chalk.red("Hello") + " " + chalk.blue("World"); +const width = visibleWidth(styled); // 11 (not counting ANSI codes) +const truncated = truncateToWidth(styled, 8); // Red "Hello" + " W..." with proper reset +``` + +### Caching + +For performance, components should cache their rendered output and only re-render when necessary: + +```typescript +class CachedComponent implements Component { + private text: string; + private cachedWidth?: number; + private cachedLines?: string[]; + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + const lines = [truncateToWidth(this.text, width)]; + + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } +} +``` + +## Example + +See `test/chat-simple.ts` for a complete chat interface example with: +- Markdown messages with custom background colors +- Loading spinner during responses +- Editor with autocomplete and slash commands +- Spacers between messages + +Run it: +```bash +npx tsx test/chat-simple.ts +``` + +## Development + +```bash +# Install dependencies (from monorepo root) +npm install + +# Run type checking +npm run check + +# Run the demo +npx tsx test/chat-simple.ts +``` + +### Debug logging + +Set `PI_TUI_WRITE_LOG` to capture the raw ANSI stream written to stdout. + +```bash +PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx test/chat-simple.ts +``` diff --git a/packages/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node b/packages/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node new file mode 100755 index 000000000..461484269 Binary files /dev/null and b/packages/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node differ diff --git a/packages/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node b/packages/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node new file mode 100755 index 000000000..0c82b3913 Binary files /dev/null and b/packages/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node differ diff --git a/packages/pi-tui/native/darwin/src/darwin-modifiers.c b/packages/pi-tui/native/darwin/src/darwin-modifiers.c new file mode 100644 index 000000000..7e612519d --- /dev/null +++ b/packages/pi-tui/native/darwin/src/darwin-modifiers.c @@ -0,0 +1,70 @@ +#include +#include +#include +#include +#include + +#define NAPI_AUTO_LENGTH ((size_t)-1) + +typedef void* napi_env; +typedef void* napi_value; +typedef void* napi_callback_info; +typedef napi_value (*napi_callback)(napi_env, napi_callback_info); +typedef int (*napi_create_function_fn)(napi_env, const char*, size_t, napi_callback, void*, napi_value*); +typedef int (*napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value); +typedef int (*napi_get_boolean_fn)(napi_env, bool, napi_value*); +typedef int (*napi_get_cb_info_fn)(napi_env, napi_callback_info, size_t*, napi_value*, napi_value*, void**); +typedef int (*napi_get_value_string_utf8_fn)(napi_env, napi_value, char*, size_t, size_t*); + +static void* node_symbol(const char* name) { + return dlsym(RTLD_DEFAULT, name); +} + +static CGEventFlags modifier_mask_for_name(const char* name) { + if (strcmp(name, "shift") == 0) return kCGEventFlagMaskShift; + if (strcmp(name, "command") == 0) return kCGEventFlagMaskCommand; + if (strcmp(name, "control") == 0) return kCGEventFlagMaskControl; + if (strcmp(name, "option") == 0) return kCGEventFlagMaskAlternate; + return 0; +} + +static napi_value is_modifier_pressed(napi_env env, napi_callback_info info) { + napi_get_cb_info_fn napi_get_cb_info = (napi_get_cb_info_fn)node_symbol("napi_get_cb_info"); + napi_get_value_string_utf8_fn napi_get_value_string_utf8 = (napi_get_value_string_utf8_fn)node_symbol("napi_get_value_string_utf8"); + napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean"); + + bool pressed = false; + if (napi_get_cb_info && napi_get_value_string_utf8) { + size_t argc = 1; + napi_value args[1] = {0}; + if (napi_get_cb_info(env, info, &argc, args, 0, 0) == 0 && argc >= 1 && args[0]) { + char name[16] = {0}; + size_t copied = 0; + if (napi_get_value_string_utf8(env, args[0], name, sizeof(name), &copied) == 0) { + CGEventFlags mask = modifier_mask_for_name(name); + if (mask != 0) { + CGEventFlags flags = CGEventSourceFlagsState(kCGEventSourceStateCombinedSessionState); + pressed = (flags & mask) != 0; + } + } + } + } + + napi_value result = 0; + if (napi_get_boolean) napi_get_boolean(env, pressed, &result); + return result; +} + +__attribute__((visibility("default"))) napi_value napi_register_module_v1(napi_env env, napi_value exports) { + napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function"); + napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property"); + + napi_value fn = 0; + if (napi_create_function && + napi_set_named_property && + napi_create_function(env, "isModifierPressed", NAPI_AUTO_LENGTH, is_modifier_pressed, 0, &fn) == 0) { + napi_set_named_property(env, exports, "isModifierPressed", fn); + } + + return exports; +} diff --git a/packages/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node b/packages/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node new file mode 100644 index 000000000..42b2c77cc Binary files /dev/null and b/packages/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node differ diff --git a/packages/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node b/packages/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node new file mode 100644 index 000000000..2c6d86d87 Binary files /dev/null and b/packages/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node differ diff --git a/packages/pi-tui/native/win32/src/win32-console-mode.c b/packages/pi-tui/native/win32/src/win32-console-mode.c new file mode 100644 index 000000000..d68810c70 --- /dev/null +++ b/packages/pi-tui/native/win32/src/win32-console-mode.c @@ -0,0 +1,53 @@ +#include + +#ifndef ENABLE_VIRTUAL_TERMINAL_INPUT +#define ENABLE_VIRTUAL_TERMINAL_INPUT 0x0200 +#endif + +#define NAPI_AUTO_LENGTH ((unsigned long long)-1) + +typedef void* napi_env; +typedef void* napi_value; +typedef void* napi_callback_info; +typedef napi_value (__cdecl *napi_callback)(napi_env, napi_callback_info); +typedef int (__cdecl *napi_create_function_fn)(napi_env, const char*, unsigned long long, napi_callback, void*, napi_value*); +typedef int (__cdecl *napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value); +typedef int (__cdecl *napi_get_boolean_fn)(napi_env, int, napi_value*); + +static void* node_symbol(const char* name) { + HMODULE module = GetModuleHandleA(0); + void* proc = module ? (void*)GetProcAddress(module, name) : 0; + if (proc) return proc; + + module = GetModuleHandleA("node.dll"); + return module ? (void*)GetProcAddress(module, name) : 0; +} + +static napi_value __cdecl enable_virtual_terminal_input(napi_env env, napi_callback_info info) { + (void)info; + + HANDLE handle = GetStdHandle(STD_INPUT_HANDLE); + DWORD mode = 0; + int enabled = handle != INVALID_HANDLE_VALUE && + GetConsoleMode(handle, &mode) && + SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_INPUT); + + napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean"); + napi_value result = 0; + if (napi_get_boolean) napi_get_boolean(env, enabled, &result); + return result; +} + +__declspec(dllexport) napi_value __cdecl napi_register_module_v1(napi_env env, napi_value exports) { + napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function"); + napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property"); + + napi_value fn = 0; + if (napi_create_function && + napi_set_named_property && + napi_create_function(env, "enableVirtualTerminalInput", NAPI_AUTO_LENGTH, enable_virtual_terminal_input, 0, &fn) == 0) { + napi_set_named_property(env, exports, "enableVirtualTerminalInput", fn); + } + + return exports; +} diff --git a/packages/pi-tui/package.json b/packages/pi-tui/package.json new file mode 100644 index 000000000..915daba7d --- /dev/null +++ b/packages/pi-tui/package.json @@ -0,0 +1,73 @@ +{ + "name": "@moonshot-ai/pi-tui", + "version": "0.80.2", + "private": true, + "description": "Terminal User Interface library with differential rendering for efficient text-based applications", + "license": "MIT", + "author": "Moonshot AI", + "homepage": "https://github.com/MoonshotAI/kimi-code/tree/main/packages/pi-tui#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/MoonshotAI/kimi-code.git", + "directory": "packages/pi-tui" + }, + "bugs": { + "url": "https://github.com/MoonshotAI/kimi-code/issues" + }, + "keywords": [ + "tui", + "terminal", + "ui", + "text-editor", + "differential-rendering", + "typescript", + "cli" + ], + "files": [ + "dist", + "native", + "README.md" + ], + "type": "module", + "imports": { + "#/*": [ + "./src/*.ts", + "./src/*/index.ts" + ] + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "provenance": true + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "node --test test/*.test.ts", + "clean": "rm -rf dist" + }, + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "devDependencies": { + "@xterm/headless": "5.5.0", + "chalk": "^5.4.1" + }, + "engines": { + "node": ">=22.19.0" + } +} diff --git a/packages/pi-tui/src/autocomplete.ts b/packages/pi-tui/src/autocomplete.ts new file mode 100644 index 000000000..205748d88 --- /dev/null +++ b/packages/pi-tui/src/autocomplete.ts @@ -0,0 +1,786 @@ +import { spawn } from "child_process"; +import { readdirSync, statSync } from "fs"; +import { homedir } from "os"; +import { basename, dirname, join } from "path"; +import { fuzzyFilter } from "./fuzzy.ts"; + +const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]); + +function toDisplayPath(value: string): string { + return value.replace(/\\/g, "/"); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function buildFdPathQuery(query: string): string { + const normalized = toDisplayPath(query); + if (!normalized.includes("/")) { + return normalized; + } + + const hasTrailingSeparator = normalized.endsWith("/"); + const trimmed = normalized.replace(/^\/+|\/+$/g, ""); + if (!trimmed) { + return normalized; + } + + const separatorPattern = "[\\\\/]"; + const segments = trimmed + .split("/") + .filter(Boolean) + .map((segment) => escapeRegex(segment)); + if (segments.length === 0) { + return normalized; + } + + let pattern = segments.join(separatorPattern); + if (hasTrailingSeparator) { + pattern += separatorPattern; + } + return pattern; +} + +function findLastDelimiter(text: string): number { + for (let i = text.length - 1; i >= 0; i -= 1) { + if (PATH_DELIMITERS.has(text[i] ?? "")) { + return i; + } + } + return -1; +} + +function findUnclosedQuoteStart(text: string): number | null { + let inQuotes = false; + let quoteStart = -1; + + for (let i = 0; i < text.length; i += 1) { + if (text[i] === '"') { + inQuotes = !inQuotes; + if (inQuotes) { + quoteStart = i; + } + } + } + + return inQuotes ? quoteStart : null; +} + +function isTokenStart(text: string, index: number): boolean { + return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? ""); +} + +function extractQuotedPrefix(text: string): string | null { + const quoteStart = findUnclosedQuoteStart(text); + if (quoteStart === null) { + return null; + } + + if (quoteStart > 0 && text[quoteStart - 1] === "@") { + if (!isTokenStart(text, quoteStart - 1)) { + return null; + } + return text.slice(quoteStart - 1); + } + + if (!isTokenStart(text, quoteStart)) { + return null; + } + + return text.slice(quoteStart); +} + +function parsePathPrefix(prefix: string): { rawPrefix: string; isAtPrefix: boolean; isQuotedPrefix: boolean } { + if (prefix.startsWith('@"')) { + return { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true }; + } + if (prefix.startsWith('"')) { + return { rawPrefix: prefix.slice(1), isAtPrefix: false, isQuotedPrefix: true }; + } + if (prefix.startsWith("@")) { + return { rawPrefix: prefix.slice(1), isAtPrefix: true, isQuotedPrefix: false }; + } + return { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false }; +} + +function buildCompletionValue( + path: string, + options: { isDirectory: boolean; isAtPrefix: boolean; isQuotedPrefix: boolean }, +): string { + const needsQuotes = options.isQuotedPrefix || path.includes(" "); + const prefix = options.isAtPrefix ? "@" : ""; + + if (!needsQuotes) { + return `${prefix}${path}`; + } + + const openQuote = `${prefix}"`; + const closeQuote = '"'; + return `${openQuote}${path}${closeQuote}`; +} + +// Use fd to walk directory tree (fast, respects .gitignore) +async function walkDirectoryWithFd( + baseDir: string, + fdPath: string, + query: string, + maxResults: number, + signal: AbortSignal, +): Promise> { + const args = [ + "--base-directory", + baseDir, + "--max-results", + String(maxResults), + "--type", + "f", + "--type", + "d", + "--follow", + "--hidden", + "--exclude", + ".git", + "--exclude", + ".git/*", + "--exclude", + ".git/**", + ]; + + if (toDisplayPath(query).includes("/")) { + args.push("--full-path"); + } + + if (query) { + args.push(buildFdPathQuery(query)); + } + + return await new Promise((resolve) => { + if (signal.aborted) { + resolve([]); + return; + } + + const child = spawn(fdPath, args, { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let resolved = false; + + const finish = (results: Array<{ path: string; isDirectory: boolean }>) => { + if (resolved) return; + resolved = true; + signal.removeEventListener("abort", onAbort); + resolve(results); + }; + + const onAbort = () => { + if (child.exitCode === null) { + child.kill("SIGKILL"); + } + }; + + signal.addEventListener("abort", onAbort, { once: true }); + child.stdout.setEncoding("utf-8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.on("error", () => { + finish([]); + }); + child.on("close", (code) => { + if (signal.aborted || code !== 0 || !stdout) { + finish([]); + return; + } + + const lines = stdout.trim().split("\n").filter(Boolean); + const results: Array<{ path: string; isDirectory: boolean }> = []; + + for (const line of lines) { + const displayLine = toDisplayPath(line); + const hasTrailingSeparator = displayLine.endsWith("/"); + const normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine; + if (normalizedPath === ".git" || normalizedPath.startsWith(".git/") || normalizedPath.includes("/.git/")) { + continue; + } + + results.push({ + path: displayLine, + isDirectory: hasTrailingSeparator, + }); + } + + finish(results); + }); + }); +} + +export interface AutocompleteItem { + value: string; + label: string; + description?: string; +} + +type Awaitable = T | Promise; + +export interface SlashCommand { + name: string; + description?: string; + argumentHint?: string; + // Function to get argument completions for this command + // Returns null if no argument completion is available + getArgumentCompletions?(argumentPrefix: string): Awaitable; +} + +export interface AutocompleteSuggestions { + items: AutocompleteItem[]; + prefix: string; // What we're matching against (e.g., "/" or "src/") +} + +export interface AutocompleteProvider { + /** Characters that should naturally trigger this provider at token boundaries. */ + triggerCharacters?: string[]; + + // Get autocomplete suggestions for current text/cursor position + // Returns null if no suggestions available + getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: { signal: AbortSignal; force?: boolean }, + ): Promise; + + // Apply the selected item + // Returns the new text and cursor position + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ): { + lines: string[]; + cursorLine: number; + cursorCol: number; + }; + + // Check if file completion should trigger for explicit Tab completion + shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean; +} + +// Combined provider that handles both slash commands and file paths +export class CombinedAutocompleteProvider implements AutocompleteProvider { + private commands: (SlashCommand | AutocompleteItem)[]; + private basePath: string; + private fdPath: string | null; + + constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string, fdPath: string | null = null) { + this.commands = commands; + this.basePath = basePath; + this.fdPath = fdPath; + } + + async getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: { signal: AbortSignal; force?: boolean }, + ): Promise { + const currentLine = lines[cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, cursorCol); + + const atPrefix = this.extractAtPrefix(textBeforeCursor); + if (atPrefix) { + const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix); + const suggestions = await this.getFuzzyFileSuggestions(rawPrefix, { + isQuotedPrefix, + signal: options.signal, + }); + if (suggestions.length === 0) return null; + + return { + items: suggestions, + prefix: atPrefix, + }; + } + + if (!options.force && textBeforeCursor.startsWith("/")) { + const spaceIndex = textBeforeCursor.indexOf(" "); + + if (spaceIndex === -1) { + const prefix = textBeforeCursor.slice(1); + const commandItems = this.commands.map((cmd) => { + const name = "name" in cmd ? cmd.name : cmd.value; + const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined; + const desc = cmd.description ?? ""; + const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc; + return { + name, + label: name, + description: fullDesc || undefined, + }; + }); + + const filtered = fuzzyFilter(commandItems, prefix, (item) => item.name).map((item) => ({ + value: item.name, + label: item.label, + ...(item.description && { description: item.description }), + })); + + if (filtered.length === 0) return null; + + return { + items: filtered, + prefix: textBeforeCursor, + }; + } + + const commandName = textBeforeCursor.slice(1, spaceIndex); + const argumentText = textBeforeCursor.slice(spaceIndex + 1); + + const command = this.commands.find((cmd) => { + const name = "name" in cmd ? cmd.name : cmd.value; + return name === commandName; + }); + if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) { + return null; + } + + const argumentSuggestions = await command.getArgumentCompletions(argumentText); + if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) { + return null; + } + + return { + items: argumentSuggestions, + prefix: argumentText, + }; + } + + const pathMatch = this.extractPathPrefix(textBeforeCursor, options.force ?? false); + if (pathMatch === null) { + return null; + } + + const suggestions = this.getFileSuggestions(pathMatch); + if (suggestions.length === 0) return null; + + return { + items: suggestions, + prefix: pathMatch, + }; + } + + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ): { lines: string[]; cursorLine: number; cursorCol: number } { + const currentLine = lines[cursorLine] || ""; + const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); + const afterCursor = currentLine.slice(cursorCol); + const isQuotedPrefix = prefix.startsWith('"') || prefix.startsWith('@"'); + const hasLeadingQuoteAfterCursor = afterCursor.startsWith('"'); + const hasTrailingQuoteInItem = item.value.endsWith('"'); + const adjustedAfterCursor = + isQuotedPrefix && hasTrailingQuoteInItem && hasLeadingQuoteAfterCursor ? afterCursor.slice(1) : afterCursor; + + // Check if we're completing a slash command (prefix starts with "/" but NOT a file path) + // Slash commands are at the start of the line and don't contain path separators after the first / + const isSlashCommand = prefix.startsWith("/") && beforePrefix.trim() === "" && !prefix.slice(1).includes("/"); + if (isSlashCommand) { + // This is a command name completion + const newLine = `${beforePrefix}/${item.value} ${adjustedAfterCursor}`; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + item.value.length + 2, // +2 for "/" and space + }; + } + + // Check if we're completing a file attachment (prefix starts with "@") + if (prefix.startsWith("@")) { + // This is a file attachment completion + // Don't add space after directories so user can continue autocompleting + const isDirectory = item.label.endsWith("/"); + const suffix = isDirectory ? "" : " "; + const newLine = `${beforePrefix + item.value}${suffix}${adjustedAfterCursor}`; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + + const hasTrailingQuote = item.value.endsWith('"'); + const cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length; + + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + cursorOffset + suffix.length, + }; + } + + // Check if we're in a slash command context (beforePrefix contains "/command ") + const textBeforeCursor = currentLine.slice(0, cursorCol); + if (textBeforeCursor.includes("/") && textBeforeCursor.includes(" ")) { + // This is likely a command argument completion + const newLine = beforePrefix + item.value + adjustedAfterCursor; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + + const isDirectory = item.label.endsWith("/"); + const hasTrailingQuote = item.value.endsWith('"'); + const cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length; + + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + cursorOffset, + }; + } + + // For file paths, complete the path + const newLine = beforePrefix + item.value + adjustedAfterCursor; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + + const isDirectory = item.label.endsWith("/"); + const hasTrailingQuote = item.value.endsWith('"'); + const cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length; + + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + cursorOffset, + }; + } + + // Extract @ prefix for fuzzy file suggestions + private extractAtPrefix(text: string): string | null { + const quotedPrefix = extractQuotedPrefix(text); + if (quotedPrefix?.startsWith('@"')) { + return quotedPrefix; + } + + const lastDelimiterIndex = findLastDelimiter(text); + const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1; + + if (text[tokenStart] === "@") { + return text.slice(tokenStart); + } + + return null; + } + + // Extract a path-like prefix from the text before cursor + private extractPathPrefix(text: string, forceExtract: boolean = false): string | null { + const quotedPrefix = extractQuotedPrefix(text); + if (quotedPrefix) { + return quotedPrefix; + } + + const lastDelimiterIndex = findLastDelimiter(text); + const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1); + + // For forced extraction (Tab key), always return something + if (forceExtract) { + return pathPrefix; + } + + // For natural triggers, return if it looks like a path, ends with /, starts with ~/, . + // Only return empty string if the text looks like it's starting a path context + if (pathPrefix.includes("/") || pathPrefix.startsWith(".") || pathPrefix.startsWith("~/")) { + return pathPrefix; + } + + // Return empty string only after a space (not for completely empty text) + // Empty text should not trigger file suggestions - that's for forced Tab completion + if (pathPrefix === "" && text.endsWith(" ")) { + return pathPrefix; + } + + return null; + } + + // Expand home directory (~/) to actual home path + private expandHomePath(path: string): string { + if (path.startsWith("~/")) { + const expandedPath = join(homedir(), path.slice(2)); + // Preserve trailing slash if original path had one + return path.endsWith("/") && !expandedPath.endsWith("/") ? `${expandedPath}/` : expandedPath; + } else if (path === "~") { + return homedir(); + } + return path; + } + + private resolveScopedFuzzyQuery(rawQuery: string): { baseDir: string; query: string; displayBase: string } | null { + const normalizedQuery = toDisplayPath(rawQuery); + const slashIndex = normalizedQuery.lastIndexOf("/"); + if (slashIndex === -1) { + return null; + } + + const displayBase = normalizedQuery.slice(0, slashIndex + 1); + const query = normalizedQuery.slice(slashIndex + 1); + + let baseDir: string; + if (displayBase.startsWith("~/")) { + baseDir = this.expandHomePath(displayBase); + } else if (displayBase.startsWith("/")) { + baseDir = displayBase; + } else { + baseDir = join(this.basePath, displayBase); + } + + try { + if (!statSync(baseDir).isDirectory()) { + return null; + } + } catch { + return null; + } + + return { baseDir, query, displayBase }; + } + + private scopedPathForDisplay(displayBase: string, relativePath: string): string { + const normalizedRelativePath = toDisplayPath(relativePath); + if (displayBase === "/") { + return `/${normalizedRelativePath}`; + } + return `${toDisplayPath(displayBase)}${normalizedRelativePath}`; + } + + // Get file/directory suggestions for a given path prefix + private getFileSuggestions(prefix: string): AutocompleteItem[] { + try { + let searchDir: string; + let searchPrefix: string; + const { rawPrefix, isAtPrefix, isQuotedPrefix } = parsePathPrefix(prefix); + let expandedPrefix = rawPrefix; + + // Handle home directory expansion + if (expandedPrefix.startsWith("~")) { + expandedPrefix = this.expandHomePath(expandedPrefix); + } + + const isRootPrefix = + rawPrefix === "" || + rawPrefix === "./" || + rawPrefix === "../" || + rawPrefix === "~" || + rawPrefix === "~/" || + rawPrefix === "/" || + (isAtPrefix && rawPrefix === ""); + + if (isRootPrefix) { + // Complete from specified position + if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) { + searchDir = expandedPrefix; + } else { + searchDir = join(this.basePath, expandedPrefix); + } + searchPrefix = ""; + } else if (rawPrefix.endsWith("/")) { + // If prefix ends with /, show contents of that directory + if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) { + searchDir = expandedPrefix; + } else { + searchDir = join(this.basePath, expandedPrefix); + } + searchPrefix = ""; + } else { + // Split into directory and file prefix + const dir = dirname(expandedPrefix); + const file = basename(expandedPrefix); + if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) { + searchDir = dir; + } else { + searchDir = join(this.basePath, dir); + } + searchPrefix = file; + } + + const entries = readdirSync(searchDir, { withFileTypes: true }); + const suggestions: AutocompleteItem[] = []; + + for (const entry of entries) { + if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) { + continue; + } + + // Check if entry is a directory (or a symlink pointing to a directory) + let isDirectory = entry.isDirectory(); + if (!isDirectory && entry.isSymbolicLink()) { + try { + const fullPath = join(searchDir, entry.name); + isDirectory = statSync(fullPath).isDirectory(); + } catch { + // Broken symlink or permission error - treat as file + } + } + + let relativePath: string; + const name = entry.name; + const displayPrefix = rawPrefix; + + if (displayPrefix.endsWith("/")) { + // If prefix ends with /, append entry to the prefix + relativePath = displayPrefix + name; + } else if (displayPrefix.includes("/") || displayPrefix.includes("\\")) { + // Preserve ~/ format for home directory paths + if (displayPrefix.startsWith("~/")) { + const homeRelativeDir = displayPrefix.slice(2); // Remove ~/ + const dir = dirname(homeRelativeDir); + relativePath = `~/${dir === "." ? name : join(dir, name)}`; + } else if (displayPrefix.startsWith("/")) { + // Absolute path - construct properly + const dir = dirname(displayPrefix); + if (dir === "/") { + relativePath = `/${name}`; + } else { + relativePath = `${dir}/${name}`; + } + } else { + relativePath = join(dirname(displayPrefix), name); + // path.join normalizes away ./ prefix, preserve it + if (displayPrefix.startsWith("./") && !relativePath.startsWith("./")) { + relativePath = `./${relativePath}`; + } + } + } else { + // For standalone entries, preserve ~/ if original prefix was ~/ + if (displayPrefix.startsWith("~")) { + relativePath = `~/${name}`; + } else { + relativePath = name; + } + } + + relativePath = toDisplayPath(relativePath); + const pathValue = isDirectory ? `${relativePath}/` : relativePath; + const value = buildCompletionValue(pathValue, { + isDirectory, + isAtPrefix, + isQuotedPrefix, + }); + + suggestions.push({ + value, + label: name + (isDirectory ? "/" : ""), + }); + } + + // Sort directories first, then alphabetically + suggestions.sort((a, b) => { + const aIsDir = a.value.endsWith("/"); + const bIsDir = b.value.endsWith("/"); + if (aIsDir && !bIsDir) return -1; + if (!aIsDir && bIsDir) return 1; + return a.label.localeCompare(b.label); + }); + + return suggestions; + } catch (_e) { + // Directory doesn't exist or not accessible + return []; + } + } + + // Score an entry against the query (higher = better match) + // isDirectory adds bonus to prioritize folders + private scoreEntry(filePath: string, query: string, isDirectory: boolean): number { + const fileName = basename(filePath); + const lowerFileName = fileName.toLowerCase(); + const lowerQuery = query.toLowerCase(); + + let score = 0; + + // Exact filename match (highest) + if (lowerFileName === lowerQuery) score = 100; + // Filename starts with query + else if (lowerFileName.startsWith(lowerQuery)) score = 80; + // Substring match in filename + else if (lowerFileName.includes(lowerQuery)) score = 50; + // Substring match in full path + else if (filePath.toLowerCase().includes(lowerQuery)) score = 30; + + // Directories get a bonus to appear first + if (isDirectory && score > 0) score += 10; + + return score; + } + + // Fuzzy file search using fd (fast, respects .gitignore) + private async getFuzzyFileSuggestions( + query: string, + options: { isQuotedPrefix: boolean; signal: AbortSignal }, + ): Promise { + if (!this.fdPath || options.signal.aborted) { + return []; + } + + try { + const scopedQuery = this.resolveScopedFuzzyQuery(query); + const fdBaseDir = scopedQuery?.baseDir ?? this.basePath; + const fdQuery = scopedQuery?.query ?? query; + const entries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal); + if (options.signal.aborted) { + return []; + } + + const scoredEntries = entries + .map((entry) => ({ + ...entry, + score: fdQuery ? this.scoreEntry(entry.path, fdQuery, entry.isDirectory) : 1, + })) + .filter((entry) => entry.score > 0); + + scoredEntries.sort((a, b) => b.score - a.score); + const topEntries = scoredEntries.slice(0, 20); + + const suggestions: AutocompleteItem[] = []; + for (const { path: entryPath, isDirectory } of topEntries) { + const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath; + const displayPath = scopedQuery + ? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash) + : pathWithoutSlash; + const entryName = basename(pathWithoutSlash); + const completionPath = isDirectory ? `${displayPath}/` : displayPath; + const value = buildCompletionValue(completionPath, { + isDirectory, + isAtPrefix: true, + isQuotedPrefix: options.isQuotedPrefix, + }); + + suggestions.push({ + value, + label: entryName + (isDirectory ? "/" : ""), + description: displayPath, + }); + } + + return suggestions; + } catch { + return []; + } + } + + // Check if we should trigger file completion (called on Tab key) + shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean { + const currentLine = lines[cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, cursorCol); + + // Don't trigger if we're typing a slash command at the start of the line + if (textBeforeCursor.trim().startsWith("/") && !textBeforeCursor.trim().includes(" ")) { + return false; + } + + return true; + } +} diff --git a/packages/pi-tui/src/components/box.ts b/packages/pi-tui/src/components/box.ts new file mode 100644 index 000000000..3573ab092 --- /dev/null +++ b/packages/pi-tui/src/components/box.ts @@ -0,0 +1,137 @@ +import type { Component } from "../tui.ts"; +import { applyBackgroundToLine, visibleWidth } from "../utils.ts"; + +type RenderCache = { + childLines: string[]; + width: number; + bgSample: string | undefined; + lines: string[]; +}; + +/** + * Box component - a container that applies padding and background to all children + */ +export class Box implements Component { + children: Component[] = []; + private paddingX: number; + private paddingY: number; + private bgFn?: (text: string) => string; + + // Cache for rendered output + private cache?: RenderCache; + + constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) { + this.paddingX = paddingX; + this.paddingY = paddingY; + this.bgFn = bgFn; + } + + addChild(component: Component): void { + this.children.push(component); + this.invalidateCache(); + } + + removeChild(component: Component): void { + const index = this.children.indexOf(component); + if (index !== -1) { + this.children.splice(index, 1); + this.invalidateCache(); + } + } + + clear(): void { + this.children = []; + this.invalidateCache(); + } + + setBgFn(bgFn?: (text: string) => string): void { + this.bgFn = bgFn; + // Don't invalidate here - we'll detect bgFn changes by sampling output + } + + private invalidateCache(): void { + this.cache = undefined; + } + + private matchCache(width: number, childLines: string[], bgSample: string | undefined): boolean { + const cache = this.cache; + return ( + !!cache && + cache.width === width && + cache.bgSample === bgSample && + cache.childLines.length === childLines.length && + cache.childLines.every((line, i) => line === childLines[i]) + ); + } + + invalidate(): void { + this.invalidateCache(); + for (const child of this.children) { + child.invalidate?.(); + } + } + + render(width: number): string[] { + if (this.children.length === 0) { + return []; + } + + const contentWidth = Math.max(1, width - this.paddingX * 2); + const leftPad = " ".repeat(this.paddingX); + + // Render all children + const childLines: string[] = []; + for (const child of this.children) { + const lines = child.render(contentWidth); + for (const line of lines) { + childLines.push(leftPad + line); + } + } + + if (childLines.length === 0) { + return []; + } + + // Check if bgFn output changed by sampling + const bgSample = this.bgFn ? this.bgFn("test") : undefined; + + // Check cache validity + if (this.matchCache(width, childLines, bgSample)) { + return this.cache!.lines; + } + + // Apply background and padding + const result: string[] = []; + + // Top padding + for (let i = 0; i < this.paddingY; i++) { + result.push(this.applyBg("", width)); + } + + // Content + for (const line of childLines) { + result.push(this.applyBg(line, width)); + } + + // Bottom padding + for (let i = 0; i < this.paddingY; i++) { + result.push(this.applyBg("", width)); + } + + // Update cache + this.cache = { childLines, width, bgSample, lines: result }; + + return result; + } + + private applyBg(line: string, width: number): string { + const visLen = visibleWidth(line); + const padNeeded = Math.max(0, width - visLen); + const padded = line + " ".repeat(padNeeded); + + if (this.bgFn) { + return applyBackgroundToLine(padded, width, this.bgFn); + } + return padded; + } +} diff --git a/packages/pi-tui/src/components/cancellable-loader.ts b/packages/pi-tui/src/components/cancellable-loader.ts new file mode 100644 index 000000000..7822cb042 --- /dev/null +++ b/packages/pi-tui/src/components/cancellable-loader.ts @@ -0,0 +1,40 @@ +import { getKeybindings } from "../keybindings.ts"; +import { Loader } from "./loader.ts"; + +/** + * 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 { + const kb = getKeybindings(); + if (kb.matches(data, "tui.select.cancel")) { + this.abortController.abort(); + this.onAbort?.(); + } + } + + dispose(): void { + this.stop(); + } +} diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts new file mode 100644 index 000000000..f7b7fbcdf --- /dev/null +++ b/packages/pi-tui/src/components/editor.ts @@ -0,0 +1,2307 @@ +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 { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts"; +import { UndoStack } from "../undo-stack.ts"; +import { + cjkBreakRegex, + getGraphemeSegmenter, + getWordSegmenter, + isWhitespaceChar, + truncateToWidth, + 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, +): Iterable { + // 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 subChunks = wordWrapLine(grapheme, maxWidth); + 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; +} + +interface LayoutLine { + text: string; + hasCursor: boolean; + cursorPos?: number; +} + +export interface EditorTheme { + borderColor: (str: string) => string; + selectList: SelectListTheme; +} + +export interface EditorOptions { + paddingX?: number; + autocompleteMaxVisible?: number; +} + +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]*)$`); +} + +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 width for cursor navigation + private lastWidth: number = 80; + + // 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; + private autocompleteRequestTask: Promise = Promise.resolve(); + private autocompleteStartToken: number = 0; + private autocompleteRequestId: number = 0; + + // Paste tracking for large pastes + private pastes: Map = new Map(); + private pasteCounter: number = 0; + + // Bracketed paste mode buffering + private pasteBuffer: string = ""; + private isInPaste: 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; + + // 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(); + + public onSubmit?: (text: string) => void; + public onChange?: (text: string) => 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; + } + + /** Set of currently valid paste IDs, for marker-aware segmentation. */ + private validPasteIds(): Set { + 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 { + 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(); + } + } + + setAutocompleteProvider(provider: AutocompleteProvider): void { + this.cancelAutocomplete(); + this.autocompleteProvider = provider; + this.setAutocompleteTriggerCharacters(provider.triggerCharacters ?? []); + } + + /** + * 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; + + const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases + if (newIndex < -1 || newIndex >= this.history.length) return; + + // Capture state when first entering history browsing mode + if (this.historyIndex === -1 && newIndex >= 0) { + this.pushUndoSnapshot(); + this.historyDraft = structuredClone(this.state); + } + + 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.onChange) this.onChange(this.getText()); + } else { + this.setTextInternal(""); + } + } else { + this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end"); + } + } + + private exitHistoryBrowsing(): void { + this.historyIndex = -1; + this.historyDraft = null; + } + + /** 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 + } + + 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; + + const horizontal = this.borderColor("─"); + + // 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); + + const result: string[] = []; + const leftPadding = " ".repeat(paddingX); + const rightPadding = leftPadding; + + // Render top border (with scroll indicator if scrolled down) + if (this.scrollOffset > 0) { + const indicator = `─── ↑ ${this.scrollOffset} more `; + const remaining = width - visibleWidth(indicator); + if (remaining >= 0) { + result.push(this.borderColor(indicator + "─".repeat(remaining))); + } else { + result.push(this.borderColor(truncateToWidth(indicator, width))); + } + } else { + result.push(horizontal.repeat(width)); + } + + // 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); + if (linesBelow > 0) { + const indicator = `─── ↓ ${linesBelow} more `; + const remaining = width - visibleWidth(indicator); + result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); + } else { + result.push(horizontal.repeat(width)); + } + + // Add autocomplete list if active + if (this.autocompleteState && this.autocompleteList) { + const autocompleteResult = this.autocompleteList.render(contentWidth); + 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; + } + + 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 = ""; + 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 = ""; + if (remaining.length > 0) { + this.handleInput(remaining); + } + return; + } + return; + } + + // 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); + + if (this.autocompletePrefix.startsWith("/")) { + 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; + } + + // 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; + } + + 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) { + this.insertCharacter(printable); + return; + } + + // Regular characters + if (data.charCodeAt(0) >= 32) { + 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): 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(); + } + 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) + if (char === "/" && this.isAtStartOfMessage()) { + 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(); + } + } + } 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+ sequences + // (ESC [ ; 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): 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.) + const 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 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(this.state); + } + + private undo(): void { + this.exitHistoryBrowsing(); + const snapshot = this.undoStack.pop(); + if (!snapshot) return; + Object.assign(this.state, snapshot); + 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() === "/"; + } + + 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; + return new SelectList(items, this.autocompleteMaxVisible, this.theme.selectList, layout); + } + + 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 { + 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 { + 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 }); + } +} diff --git a/packages/pi-tui/src/components/image.ts b/packages/pi-tui/src/components/image.ts new file mode 100644 index 000000000..50c1e0f78 --- /dev/null +++ b/packages/pi-tui/src/components/image.ts @@ -0,0 +1,126 @@ +import { + allocateImageId, + getCapabilities, + getCellDimensions, + getImageDimensions, + type ImageDimensions, + imageFallback, + renderImage, +} from "../terminal-image.ts"; +import type { Component } from "../tui.ts"; + +export interface ImageTheme { + fallbackColor: (str: string) => string; +} + +export interface ImageOptions { + maxWidthCells?: number; + maxHeightCells?: number; + filename?: string; + /** Kitty image ID. If provided, reuses this ID (for animations/updates). */ + imageId?: number; +} + +export class Image implements Component { + private base64Data: string; + private mimeType: string; + private dimensions: ImageDimensions; + private theme: ImageTheme; + private options: ImageOptions; + private imageId?: number; + + private cachedLines?: string[]; + private cachedWidth?: number; + + constructor( + base64Data: string, + mimeType: string, + theme: ImageTheme, + options: ImageOptions = {}, + dimensions?: ImageDimensions, + ) { + this.base64Data = base64Data; + this.mimeType = mimeType; + this.theme = theme; + this.options = options; + this.dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 }; + this.imageId = options.imageId; + } + + /** Get the Kitty image ID used by this image (if any). */ + getImageId(): number | undefined { + return this.imageId; + } + + invalidate(): void { + this.cachedLines = undefined; + this.cachedWidth = undefined; + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + const maxWidth = Math.max(1, Math.min(width - 2, this.options.maxWidthCells ?? 60)); + const cellDimensions = getCellDimensions(); + const defaultMaxHeight = Math.max(1, Math.ceil((maxWidth * cellDimensions.widthPx) / cellDimensions.heightPx)); + const maxHeight = this.options.maxHeightCells ?? defaultMaxHeight; + + const caps = getCapabilities(); + let lines: string[]; + + if (caps.images) { + if (caps.images === "kitty" && this.imageId === undefined) { + this.imageId = allocateImageId(); + } + const result = renderImage(this.base64Data, this.dimensions, { + maxWidthCells: maxWidth, + maxHeightCells: maxHeight, + imageId: this.imageId, + moveCursor: false, + }); + + if (result) { + // Store the image ID for later cleanup + if (result.imageId) { + this.imageId = result.imageId; + } + + if (caps.images === "kitty") { + // For Kitty: C=1 prevents cursor movement. + // Don't need the cursor movement. + lines = [result.sequence]; + + // Return `rows` lines so TUI accounts for image height. + for (let i = 0; i < result.rows - 1; i++) { + lines.push(""); + } + } else { + // Return `rows` lines so TUI accounts for image height. + // First (rows-1) lines are empty and cleared before the image is drawn. + // Last line: move cursor back up, draw the image, then move back down + // so TUI cursor accounting stays inside the scroll area. + lines = []; + for (let i = 0; i < result.rows - 1; i++) { + lines.push(""); + } + const rowOffset = result.rows - 1; + const moveUp = rowOffset > 0 ? `\x1b[${rowOffset}A` : ""; + lines.push(moveUp + result.sequence); + } + } else { + const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename); + lines = [this.theme.fallbackColor(fallback)]; + } + } else { + const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename); + lines = [this.theme.fallbackColor(fallback)]; + } + + this.cachedLines = lines; + this.cachedWidth = width; + + return lines; + } +} diff --git a/packages/pi-tui/src/components/input.ts b/packages/pi-tui/src/components/input.ts new file mode 100644 index 000000000..a054076d0 --- /dev/null +++ b/packages/pi-tui/src/components/input.ts @@ -0,0 +1,447 @@ +import { getKeybindings } from "../keybindings.ts"; +import { decodeKittyPrintable } from "../keys.ts"; +import { KillRing } from "../kill-ring.ts"; +import { type Component, CURSOR_MARKER, type Focusable } from "../tui.ts"; +import { UndoStack } from "../undo-stack.ts"; +import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.ts"; +import { findWordBackward, findWordForward } from "../word-navigation.ts"; + +const segmenter = getGraphemeSegmenter(); + +interface InputState { + value: string; + cursor: number; +} + +/** + * Input component - single-line text input with horizontal scrolling + */ +export class Input implements Component, Focusable { + private value: string = ""; + private cursor: number = 0; // Cursor position in the value + public onSubmit?: (value: string) => void; + public onEscape?: () => void; + + /** Focusable interface - set by TUI when focus changes */ + focused: boolean = false; + + // Bracketed paste mode buffering + private pasteBuffer: string = ""; + private isInPaste: boolean = false; + + // Kill ring for Emacs-style kill/yank operations + private killRing = new KillRing(); + private lastAction: "kill" | "yank" | "type-word" | null = null; + + // Undo support + private undoStack = new UndoStack(); + + getValue(): string { + return this.value; + } + + setValue(value: string): void { + this.value = value; + this.cursor = Math.min(this.cursor, value.length); + } + + handleInput(data: string): void { + // Handle bracketed paste mode + // Start of paste: \x1b[200~ + // End of paste: \x1b[201~ + + // Check if we're starting a bracketed paste + if (data.includes("\x1b[200~")) { + this.isInPaste = true; + this.pasteBuffer = ""; + data = data.replace("\x1b[200~", ""); + } + + // If we're in a paste, buffer the data + if (this.isInPaste) { + // Check if this chunk contains the end marker + this.pasteBuffer += data; + + const endIndex = this.pasteBuffer.indexOf("\x1b[201~"); + if (endIndex !== -1) { + // Extract the pasted content + const pasteContent = this.pasteBuffer.substring(0, endIndex); + + // Process the complete paste + this.handlePaste(pasteContent); + + // Reset paste state + this.isInPaste = false; + + // Handle any remaining input after the paste marker + const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~ + this.pasteBuffer = ""; + if (remaining) { + this.handleInput(remaining); + } + } + return; + } + + const kb = getKeybindings(); + + // Escape/Cancel + if (kb.matches(data, "tui.select.cancel")) { + if (this.onEscape) this.onEscape(); + return; + } + + // Undo + if (kb.matches(data, "tui.editor.undo")) { + this.undo(); + return; + } + + // Submit + if (kb.matches(data, "tui.input.submit") || data === "\n") { + if (this.onSubmit) this.onSubmit(this.value); + return; + } + + // Deletion + if (kb.matches(data, "tui.editor.deleteCharBackward")) { + this.handleBackspace(); + return; + } + + if (kb.matches(data, "tui.editor.deleteCharForward")) { + this.handleForwardDelete(); + 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.deleteToLineStart")) { + this.deleteToLineStart(); + return; + } + + if (kb.matches(data, "tui.editor.deleteToLineEnd")) { + this.deleteToLineEnd(); + return; + } + + // Kill ring actions + if (kb.matches(data, "tui.editor.yank")) { + this.yank(); + return; + } + if (kb.matches(data, "tui.editor.yankPop")) { + this.yankPop(); + return; + } + + // Cursor movement + if (kb.matches(data, "tui.editor.cursorLeft")) { + this.lastAction = null; + if (this.cursor > 0) { + const beforeCursor = this.value.slice(0, this.cursor); + const graphemes = [...segmenter.segment(beforeCursor)]; + const lastGrapheme = graphemes[graphemes.length - 1]; + this.cursor -= lastGrapheme ? lastGrapheme.segment.length : 1; + } + return; + } + + if (kb.matches(data, "tui.editor.cursorRight")) { + this.lastAction = null; + if (this.cursor < this.value.length) { + const afterCursor = this.value.slice(this.cursor); + const graphemes = [...segmenter.segment(afterCursor)]; + const firstGrapheme = graphemes[0]; + this.cursor += firstGrapheme ? firstGrapheme.segment.length : 1; + } + return; + } + + if (kb.matches(data, "tui.editor.cursorLineStart")) { + this.lastAction = null; + this.cursor = 0; + return; + } + + if (kb.matches(data, "tui.editor.cursorLineEnd")) { + this.lastAction = null; + this.cursor = this.value.length; + return; + } + + if (kb.matches(data, "tui.editor.cursorWordLeft")) { + this.moveWordBackwards(); + return; + } + + if (kb.matches(data, "tui.editor.cursorWordRight")) { + this.moveWordForwards(); + return; + } + + // Kitty CSI-u printable character (e.g. \x1b[97u for 'a'). + // Terminals with Kitty protocol flag 1 (disambiguate) send CSI-u for all keys, + // including plain printable characters. Decode before the control-char check + // since CSI-u sequences contain \x1b which would be rejected. + const kittyPrintable = decodeKittyPrintable(data); + if (kittyPrintable !== undefined) { + this.insertCharacter(kittyPrintable); + return; + } + + // Regular character input - accept printable characters including Unicode, + // but reject control characters (C0: 0x00-0x1F, DEL: 0x7F, C1: 0x80-0x9F) + const hasControlChars = [...data].some((ch) => { + const code = ch.charCodeAt(0); + return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f); + }); + if (!hasControlChars) { + this.insertCharacter(data); + } + } + + private insertCharacter(char: string): void { + // Undo coalescing: consecutive word chars coalesce into one undo unit + if (isWhitespaceChar(char) || this.lastAction !== "type-word") { + this.pushUndo(); + } + this.lastAction = "type-word"; + + this.value = this.value.slice(0, this.cursor) + char + this.value.slice(this.cursor); + this.cursor += char.length; + } + + private handleBackspace(): void { + this.lastAction = null; + if (this.cursor > 0) { + this.pushUndo(); + const beforeCursor = this.value.slice(0, this.cursor); + const graphemes = [...segmenter.segment(beforeCursor)]; + const lastGrapheme = graphemes[graphemes.length - 1]; + const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; + this.value = this.value.slice(0, this.cursor - graphemeLength) + this.value.slice(this.cursor); + this.cursor -= graphemeLength; + } + } + + private handleForwardDelete(): void { + this.lastAction = null; + if (this.cursor < this.value.length) { + this.pushUndo(); + const afterCursor = this.value.slice(this.cursor); + const graphemes = [...segmenter.segment(afterCursor)]; + const firstGrapheme = graphemes[0]; + const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; + this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength); + } + } + + private deleteToLineStart(): void { + if (this.cursor === 0) return; + this.pushUndo(); + const deletedText = this.value.slice(0, this.cursor); + this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" }); + this.lastAction = "kill"; + this.value = this.value.slice(this.cursor); + this.cursor = 0; + } + + private deleteToLineEnd(): void { + if (this.cursor >= this.value.length) return; + this.pushUndo(); + const deletedText = this.value.slice(this.cursor); + this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" }); + this.lastAction = "kill"; + this.value = this.value.slice(0, this.cursor); + } + + private deleteWordBackwards(): void { + if (this.cursor === 0) return; + + // Save lastAction before cursor movement (moveWordBackwards resets it) + const wasKill = this.lastAction === "kill"; + + this.pushUndo(); + + const oldCursor = this.cursor; + this.moveWordBackwards(); + const deleteFrom = this.cursor; + this.cursor = oldCursor; + + const deletedText = this.value.slice(deleteFrom, this.cursor); + this.killRing.push(deletedText, { prepend: true, accumulate: wasKill }); + this.lastAction = "kill"; + + this.value = this.value.slice(0, deleteFrom) + this.value.slice(this.cursor); + this.cursor = deleteFrom; + } + + private deleteWordForward(): void { + if (this.cursor >= this.value.length) return; + + // Save lastAction before cursor movement (moveWordForwards resets it) + const wasKill = this.lastAction === "kill"; + + this.pushUndo(); + + const oldCursor = this.cursor; + this.moveWordForwards(); + const deleteTo = this.cursor; + this.cursor = oldCursor; + + const deletedText = this.value.slice(this.cursor, deleteTo); + this.killRing.push(deletedText, { prepend: false, accumulate: wasKill }); + this.lastAction = "kill"; + + this.value = this.value.slice(0, this.cursor) + this.value.slice(deleteTo); + } + + private yank(): void { + const text = this.killRing.peek(); + if (!text) return; + + this.pushUndo(); + + this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor); + this.cursor += text.length; + this.lastAction = "yank"; + } + + private yankPop(): void { + if (this.lastAction !== "yank" || this.killRing.length <= 1) return; + + this.pushUndo(); + + // Delete the previously yanked text (still at end of ring before rotation) + const prevText = this.killRing.peek() || ""; + this.value = this.value.slice(0, this.cursor - prevText.length) + this.value.slice(this.cursor); + this.cursor -= prevText.length; + + // Rotate and insert new entry + this.killRing.rotate(); + const text = this.killRing.peek() || ""; + this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor); + this.cursor += text.length; + this.lastAction = "yank"; + } + + private pushUndo(): void { + this.undoStack.push({ value: this.value, cursor: this.cursor }); + } + + private undo(): void { + const snapshot = this.undoStack.pop(); + if (!snapshot) return; + this.value = snapshot.value; + this.cursor = snapshot.cursor; + this.lastAction = null; + } + + private moveWordBackwards(): void { + if (this.cursor === 0) return; + this.lastAction = null; + this.cursor = findWordBackward(this.value, this.cursor); + } + + private moveWordForwards(): void { + if (this.cursor >= this.value.length) return; + this.lastAction = null; + this.cursor = findWordForward(this.value, this.cursor); + } + + private handlePaste(pastedText: string): void { + this.lastAction = null; + this.pushUndo(); + + // Clean the pasted text - remove newlines and carriage returns + const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "").replace(/\t/g, " "); + + // Insert at cursor position + this.value = this.value.slice(0, this.cursor) + cleanText + this.value.slice(this.cursor); + this.cursor += cleanText.length; + } + + invalidate(): void { + // No cached state to invalidate currently + } + + render(width: number): string[] { + // Calculate visible window + const prompt = "> "; + const availableWidth = width - prompt.length; + + if (availableWidth <= 0) { + return [prompt]; + } + + let visibleText = ""; + let cursorDisplay = this.cursor; + const totalWidth = visibleWidth(this.value); + + if (totalWidth < availableWidth) { + // Everything fits (leave room for cursor at end) + visibleText = this.value; + } else { + // Need horizontal scrolling + // Reserve one column for cursor if it's at the end + const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth; + const cursorCol = visibleWidth(this.value.slice(0, this.cursor)); + + if (scrollWidth > 0) { + const halfWidth = Math.floor(scrollWidth / 2); + let startCol = 0; + + if (cursorCol < halfWidth) { + // Cursor near start + startCol = 0; + } else if (cursorCol > totalWidth - halfWidth) { + // Cursor near end + startCol = Math.max(0, totalWidth - scrollWidth); + } else { + // Cursor in middle + startCol = Math.max(0, cursorCol - halfWidth); + } + + visibleText = sliceByColumn(this.value, startCol, scrollWidth, true); + const beforeCursor = sliceByColumn(this.value, startCol, Math.max(0, cursorCol - startCol), true); + cursorDisplay = beforeCursor.length; + } else { + visibleText = ""; + cursorDisplay = 0; + } + } + + // Build line with fake cursor + // Insert cursor character at cursor position + const graphemes = [...segmenter.segment(visibleText.slice(cursorDisplay))]; + const cursorGrapheme = graphemes[0]; + + const beforeCursor = visibleText.slice(0, cursorDisplay); + const atCursor = cursorGrapheme?.segment ?? " "; // Character at cursor, or space if at end + const afterCursor = visibleText.slice(cursorDisplay + atCursor.length); + + // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning) + const marker = this.focused ? CURSOR_MARKER : ""; + + // Use inverse video to show cursor + const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal + const textWithCursor = beforeCursor + marker + cursorChar + afterCursor; + + // Calculate visual width + const visualLength = visibleWidth(textWithCursor); + const padding = " ".repeat(Math.max(0, availableWidth - visualLength)); + const line = prompt + textWithCursor + padding; + + return [line]; + } +} diff --git a/packages/pi-tui/src/components/loader.ts b/packages/pi-tui/src/components/loader.ts new file mode 100644 index 000000000..9467ccf61 --- /dev/null +++ b/packages/pi-tui/src/components/loader.ts @@ -0,0 +1,92 @@ +import type { TUI } from "../tui.ts"; +import { Text } from "./text.ts"; + +export interface LoaderIndicatorOptions { + /** Animation frames. Use an empty array to hide the indicator. */ + frames?: string[]; + /** Frame interval in milliseconds for animated indicators. */ + intervalMs?: number; +} + +const DEFAULT_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const DEFAULT_INTERVAL_MS = 80; + +/** + * Loader component that updates with an optional spinning animation. + */ +export class Loader extends Text { + private frames = [...DEFAULT_FRAMES]; + private intervalMs = DEFAULT_INTERVAL_MS; + private currentFrame = 0; + private intervalId: NodeJS.Timeout | null = null; + private ui: TUI | null = null; + private renderIndicatorVerbatim = false; + private spinnerColorFn: (str: string) => string; + private messageColorFn: (str: string) => string; + private message: string = "Loading..."; + + constructor( + ui: TUI, + spinnerColorFn: (str: string) => string, + messageColorFn: (str: string) => string, + message: string = "Loading...", + indicator?: LoaderIndicatorOptions, + ) { + super("", 1, 0); + this.ui = ui; + this.spinnerColorFn = spinnerColorFn; + this.messageColorFn = messageColorFn; + this.message = message; + this.setIndicator(indicator); + } + + override render(width: number): string[] { + return ["", ...super.render(width)]; + } + + start(): void { + this.updateDisplay(); + this.restartAnimation(); + } + + stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + setMessage(message: string): void { + this.message = message; + this.updateDisplay(); + } + + setIndicator(indicator?: LoaderIndicatorOptions): void { + this.renderIndicatorVerbatim = indicator !== undefined; + this.frames = indicator?.frames !== undefined ? [...indicator.frames] : [...DEFAULT_FRAMES]; + this.intervalMs = indicator?.intervalMs && indicator.intervalMs > 0 ? indicator.intervalMs : DEFAULT_INTERVAL_MS; + this.currentFrame = 0; + this.start(); + } + + private restartAnimation(): void { + this.stop(); + if (this.frames.length <= 1) { + return; + } + this.intervalId = setInterval(() => { + this.currentFrame = (this.currentFrame + 1) % this.frames.length; + this.updateDisplay(); + }, this.intervalMs); + } + + private updateDisplay(): void { + const frame = this.frames[this.currentFrame] ?? ""; + const renderedFrame = this.renderIndicatorVerbatim ? frame : this.spinnerColorFn(frame); + const indicator = frame.length > 0 ? `${renderedFrame} ` : ""; + this.setText(`${indicator}${this.messageColorFn(this.message)}`); + if (this.ui) { + this.ui.requestRender(); + } + } +} diff --git a/packages/pi-tui/src/components/markdown.ts b/packages/pi-tui/src/components/markdown.ts new file mode 100644 index 000000000..9231cd456 --- /dev/null +++ b/packages/pi-tui/src/components/markdown.ts @@ -0,0 +1,858 @@ +import { Marked, type Token, Tokenizer, type Tokens } from "marked"; +import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.ts"; +import type { Component } from "../tui.ts"; +import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts"; + +const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/; + +class StrictStrikethroughTokenizer extends Tokenizer { + override del(src: string): Tokens.Del | undefined { + const match = STRICT_STRIKETHROUGH_REGEX.exec(src); + if (!match) { + return undefined; + } + + const text = match[2]!; + return { + type: "del", + raw: match[0], + text, + tokens: this.lexer.inlineTokens(text), + }; + } +} + +function trimPartialClosingFences(tokens: readonly Token[]): void { + const token = tokens[tokens.length - 1]; + if (token?.type === "list") { + trimPartialClosingFences(token.items[token.items.length - 1]?.tokens ?? []); + return; + } + if (token?.type === "blockquote") { + trimPartialClosingFences(token.tokens ?? []); + return; + } + if (token?.type !== "code") { + return; + } + + // Trim streamed partial closing fences so code blocks do not shrink/flicker + // when the final fence character arrives. See https://github.com/earendil-works/pi/issues/5825. + const marker = /^(`{3,}|~{3,})/.exec(token.raw)?.[1]; + const lastLine = token.raw.split("\n").pop(); + if (!marker || !lastLine || lastLine.length >= marker.length || lastLine !== marker[0]?.repeat(lastLine.length)) { + return; + } + + token.text = token.text.slice(0, -lastLine.length).replace(/\n$/, ""); +} + +const markdownParser = new Marked(); +markdownParser.setOptions({ + tokenizer: new StrictStrikethroughTokenizer(), +}); + +/** + * Default text styling for markdown content. + * Applied to all text unless overridden by markdown formatting. + */ +export interface DefaultTextStyle { + /** Foreground color function */ + color?: (text: string) => string; + /** Background color function */ + bgColor?: (text: string) => string; + /** Bold text */ + bold?: boolean; + /** Italic text */ + italic?: boolean; + /** Strikethrough text */ + strikethrough?: boolean; + /** Underline text */ + underline?: boolean; +} + +/** + * Theme functions for markdown elements. + * Each function takes text and returns styled text with ANSI codes. + */ +export interface MarkdownTheme { + heading: (text: string) => string; + link: (text: string) => string; + linkUrl: (text: string) => string; + code: (text: string) => string; + codeBlock: (text: string) => string; + codeBlockBorder: (text: string) => string; + quote: (text: string) => string; + quoteBorder: (text: string) => string; + hr: (text: string) => string; + listBullet: (text: string) => string; + bold: (text: string) => string; + italic: (text: string) => string; + strikethrough: (text: string) => string; + underline: (text: string) => string; + highlightCode?: (code: string, lang?: string) => string[]; + /** Prefix applied to each rendered code block line (default: " ") */ + codeBlockIndent?: string; +} + +export interface MarkdownOptions { + /** Preserve source list markers instead of normalizing them. */ + preserveOrderedListMarkers?: boolean; + /** Preserve source backslash escapes instead of normalizing escaped punctuation. */ + preserveBackslashEscapes?: boolean; +} + +interface InlineStyleContext { + applyText: (text: string) => string; + stylePrefix: string; +} + +export class Markdown implements Component { + private text: string; + private paddingX: number; // Left/right padding + private paddingY: number; // Top/bottom padding + private defaultTextStyle?: DefaultTextStyle; + private theme: MarkdownTheme; + private options: MarkdownOptions; + private defaultStylePrefix?: string; + + // Cache for rendered output + private cachedText?: string; + private cachedWidth?: number; + private cachedLines?: string[]; + + constructor( + text: string, + paddingX: number, + paddingY: number, + theme: MarkdownTheme, + defaultTextStyle?: DefaultTextStyle, + options?: MarkdownOptions, + ) { + this.text = text; + this.paddingX = paddingX; + this.paddingY = paddingY; + this.theme = theme; + this.defaultTextStyle = defaultTextStyle; + this.options = options ? { ...options } : {}; + } + + setText(text: string): void { + this.text = text; + this.invalidate(); + } + + invalidate(): void { + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + render(width: number): string[] { + // Check cache + if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) { + return this.cachedLines; + } + + // Calculate available width for content (subtract horizontal padding) + const contentWidth = Math.max(1, width - this.paddingX * 2); + + // Don't render anything if there's no actual text + if (!this.text || this.text.trim() === "") { + const result: string[] = []; + // Update cache + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = result; + return result; + } + + // Replace tabs with 3 spaces for consistent rendering + const normalizedText = this.text.replace(/\t/g, " "); + + // Parse markdown to HTML-like tokens + const tokens = markdownParser.lexer(normalizedText); + trimPartialClosingFences(tokens); + + // Convert tokens to styled terminal output + const renderedLines: string[] = []; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]!; + const nextToken = tokens[i + 1]; + const tokenLines = this.renderToken(token, contentWidth, nextToken?.type); + for (const tokenLine of tokenLines) { + renderedLines.push(tokenLine); + } + } + + // Wrap lines (NO padding, NO background yet) + const wrappedLines: string[] = []; + for (const line of renderedLines) { + if (isImageLine(line)) { + wrappedLines.push(line); + } else { + for (const wrappedLine of wrapTextWithAnsi(line, contentWidth)) { + wrappedLines.push(wrappedLine); + } + } + } + + // Add margins and background to each wrapped line + const leftMargin = " ".repeat(this.paddingX); + const rightMargin = " ".repeat(this.paddingX); + const bgFn = this.defaultTextStyle?.bgColor; + const contentLines: string[] = []; + + for (const line of wrappedLines) { + if (isImageLine(line)) { + contentLines.push(line); + continue; + } + + const lineWithMargins = leftMargin + line + rightMargin; + + if (bgFn) { + contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn)); + } else { + // No background - just pad to width + const visibleLen = visibleWidth(lineWithMargins); + const paddingNeeded = Math.max(0, width - visibleLen); + contentLines.push(lineWithMargins + " ".repeat(paddingNeeded)); + } + } + + // Add top/bottom padding (empty lines) + const emptyLine = " ".repeat(width); + const emptyLines: string[] = []; + for (let i = 0; i < this.paddingY; i++) { + const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine; + emptyLines.push(line); + } + + // Combine top padding, content, and bottom padding + const result = emptyLines.concat(contentLines, emptyLines); + + // Update cache + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = result; + + return result.length > 0 ? result : [""]; + } + + /** + * Apply default text style to a string. + * This is the base styling applied to all text content. + * NOTE: Background color is NOT applied here - it's applied at the padding stage + * to ensure it extends to the full line width. + */ + private applyDefaultStyle(text: string): string { + if (!this.defaultTextStyle) { + return text; + } + + let styled = text; + + // Apply foreground color (NOT background - that's applied at padding stage) + if (this.defaultTextStyle.color) { + styled = this.defaultTextStyle.color(styled); + } + + // Apply text decorations using this.theme + if (this.defaultTextStyle.bold) { + styled = this.theme.bold(styled); + } + if (this.defaultTextStyle.italic) { + styled = this.theme.italic(styled); + } + if (this.defaultTextStyle.strikethrough) { + styled = this.theme.strikethrough(styled); + } + if (this.defaultTextStyle.underline) { + styled = this.theme.underline(styled); + } + + return styled; + } + + private getDefaultStylePrefix(): string { + if (!this.defaultTextStyle) { + return ""; + } + + if (this.defaultStylePrefix !== undefined) { + return this.defaultStylePrefix; + } + + const sentinel = "\u0000"; + let styled = sentinel; + + if (this.defaultTextStyle.color) { + styled = this.defaultTextStyle.color(styled); + } + + if (this.defaultTextStyle.bold) { + styled = this.theme.bold(styled); + } + if (this.defaultTextStyle.italic) { + styled = this.theme.italic(styled); + } + if (this.defaultTextStyle.strikethrough) { + styled = this.theme.strikethrough(styled); + } + if (this.defaultTextStyle.underline) { + styled = this.theme.underline(styled); + } + + const sentinelIndex = styled.indexOf(sentinel); + this.defaultStylePrefix = sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : ""; + return this.defaultStylePrefix; + } + + private getStylePrefix(styleFn: (text: string) => string): string { + const sentinel = "\u0000"; + const styled = styleFn(sentinel); + const sentinelIndex = styled.indexOf(sentinel); + return sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : ""; + } + + private getDefaultInlineStyleContext(): InlineStyleContext { + return { + applyText: (text: string) => this.applyDefaultStyle(text), + stylePrefix: this.getDefaultStylePrefix(), + }; + } + + private renderToken( + token: Token, + width: number, + nextTokenType?: string, + styleContext?: InlineStyleContext, + ): string[] { + const lines: string[] = []; + + switch (token.type) { + case "heading": { + const headingLevel = token.depth; + const headingPrefix = `${"#".repeat(headingLevel)} `; + + // Build a heading-specific style context so inline tokens (codespan, bold, etc.) + // restore heading styling after their own ANSI resets instead of falling back to + // the default text style. + let headingStyleFn: (text: string) => string; + if (headingLevel === 1) { + headingStyleFn = (text: string) => this.theme.heading(this.theme.bold(this.theme.underline(text))); + } else { + headingStyleFn = (text: string) => this.theme.heading(this.theme.bold(text)); + } + + const headingStyleContext: InlineStyleContext = { + applyText: headingStyleFn, + stylePrefix: this.getStylePrefix(headingStyleFn), + }; + + const headingText = this.renderInlineTokens(token.tokens || [], headingStyleContext); + const styledHeading = headingLevel >= 3 ? headingStyleFn(headingPrefix) + headingText : headingText; + lines.push(styledHeading); + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); // Add spacing after headings (unless space token follows) + } + break; + } + + case "paragraph": { + const paragraphText = this.renderInlineTokens(token.tokens || [], styleContext); + lines.push(paragraphText); + // Don't add spacing if next token is space or list + if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") { + lines.push(""); + } + break; + } + + case "text": + lines.push(this.renderInlineTokens([token], styleContext)); + break; + + case "code": { + const indent = this.theme.codeBlockIndent ?? " "; + lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); + if (this.theme.highlightCode) { + const highlightedLines = this.theme.highlightCode(token.text, token.lang); + for (const hlLine of highlightedLines) { + lines.push(`${indent}${hlLine}`); + } + } else { + // Split code by newlines and style each line + const codeLines = token.text.split("\n"); + for (const codeLine of codeLines) { + lines.push(`${indent}${this.theme.codeBlock(codeLine)}`); + } + } + lines.push(this.theme.codeBlockBorder("```")); + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); // Add spacing after code blocks (unless space token follows) + } + break; + } + + case "list": { + const listLines = this.renderList(token as Tokens.List, 0, width, styleContext); + lines.push(...listLines); + // Don't add spacing after lists if a space token follows + // (the space token will handle it) + break; + } + + case "table": { + const tableLines = this.renderTable(token as Tokens.Table, width, nextTokenType, styleContext); + lines.push(...tableLines); + break; + } + + case "blockquote": { + const quoteStyle = (text: string) => this.theme.quote(this.theme.italic(text)); + const quoteStylePrefix = this.getStylePrefix(quoteStyle); + const applyQuoteStyle = (line: string): string => { + if (!quoteStylePrefix) { + return quoteStyle(line); + } + const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`); + return quoteStyle(lineWithReappliedStyle); + }; + + // Calculate available width for quote content (subtract border "│ " = 2 chars) + const quoteContentWidth = Math.max(1, width - 2); + + // Blockquotes contain block-level tokens (paragraph, list, code, etc.), so render + // children with renderToken() instead of renderInlineTokens(). + // Default message style should not apply inside blockquotes. + const quoteInlineStyleContext: InlineStyleContext = { + applyText: (text: string) => text, + stylePrefix: quoteStylePrefix, + }; + const quoteTokens = token.tokens || []; + const renderedQuoteLines: string[] = []; + for (let i = 0; i < quoteTokens.length; i++) { + const quoteToken = quoteTokens[i]!; + const nextQuoteToken = quoteTokens[i + 1]; + renderedQuoteLines.push( + ...this.renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext), + ); + } + + // Avoid rendering an extra empty quote line before the outer blockquote spacing. + while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") { + renderedQuoteLines.pop(); + } + + for (const quoteLine of renderedQuoteLines) { + const styledLine = applyQuoteStyle(quoteLine); + const wrappedLines = wrapTextWithAnsi(styledLine, quoteContentWidth); + for (const wrappedLine of wrappedLines) { + lines.push(this.theme.quoteBorder("│ ") + wrappedLine); + } + } + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); // Add spacing after blockquotes (unless space token follows) + } + break; + } + + case "hr": + lines.push(this.theme.hr("─".repeat(Math.min(width, 80)))); + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); // Add spacing after horizontal rules (unless space token follows) + } + break; + + case "html": + // Render HTML as plain text (escaped for terminal) + if ("raw" in token && typeof token.raw === "string") { + lines.push(this.applyDefaultStyle(token.raw.trim())); + } + break; + + case "space": + // Space tokens represent blank lines in markdown + lines.push(""); + break; + + default: + // Handle any other token types as plain text + if ("text" in token && typeof token.text === "string") { + lines.push(token.text); + } + } + + return lines; + } + + private renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string { + let result = ""; + const resolvedStyleContext = styleContext ?? this.getDefaultInlineStyleContext(); + const { applyText, stylePrefix } = resolvedStyleContext; + const applyTextWithNewlines = (text: string): string => { + const segments: string[] = text.split("\n"); + return segments.map((segment: string) => applyText(segment)).join("\n"); + }; + + for (const token of tokens) { + switch (token.type) { + case "escape": + result += applyTextWithNewlines(this.options.preserveBackslashEscapes ? token.raw : token.text); + break; + + case "text": + // Text tokens in list items can have nested tokens for inline formatting + if (token.tokens && token.tokens.length > 0) { + result += this.renderInlineTokens(token.tokens, resolvedStyleContext); + } else { + result += applyTextWithNewlines(token.text); + } + break; + + case "paragraph": + // Paragraph tokens contain nested inline tokens + result += this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + break; + + case "strong": { + const boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.theme.bold(boldContent) + stylePrefix; + break; + } + + case "em": { + const italicContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.theme.italic(italicContent) + stylePrefix; + break; + } + + case "codespan": + result += this.theme.code(token.text) + stylePrefix; + break; + + case "link": { + const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + const styledLink = this.theme.link(this.theme.underline(linkText)); + if (getCapabilities().hyperlinks) { + // OSC 8: render as a clickable hyperlink. The URL is not printed inline, + // so we always show only the link text regardless of whether it matches href. + result += hyperlink(styledLink, token.href) + stylePrefix; + } else { + // Fallback: print URL in parentheses when text differs from href. + // Compare raw token.text (not styled) against href for the equality check. + // For mailto: links strip the prefix (autolinked emails use text="foo@bar.com" + // but href="mailto:foo@bar.com"). + const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href; + if (token.text === token.href || token.text === hrefForComparison) { + result += styledLink + stylePrefix; + } else { + result += styledLink + this.theme.linkUrl(` (${token.href})`) + stylePrefix; + } + } + break; + } + + case "br": + result += "\n"; + break; + + case "del": { + const delContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.theme.strikethrough(delContent) + stylePrefix; + break; + } + + case "html": + // Render inline HTML as plain text + if ("raw" in token && typeof token.raw === "string") { + result += applyTextWithNewlines(token.raw); + } + break; + + default: + // Handle any other inline token types as plain text + if ("text" in token && typeof token.text === "string") { + result += applyTextWithNewlines(token.text); + } + } + } + + while (stylePrefix && result.endsWith(stylePrefix)) { + result = result.slice(0, -stylePrefix.length); + } + + return result; + } + + private getOrderedListMarker(item: Tokens.ListItem): string | undefined { + const match = /^(?: {0,3})(\d{1,9}[.)])[ \t]+/.exec(item.raw); + return match ? `${match[1]} ` : undefined; + } + + private getUnorderedListMarker(item: Tokens.ListItem): string | undefined { + const match = /^(?: {0,3})([-+*])(?:[ \t]+|(?=\r?\n|$))/.exec(item.raw); + return match ? `${match[1]} ` : undefined; + } + + /** + * Render a list with proper nesting support + */ + private renderList(token: Tokens.List, depth: number, width: number, styleContext?: InlineStyleContext): string[] { + const lines: string[] = []; + const indent = " ".repeat(depth); + // Use the list's start property (defaults to 1 for ordered lists) + const startNumber = typeof token.start === "number" ? token.start : 1; + + for (let i = 0; i < token.items.length; i++) { + const item = token.items[i]!; + const isLastItem = i === token.items.length - 1; + const bullet = token.ordered + ? this.options.preserveOrderedListMarkers + ? (this.getOrderedListMarker(item) ?? `${startNumber + i}. `) + : `${startNumber + i}. ` + : this.options.preserveOrderedListMarkers + ? (this.getUnorderedListMarker(item) ?? "- ") + : "- "; + const taskMarker = item.task ? `[${item.checked ? "x" : " "}] ` : ""; + const marker = bullet + taskMarker; + const firstPrefix = indent + this.theme.listBullet(marker); + const continuationPrefix = indent + " ".repeat(visibleWidth(marker)); + const itemWidth = Math.max(1, width - visibleWidth(firstPrefix)); + let renderedAnyLine = false; + + for (const itemToken of item.tokens) { + if (itemToken.type === "list") { + lines.push(...this.renderList(itemToken as Tokens.List, depth + 1, width, styleContext)); + renderedAnyLine = true; + continue; + } + + const itemLines = this.renderToken(itemToken, itemWidth, undefined, styleContext); + for (const line of itemLines) { + for (const wrappedLine of wrapTextWithAnsi(line, itemWidth)) { + const linePrefix = renderedAnyLine ? continuationPrefix : firstPrefix; + lines.push(linePrefix + wrappedLine); + renderedAnyLine = true; + } + } + } + + if (!renderedAnyLine) { + lines.push(firstPrefix); + } + + if (token.loose && !isLastItem) { + lines.push(""); + } + } + + return lines; + } + + /** + * Get the visible width of the longest word in a string. + */ + private getLongestWordWidth(text: string, maxWidth?: number): number { + const words = text.split(/\s+/).filter((word) => word.length > 0); + let longest = 0; + for (const word of words) { + longest = Math.max(longest, visibleWidth(word)); + } + if (maxWidth === undefined) { + return longest; + } + return Math.min(longest, maxWidth); + } + + /** + * Wrap a table cell to fit into a column. + * + * Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled + * consistently with the rest of the renderer. + */ + private wrapCellText(text: string, maxWidth: number): string[] { + return wrapTextWithAnsi(text, Math.max(1, maxWidth)); + } + + /** + * Render a table with width-aware cell wrapping. + * Cells that don't fit are wrapped to multiple lines. + */ + private renderTable( + token: Tokens.Table, + availableWidth: number, + nextTokenType?: string, + styleContext?: InlineStyleContext, + ): string[] { + const lines: string[] = []; + const numCols = token.header.length; + + if (numCols === 0) { + return lines; + } + + // Calculate border overhead: "│ " + (n-1) * " │ " + " │" + // = 2 + (n-1) * 3 + 2 = 3n + 1 + const borderOverhead = 3 * numCols + 1; + const availableForCells = availableWidth - borderOverhead; + if (availableForCells < numCols) { + // Too narrow to render a stable table. Fall back to raw markdown. + const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : []; + if (nextTokenType && nextTokenType !== "space") { + fallbackLines.push(""); + } + return fallbackLines; + } + + const maxUnbrokenWordWidth = 30; + + // Calculate natural column widths (what each column needs without constraints) + const naturalWidths: number[] = []; + const minWordWidths: number[] = []; + for (let i = 0; i < numCols; i++) { + const headerText = this.renderInlineTokens(token.header[i]!.tokens || [], styleContext); + naturalWidths[i] = visibleWidth(headerText); + minWordWidths[i] = Math.max(1, this.getLongestWordWidth(headerText, maxUnbrokenWordWidth)); + } + for (const row of token.rows) { + for (let i = 0; i < row.length; i++) { + const cellText = this.renderInlineTokens(row[i]!.tokens || [], styleContext); + naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText)); + minWordWidths[i] = Math.max( + minWordWidths[i] || 1, + this.getLongestWordWidth(cellText, maxUnbrokenWordWidth), + ); + } + } + + let minColumnWidths = minWordWidths; + let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0); + + if (minCellsWidth > availableForCells) { + minColumnWidths = new Array(numCols).fill(1); + const remaining = availableForCells - numCols; + + if (remaining > 0) { + const totalWeight = minWordWidths.reduce((total, width) => total + Math.max(0, width - 1), 0); + const growth = minWordWidths.map((width) => { + const weight = Math.max(0, width - 1); + return totalWeight > 0 ? Math.floor((weight / totalWeight) * remaining) : 0; + }); + + for (let i = 0; i < numCols; i++) { + minColumnWidths[i]! += growth[i] ?? 0; + } + + const allocated = growth.reduce((total, width) => total + width, 0); + let leftover = remaining - allocated; + for (let i = 0; leftover > 0 && i < numCols; i++) { + minColumnWidths[i]!++; + leftover--; + } + } + + minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0); + } + + // Calculate column widths that fit within available width + const totalNaturalWidth = naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead; + let columnWidths: number[]; + + if (totalNaturalWidth <= availableWidth) { + // Everything fits naturally + columnWidths = naturalWidths.map((width, index) => Math.max(width, minColumnWidths[index]!)); + } else { + // Need to shrink columns to fit + const totalGrowPotential = naturalWidths.reduce((total, width, index) => { + return total + Math.max(0, width - minColumnWidths[index]!); + }, 0); + const extraWidth = Math.max(0, availableForCells - minCellsWidth); + columnWidths = minColumnWidths.map((minWidth, index) => { + const naturalWidth = naturalWidths[index]!; + const minWidthDelta = Math.max(0, naturalWidth - minWidth); + let grow = 0; + if (totalGrowPotential > 0) { + grow = Math.floor((minWidthDelta / totalGrowPotential) * extraWidth); + } + return minWidth + grow; + }); + + // Adjust for rounding errors - distribute remaining space + const allocated = columnWidths.reduce((a, b) => a + b, 0); + let remaining = availableForCells - allocated; + while (remaining > 0) { + let grew = false; + for (let i = 0; i < numCols && remaining > 0; i++) { + if (columnWidths[i]! < naturalWidths[i]!) { + columnWidths[i]!++; + remaining--; + grew = true; + } + } + if (!grew) { + break; + } + } + } + + // Render top border + const topBorderCells = columnWidths.map((w) => "─".repeat(w)); + lines.push(`┌─${topBorderCells.join("─┬─")}─┐`); + + // Render header with wrapping + const headerCellLines: string[][] = token.header.map((cell, i) => { + const text = this.renderInlineTokens(cell.tokens || [], styleContext); + return this.wrapCellText(text, columnWidths[i]!); + }); + const headerLineCount = Math.max(...headerCellLines.map((c) => c.length)); + + for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) { + const rowParts = headerCellLines.map((cellLines, colIdx) => { + const text = cellLines[lineIdx] || ""; + const padded = text + " ".repeat(Math.max(0, columnWidths[colIdx]! - visibleWidth(text))); + return this.theme.bold(padded); + }); + lines.push(`│ ${rowParts.join(" │ ")} │`); + } + + // Render separator + const separatorCells = columnWidths.map((w) => "─".repeat(w)); + const separatorLine = `├─${separatorCells.join("─┼─")}─┤`; + lines.push(separatorLine); + + // Render rows with wrapping + for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) { + const row = token.rows[rowIndex]!; + const rowCellLines: string[][] = row.map((cell, i) => { + const text = this.renderInlineTokens(cell.tokens || [], styleContext); + return this.wrapCellText(text, columnWidths[i]!); + }); + const rowLineCount = Math.max(...rowCellLines.map((c) => c.length)); + + for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) { + const rowParts = rowCellLines.map((cellLines, colIdx) => { + const text = cellLines[lineIdx] || ""; + return text + " ".repeat(Math.max(0, columnWidths[colIdx]! - visibleWidth(text))); + }); + lines.push(`│ ${rowParts.join(" │ ")} │`); + } + + if (rowIndex < token.rows.length - 1) { + lines.push(separatorLine); + } + } + + // Render bottom border + const bottomBorderCells = columnWidths.map((w) => "─".repeat(w)); + lines.push(`└─${bottomBorderCells.join("─┴─")}─┘`); + + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); // Add spacing after table + } + return lines; + } +} diff --git a/packages/pi-tui/src/components/select-list.ts b/packages/pi-tui/src/components/select-list.ts new file mode 100644 index 000000000..26fdb685a --- /dev/null +++ b/packages/pi-tui/src/components/select-list.ts @@ -0,0 +1,229 @@ +import { getKeybindings } from "../keybindings.ts"; +import type { Component } from "../tui.ts"; +import { truncateToWidth, visibleWidth } from "../utils.ts"; + +const DEFAULT_PRIMARY_COLUMN_WIDTH = 32; +const PRIMARY_COLUMN_GAP = 2; +const MIN_DESCRIPTION_WIDTH = 10; + +const normalizeToSingleLine = (text: string): string => text.replace(/[\r\n]+/g, " ").trim(); +const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max)); + +export interface SelectItem { + value: string; + label: string; + description?: string; +} + +export interface SelectListTheme { + selectedPrefix: (text: string) => string; + selectedText: (text: string) => string; + description: (text: string) => string; + scrollInfo: (text: string) => string; + noMatch: (text: string) => string; +} + +export interface SelectListTruncatePrimaryContext { + text: string; + maxWidth: number; + columnWidth: number; + item: SelectItem; + isSelected: boolean; +} + +export interface SelectListLayoutOptions { + minPrimaryColumnWidth?: number; + maxPrimaryColumnWidth?: number; + truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string; +} + +export class SelectList implements Component { + private items: SelectItem[] = []; + private filteredItems: SelectItem[] = []; + private selectedIndex: number = 0; + private maxVisible: number = 5; + private theme: SelectListTheme; + private layout: SelectListLayoutOptions; + + public onSelect?: (item: SelectItem) => void; + public onCancel?: () => void; + public onSelectionChange?: (item: SelectItem) => void; + + constructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme, layout: SelectListLayoutOptions = {}) { + this.items = items; + this.filteredItems = items; + this.maxVisible = maxVisible; + this.theme = theme; + this.layout = layout; + } + + setFilter(filter: string): void { + this.filteredItems = this.items.filter((item) => item.value.toLowerCase().startsWith(filter.toLowerCase())); + // Reset selection when filter changes + this.selectedIndex = 0; + } + + setSelectedIndex(index: number): void { + this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1)); + } + + invalidate(): void { + // No cached state to invalidate currently + } + + render(width: number): string[] { + const lines: string[] = []; + + // If no items match filter, show message + if (this.filteredItems.length === 0) { + lines.push(this.theme.noMatch(" No matching commands")); + return lines; + } + + const primaryColumnWidth = this.getPrimaryColumnWidth(); + + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible), + ); + const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length); + + // Render visible items + for (let i = startIndex; i < endIndex; i++) { + const item = this.filteredItems[i]; + if (!item) continue; + + const isSelected = i === this.selectedIndex; + const descriptionSingleLine = item.description ? normalizeToSingleLine(item.description) : undefined; + lines.push(this.renderItem(item, isSelected, width, descriptionSingleLine, primaryColumnWidth)); + } + + // Add scroll indicators if needed + if (startIndex > 0 || endIndex < this.filteredItems.length) { + const scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`; + // Truncate if too long for terminal + lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, ""))); + } + + return lines; + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + // Up arrow - wrap to bottom when at top + if (kb.matches(keyData, "tui.select.up")) { + this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1; + this.notifySelectionChange(); + } + // Down arrow - wrap to top when at bottom + else if (kb.matches(keyData, "tui.select.down")) { + this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1; + this.notifySelectionChange(); + } + // Enter + else if (kb.matches(keyData, "tui.select.confirm")) { + const selectedItem = this.filteredItems[this.selectedIndex]; + if (selectedItem && this.onSelect) { + this.onSelect(selectedItem); + } + } + // Escape or Ctrl+C + else if (kb.matches(keyData, "tui.select.cancel")) { + if (this.onCancel) { + this.onCancel(); + } + } + } + + private renderItem( + item: SelectItem, + isSelected: boolean, + width: number, + descriptionSingleLine: string | undefined, + primaryColumnWidth: number, + ): string { + const prefix = isSelected ? "→ " : " "; + const prefixWidth = visibleWidth(prefix); + + if (descriptionSingleLine && width > 40) { + const effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4)); + const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP); + const truncatedValue = this.truncatePrimary(item, isSelected, maxPrimaryWidth, effectivePrimaryColumnWidth); + const truncatedValueWidth = visibleWidth(truncatedValue); + const spacing = " ".repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth)); + const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length; + const remainingWidth = width - descriptionStart - 2; // -2 for safety + + if (remainingWidth > MIN_DESCRIPTION_WIDTH) { + const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, ""); + if (isSelected) { + return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`); + } + + const descText = this.theme.description(spacing + truncatedDesc); + return prefix + truncatedValue + descText; + } + } + + const maxWidth = width - prefixWidth - 2; + const truncatedValue = this.truncatePrimary(item, isSelected, maxWidth, maxWidth); + if (isSelected) { + return this.theme.selectedText(`${prefix}${truncatedValue}`); + } + + return prefix + truncatedValue; + } + + private getPrimaryColumnWidth(): number { + const { min, max } = this.getPrimaryColumnBounds(); + const widestPrimary = this.filteredItems.reduce((widest, item) => { + return Math.max(widest, visibleWidth(this.getDisplayValue(item)) + PRIMARY_COLUMN_GAP); + }, 0); + + return clamp(widestPrimary, min, max); + } + + private getPrimaryColumnBounds(): { min: number; max: number } { + const rawMin = + this.layout.minPrimaryColumnWidth ?? this.layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH; + const rawMax = + this.layout.maxPrimaryColumnWidth ?? this.layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH; + + return { + min: Math.max(1, Math.min(rawMin, rawMax)), + max: Math.max(1, Math.max(rawMin, rawMax)), + }; + } + + private truncatePrimary(item: SelectItem, isSelected: boolean, maxWidth: number, columnWidth: number): string { + const displayValue = this.getDisplayValue(item); + const truncatedValue = this.layout.truncatePrimary + ? this.layout.truncatePrimary({ + text: displayValue, + maxWidth, + columnWidth, + item, + isSelected, + }) + : truncateToWidth(displayValue, maxWidth, ""); + + return truncateToWidth(truncatedValue, maxWidth, ""); + } + + private getDisplayValue(item: SelectItem): string { + return item.label || item.value; + } + + private notifySelectionChange(): void { + const selectedItem = this.filteredItems[this.selectedIndex]; + if (selectedItem && this.onSelectionChange) { + this.onSelectionChange(selectedItem); + } + } + + getSelectedItem(): SelectItem | null { + const item = this.filteredItems[this.selectedIndex]; + return item || null; + } +} diff --git a/packages/pi-tui/src/components/settings-list.ts b/packages/pi-tui/src/components/settings-list.ts new file mode 100644 index 000000000..8711923a1 --- /dev/null +++ b/packages/pi-tui/src/components/settings-list.ts @@ -0,0 +1,250 @@ +import { fuzzyFilter } from "../fuzzy.ts"; +import { getKeybindings } from "../keybindings.ts"; +import type { Component } from "../tui.ts"; +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.ts"; +import { Input } from "./input.ts"; + +export interface SettingItem { + /** Unique identifier for this setting */ + id: string; + /** Display label (left side) */ + label: string; + /** Optional description shown when selected */ + description?: string; + /** Current value to display (right side) */ + currentValue: string; + /** If provided, Enter/Space cycles through these values */ + values?: string[]; + /** If provided, Enter opens this submenu. Receives current value and done callback. */ + submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component; +} + +export interface SettingsListTheme { + label: (text: string, selected: boolean) => string; + value: (text: string, selected: boolean) => string; + description: (text: string) => string; + cursor: string; + hint: (text: string) => string; +} + +export interface SettingsListOptions { + enableSearch?: boolean; +} + +export class SettingsList implements Component { + private items: SettingItem[]; + private filteredItems: SettingItem[]; + private theme: SettingsListTheme; + private selectedIndex = 0; + private maxVisible: number; + private onChange: (id: string, newValue: string) => void; + private onCancel: () => void; + private searchInput?: Input; + private searchEnabled: boolean; + + // Submenu state + private submenuComponent: Component | null = null; + private submenuItemIndex: number | null = null; + + constructor( + items: SettingItem[], + maxVisible: number, + theme: SettingsListTheme, + onChange: (id: string, newValue: string) => void, + onCancel: () => void, + options: SettingsListOptions = {}, + ) { + this.items = items; + this.filteredItems = items; + this.maxVisible = maxVisible; + this.theme = theme; + this.onChange = onChange; + this.onCancel = onCancel; + this.searchEnabled = options.enableSearch ?? false; + if (this.searchEnabled) { + this.searchInput = new Input(); + } + } + + /** Update an item's currentValue */ + updateValue(id: string, newValue: string): void { + const item = this.items.find((i) => i.id === id); + if (item) { + item.currentValue = newValue; + } + } + + invalidate(): void { + this.submenuComponent?.invalidate?.(); + } + + render(width: number): string[] { + // If submenu is active, render it instead + if (this.submenuComponent) { + return this.submenuComponent.render(width); + } + + return this.renderMainList(width); + } + + private renderMainList(width: number): string[] { + const lines: string[] = []; + + if (this.searchEnabled && this.searchInput) { + lines.push(...this.searchInput.render(width)); + lines.push(""); + } + + if (this.items.length === 0) { + lines.push(this.theme.hint(" No settings available")); + if (this.searchEnabled) { + this.addHintLine(lines, width); + } + return lines; + } + + const displayItems = this.searchEnabled ? this.filteredItems : this.items; + if (displayItems.length === 0) { + lines.push(truncateToWidth(this.theme.hint(" No matching settings"), width)); + this.addHintLine(lines, width); + return lines; + } + + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible), + ); + const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length); + + // Calculate max label width for alignment + const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label)))); + + // Render visible items + for (let i = startIndex; i < endIndex; i++) { + const item = displayItems[i]; + if (!item) continue; + + const isSelected = i === this.selectedIndex; + const prefix = isSelected ? this.theme.cursor : " "; + const prefixWidth = visibleWidth(prefix); + + // Pad label to align values + const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label))); + const labelText = this.theme.label(labelPadded, isSelected); + + // Calculate space for value + const separator = " "; + const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator); + const valueMaxWidth = width - usedWidth - 2; + + const valueText = this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected); + + lines.push(truncateToWidth(prefix + labelText + separator + valueText, width)); + } + + // Add scroll indicator if needed + if (startIndex > 0 || endIndex < displayItems.length) { + const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`; + lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, ""))); + } + + // Add description for selected item + const selectedItem = displayItems[this.selectedIndex]; + if (selectedItem?.description) { + lines.push(""); + const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4); + for (const line of wrappedDesc) { + lines.push(this.theme.description(` ${line}`)); + } + } + + // Add hint + this.addHintLine(lines, width); + + return lines; + } + + handleInput(data: string): void { + // If submenu is active, delegate all input to it + // The submenu's onCancel (triggered by escape) will call done() which closes it + if (this.submenuComponent) { + this.submenuComponent.handleInput?.(data); + return; + } + + // Main list input handling + const kb = getKeybindings(); + const displayItems = this.searchEnabled ? this.filteredItems : this.items; + if (kb.matches(data, "tui.select.up")) { + if (displayItems.length === 0) return; + this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1; + } else if (kb.matches(data, "tui.select.down")) { + if (displayItems.length === 0) return; + this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1; + } else if (kb.matches(data, "tui.select.confirm") || data === " ") { + this.activateItem(); + } else if (kb.matches(data, "tui.select.cancel")) { + this.onCancel(); + } else if (this.searchEnabled && this.searchInput) { + const sanitized = data.replace(/ /g, ""); + if (!sanitized) { + return; + } + this.searchInput.handleInput(sanitized); + this.applyFilter(this.searchInput.getValue()); + } + } + + private activateItem(): void { + const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex]; + if (!item) return; + + if (item.submenu) { + // Open submenu, passing current value so it can pre-select correctly + this.submenuItemIndex = this.selectedIndex; + this.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => { + if (selectedValue !== undefined) { + item.currentValue = selectedValue; + this.onChange(item.id, selectedValue); + } + this.closeSubmenu(); + }); + } else if (item.values && item.values.length > 0) { + // Cycle through values + const currentIndex = item.values.indexOf(item.currentValue); + const nextIndex = (currentIndex + 1) % item.values.length; + const newValue = item.values[nextIndex]!; + item.currentValue = newValue; + this.onChange(item.id, newValue); + } + } + + private closeSubmenu(): void { + this.submenuComponent = null; + // Restore selection to the item that opened the submenu + if (this.submenuItemIndex !== null) { + this.selectedIndex = this.submenuItemIndex; + this.submenuItemIndex = null; + } + } + + private applyFilter(query: string): void { + this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label); + this.selectedIndex = 0; + } + + private addHintLine(lines: string[], width: number): void { + lines.push(""); + lines.push( + truncateToWidth( + this.theme.hint( + this.searchEnabled + ? " Type to search · Enter/Space to change · Esc to cancel" + : " Enter/Space to change · Esc to cancel", + ), + width, + ), + ); + } +} diff --git a/packages/pi-tui/src/components/spacer.ts b/packages/pi-tui/src/components/spacer.ts new file mode 100644 index 000000000..7abe1551c --- /dev/null +++ b/packages/pi-tui/src/components/spacer.ts @@ -0,0 +1,28 @@ +import type { Component } from "../tui.ts"; + +/** + * Spacer component that renders empty lines + */ +export class Spacer implements Component { + private lines: number; + + constructor(lines: number = 1) { + this.lines = lines; + } + + setLines(lines: number): void { + this.lines = lines; + } + + invalidate(): void { + // No cached state to invalidate currently + } + + render(_width: number): string[] { + const result: string[] = []; + for (let i = 0; i < this.lines; i++) { + result.push(""); + } + return result; + } +} diff --git a/packages/pi-tui/src/components/text.ts b/packages/pi-tui/src/components/text.ts new file mode 100644 index 000000000..3809a48a8 --- /dev/null +++ b/packages/pi-tui/src/components/text.ts @@ -0,0 +1,106 @@ +import type { Component } from "../tui.ts"; +import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts"; + +/** + * Text component - displays multi-line text with word wrapping + */ +export class Text implements Component { + private text: string; + private paddingX: number; // Left/right padding + private paddingY: number; // Top/bottom padding + private customBgFn?: (text: string) => string; + + // Cache for rendered output + private cachedText?: string; + private cachedWidth?: number; + private cachedLines?: string[]; + + constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) { + this.text = text; + this.paddingX = paddingX; + this.paddingY = paddingY; + this.customBgFn = customBgFn; + } + + setText(text: string): void { + this.text = text; + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + setCustomBgFn(customBgFn?: (text: string) => string): void { + this.customBgFn = customBgFn; + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + invalidate(): void { + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + render(width: number): string[] { + // Check cache + if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) { + return this.cachedLines; + } + + // Don't render anything if there's no actual text + if (!this.text || this.text.trim() === "") { + const result: string[] = []; + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = result; + return result; + } + + // Replace tabs with 3 spaces + const normalizedText = this.text.replace(/\t/g, " "); + + // Calculate content width (subtract left/right margins) + const contentWidth = Math.max(1, width - this.paddingX * 2); + + // Wrap text (this preserves ANSI codes but does NOT pad) + const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth); + + // Add margins and background to each line + const leftMargin = " ".repeat(this.paddingX); + const rightMargin = " ".repeat(this.paddingX); + const contentLines: string[] = []; + + for (const line of wrappedLines) { + // Add margins + const lineWithMargins = leftMargin + line + rightMargin; + + // Apply background if specified (this also pads to full width) + if (this.customBgFn) { + contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgFn)); + } else { + // No background - just pad to width with spaces + const visibleLen = visibleWidth(lineWithMargins); + const paddingNeeded = Math.max(0, width - visibleLen); + contentLines.push(lineWithMargins + " ".repeat(paddingNeeded)); + } + } + + // Add top/bottom padding (empty lines) + const emptyLine = " ".repeat(width); + const emptyLines: string[] = []; + for (let i = 0; i < this.paddingY; i++) { + const line = this.customBgFn ? applyBackgroundToLine(emptyLine, width, this.customBgFn) : emptyLine; + emptyLines.push(line); + } + + const result = [...emptyLines, ...contentLines, ...emptyLines]; + + // Update cache + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = result; + + return result.length > 0 ? result : [""]; + } +} diff --git a/packages/pi-tui/src/components/truncated-text.ts b/packages/pi-tui/src/components/truncated-text.ts new file mode 100644 index 000000000..c26b88929 --- /dev/null +++ b/packages/pi-tui/src/components/truncated-text.ts @@ -0,0 +1,65 @@ +import type { Component } from "../tui.ts"; +import { truncateToWidth, visibleWidth } from "../utils.ts"; + +/** + * Text component that truncates to fit viewport width + */ +export class TruncatedText implements Component { + private text: string; + private paddingX: number; + private paddingY: number; + + constructor(text: string, paddingX: number = 0, paddingY: number = 0) { + this.text = text; + this.paddingX = paddingX; + this.paddingY = paddingY; + } + + invalidate(): void { + // No cached state to invalidate currently + } + + render(width: number): string[] { + const result: string[] = []; + + // Empty line padded to width + const emptyLine = " ".repeat(width); + + // Add vertical padding above + for (let i = 0; i < this.paddingY; i++) { + result.push(emptyLine); + } + + // Calculate available width after horizontal padding + const availableWidth = Math.max(1, width - this.paddingX * 2); + + // Take only the first line (stop at newline) + let singleLineText = this.text; + const newlineIndex = this.text.indexOf("\n"); + if (newlineIndex !== -1) { + singleLineText = this.text.substring(0, newlineIndex); + } + + // Truncate text if needed (accounting for ANSI codes) + const displayText = truncateToWidth(singleLineText, availableWidth); + + // Add horizontal padding + const leftPadding = " ".repeat(this.paddingX); + const rightPadding = " ".repeat(this.paddingX); + const lineWithPadding = leftPadding + displayText + rightPadding; + + // Pad line to exactly width characters + const lineVisibleWidth = visibleWidth(lineWithPadding); + const paddingNeeded = Math.max(0, width - lineVisibleWidth); + const finalLine = lineWithPadding + " ".repeat(paddingNeeded); + + result.push(finalLine); + + // Add vertical padding below + for (let i = 0; i < this.paddingY; i++) { + result.push(emptyLine); + } + + return result; + } +} diff --git a/packages/pi-tui/src/editor-component.ts b/packages/pi-tui/src/editor-component.ts new file mode 100644 index 000000000..595645033 --- /dev/null +++ b/packages/pi-tui/src/editor-component.ts @@ -0,0 +1,74 @@ +import type { AutocompleteProvider } from "./autocomplete.ts"; +import type { Component } from "./tui.ts"; + +/** + * Interface for custom editor components. + * + * This allows extensions to provide their own editor implementation + * (e.g., vim mode, emacs mode, custom keybindings) while maintaining + * compatibility with the core application. + */ +export interface EditorComponent extends Component { + // ========================================================================= + // Core text access (required) + // ========================================================================= + + /** Get the current text content */ + getText(): string; + + /** Set the text content */ + setText(text: string): void; + + /** Handle raw terminal input (key presses, paste sequences, etc.) */ + handleInput(data: string): void; + + // ========================================================================= + // Callbacks (required) + // ========================================================================= + + /** Called when user submits (e.g., Enter key) */ + onSubmit?: (text: string) => void; + + /** Called when text changes */ + onChange?: (text: string) => void; + + // ========================================================================= + // History support (optional) + // ========================================================================= + + /** Add text to history for up/down navigation */ + addToHistory?(text: string): void; + + // ========================================================================= + // Advanced text manipulation (optional) + // ========================================================================= + + /** Insert text at current cursor position */ + insertTextAtCursor?(text: string): void; + + /** + * Get text with any markers expanded (e.g., paste markers). + * Falls back to getText() if not implemented. + */ + getExpandedText?(): string; + + // ========================================================================= + // Autocomplete support (optional) + // ========================================================================= + + /** Set the autocomplete provider */ + setAutocompleteProvider?(provider: AutocompleteProvider): void; + + // ========================================================================= + // Appearance (optional) + // ========================================================================= + + /** Border color function */ + borderColor?: (str: string) => string; + + /** Set horizontal padding */ + setPaddingX?(padding: number): void; + + /** Set max visible items in autocomplete dropdown */ + setAutocompleteMaxVisible?(maxVisible: number): void; +} diff --git a/packages/pi-tui/src/fuzzy.ts b/packages/pi-tui/src/fuzzy.ts new file mode 100644 index 000000000..26d59b087 --- /dev/null +++ b/packages/pi-tui/src/fuzzy.ts @@ -0,0 +1,137 @@ +/** + * Fuzzy matching utilities. + * Matches if all query characters appear in order (not necessarily consecutive). + * Lower score = better match. + */ + +export interface FuzzyMatch { + matches: boolean; + score: number; +} + +export function fuzzyMatch(query: string, text: string): FuzzyMatch { + const queryLower = query.toLowerCase(); + const textLower = text.toLowerCase(); + + const matchQuery = (normalizedQuery: string): FuzzyMatch => { + if (normalizedQuery.length === 0) { + return { matches: true, score: 0 }; + } + + if (normalizedQuery.length > textLower.length) { + return { matches: false, score: 0 }; + } + + let queryIndex = 0; + let score = 0; + let lastMatchIndex = -1; + let consecutiveMatches = 0; + + for (let i = 0; i < textLower.length && queryIndex < normalizedQuery.length; i++) { + if (textLower[i] === normalizedQuery[queryIndex]) { + const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!); + + // Reward consecutive matches + if (lastMatchIndex === i - 1) { + consecutiveMatches++; + score -= consecutiveMatches * 5; + } else { + consecutiveMatches = 0; + // Penalize gaps + if (lastMatchIndex >= 0) { + score += (i - lastMatchIndex - 1) * 2; + } + } + + // Reward word boundary matches + if (isWordBoundary) { + score -= 10; + } + + // Slight penalty for later matches + score += i * 0.1; + + lastMatchIndex = i; + queryIndex++; + } + } + + if (queryIndex < normalizedQuery.length) { + return { matches: false, score: 0 }; + } + + if (normalizedQuery === textLower) { + score -= 100; + } + + return { matches: true, score }; + }; + + const primaryMatch = matchQuery(queryLower); + if (primaryMatch.matches) { + return primaryMatch; + } + + const alphaNumericMatch = queryLower.match(/^(?[a-z]+)(?[0-9]+)$/); + const numericAlphaMatch = queryLower.match(/^(?[0-9]+)(?[a-z]+)$/); + const swappedQuery = alphaNumericMatch + ? `${alphaNumericMatch.groups?.["digits"] ?? ""}${alphaNumericMatch.groups?.["letters"] ?? ""}` + : numericAlphaMatch + ? `${numericAlphaMatch.groups?.["letters"] ?? ""}${numericAlphaMatch.groups?.["digits"] ?? ""}` + : ""; + + if (!swappedQuery) { + return primaryMatch; + } + + const swappedMatch = matchQuery(swappedQuery); + if (!swappedMatch.matches) { + return primaryMatch; + } + + return { matches: true, score: swappedMatch.score + 5 }; +} + +/** + * Filter and sort items by fuzzy match quality (best matches first). + * Supports whitespace- and slash-separated tokens: all tokens must match. + */ +export function fuzzyFilter(items: T[], query: string, getText: (item: T) => string): T[] { + if (!query.trim()) { + return items; + } + + const tokens = query + .trim() + .split(/[\s/]+/) + .filter((t) => t.length > 0); + + if (tokens.length === 0) { + return items; + } + + const results: { item: T; totalScore: number }[] = []; + + for (const item of items) { + const text = getText(item); + let totalScore = 0; + let allMatch = true; + + for (const token of tokens) { + const match = fuzzyMatch(token, text); + if (match.matches) { + totalScore += match.score; + } else { + allMatch = false; + break; + } + } + + if (allMatch) { + results.push({ item, totalScore }); + } + } + + results.sort((a, b) => a.totalScore - b.totalScore); + return results.map((r) => r.item); +} diff --git a/packages/pi-tui/src/index.ts b/packages/pi-tui/src/index.ts new file mode 100644 index 000000000..4e76b1079 --- /dev/null +++ b/packages/pi-tui/src/index.ts @@ -0,0 +1,114 @@ +// Core TUI interfaces and classes + +// Autocomplete support +export { + type AutocompleteItem, + type AutocompleteProvider, + type AutocompleteSuggestions, + CombinedAutocompleteProvider, + type SlashCommand, +} from "./autocomplete.ts"; +// Components +export { Box } from "./components/box.ts"; +export { CancellableLoader } from "./components/cancellable-loader.ts"; +export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.ts"; +export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts"; +export { Input } from "./components/input.ts"; +export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts"; +export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts"; +export { + type SelectItem, + SelectList, + type SelectListLayoutOptions, + type SelectListTheme, + type SelectListTruncatePrimaryContext, +} from "./components/select-list.ts"; +export { type SettingItem, SettingsList, type SettingsListTheme } from "./components/settings-list.ts"; +export { Spacer } from "./components/spacer.ts"; +export { Text } from "./components/text.ts"; +export { TruncatedText } from "./components/truncated-text.ts"; +// Editor component interface (for custom editors) +export type { EditorComponent } from "./editor-component.ts"; +// Fuzzy matching +export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.ts"; +// Keybindings +export { + getKeybindings, + type Keybinding, + type KeybindingConflict, + type KeybindingDefinition, + type KeybindingDefinitions, + type Keybindings, + type KeybindingsConfig, + KeybindingsManager, + setKeybindings, + TUI_KEYBINDINGS, +} from "./keybindings.ts"; +// Keyboard input handling +export { + decodeKittyPrintable, + isKeyRelease, + isKeyRepeat, + isKittyProtocolActive, + Key, + type KeyEventType, + type KeyId, + matchesKey, + parseKey, + setKittyProtocolActive, +} from "./keys.ts"; +// Input buffering for batch splitting +export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts"; +// Terminal interface and implementations +export { ProcessTerminal, type Terminal } from "./terminal.ts"; +// Terminal colors +export { + parseOsc11BackgroundColor, + parseTerminalColorSchemeReport, + type RgbColor, + type TerminalColorScheme, +} from "./terminal-colors.ts"; +// Terminal image support +export { + allocateImageId, + type CellDimensions, + calculateImageRows, + deleteAllKittyImages, + deleteKittyImage, + detectCapabilities, + encodeITerm2, + encodeKitty, + getCapabilities, + getCellDimensions, + getGifDimensions, + getImageDimensions, + getJpegDimensions, + getPngDimensions, + getWebpDimensions, + hyperlink, + type ImageDimensions, + type ImageProtocol, + type ImageRenderOptions, + imageFallback, + renderImage, + resetCapabilitiesCache, + setCapabilities, + setCellDimensions, + type TerminalCapabilities, +} from "./terminal-image.ts"; +export { + type Component, + Container, + CURSOR_MARKER, + type Focusable, + isFocusable, + type OverlayAnchor, + type OverlayHandle, + type OverlayMargin, + type OverlayOptions, + type OverlayUnfocusOptions, + type SizeValue, + TUI, +} from "./tui.ts"; +// Utilities +export { sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.ts"; diff --git a/packages/pi-tui/src/keybindings.ts b/packages/pi-tui/src/keybindings.ts new file mode 100644 index 000000000..3aea1fed4 --- /dev/null +++ b/packages/pi-tui/src/keybindings.ts @@ -0,0 +1,244 @@ +import { type KeyId, matchesKey } from "./keys.ts"; + +/** + * Global keybinding registry. + * Downstream packages can add keybindings via declaration merging. + */ +export interface Keybindings { + // Editor navigation and editing + "tui.editor.cursorUp": true; + "tui.editor.cursorDown": true; + "tui.editor.cursorLeft": true; + "tui.editor.cursorRight": true; + "tui.editor.cursorWordLeft": true; + "tui.editor.cursorWordRight": true; + "tui.editor.cursorLineStart": true; + "tui.editor.cursorLineEnd": true; + "tui.editor.jumpForward": true; + "tui.editor.jumpBackward": true; + "tui.editor.pageUp": true; + "tui.editor.pageDown": true; + "tui.editor.deleteCharBackward": true; + "tui.editor.deleteCharForward": true; + "tui.editor.deleteWordBackward": true; + "tui.editor.deleteWordForward": true; + "tui.editor.deleteToLineStart": true; + "tui.editor.deleteToLineEnd": true; + "tui.editor.yank": true; + "tui.editor.yankPop": true; + "tui.editor.undo": true; + // Generic input actions + "tui.input.newLine": true; + "tui.input.submit": true; + "tui.input.tab": true; + "tui.input.copy": true; + // Generic selection actions + "tui.select.up": true; + "tui.select.down": true; + "tui.select.pageUp": true; + "tui.select.pageDown": true; + "tui.select.confirm": true; + "tui.select.cancel": true; +} + +export type Keybinding = keyof Keybindings; + +export interface KeybindingDefinition { + defaultKeys: KeyId | KeyId[]; + description?: string; +} + +export type KeybindingDefinitions = Record; +export type KeybindingsConfig = Record; + +export const TUI_KEYBINDINGS = { + "tui.editor.cursorUp": { defaultKeys: "up", description: "Move cursor up" }, + "tui.editor.cursorDown": { defaultKeys: "down", description: "Move cursor down" }, + "tui.editor.cursorLeft": { + defaultKeys: ["left", "ctrl+b"], + description: "Move cursor left", + }, + "tui.editor.cursorRight": { + defaultKeys: ["right", "ctrl+f"], + description: "Move cursor right", + }, + "tui.editor.cursorWordLeft": { + defaultKeys: ["alt+left", "ctrl+left", "alt+b"], + description: "Move cursor word left", + }, + "tui.editor.cursorWordRight": { + defaultKeys: ["alt+right", "ctrl+right", "alt+f"], + description: "Move cursor word right", + }, + "tui.editor.cursorLineStart": { + defaultKeys: ["home", "ctrl+a"], + description: "Move to line start", + }, + "tui.editor.cursorLineEnd": { + defaultKeys: ["end", "ctrl+e"], + description: "Move to line end", + }, + "tui.editor.jumpForward": { + defaultKeys: "ctrl+]", + description: "Jump forward to character", + }, + "tui.editor.jumpBackward": { + defaultKeys: "ctrl+alt+]", + description: "Jump backward to character", + }, + "tui.editor.pageUp": { defaultKeys: "pageUp", description: "Page up" }, + "tui.editor.pageDown": { defaultKeys: "pageDown", description: "Page down" }, + "tui.editor.deleteCharBackward": { + defaultKeys: "backspace", + description: "Delete character backward", + }, + "tui.editor.deleteCharForward": { + defaultKeys: ["delete", "ctrl+d"], + description: "Delete character forward", + }, + "tui.editor.deleteWordBackward": { + defaultKeys: ["ctrl+w", "alt+backspace"], + description: "Delete word backward", + }, + "tui.editor.deleteWordForward": { + defaultKeys: ["alt+d", "alt+delete"], + description: "Delete word forward", + }, + "tui.editor.deleteToLineStart": { + defaultKeys: "ctrl+u", + description: "Delete to line start", + }, + "tui.editor.deleteToLineEnd": { + defaultKeys: "ctrl+k", + description: "Delete to line end", + }, + "tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" }, + "tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" }, + "tui.editor.undo": { defaultKeys: "ctrl+-", description: "Undo" }, + "tui.input.newLine": { defaultKeys: ["shift+enter", "ctrl+j"], description: "Insert newline" }, + "tui.input.submit": { defaultKeys: "enter", description: "Submit input" }, + "tui.input.tab": { defaultKeys: "tab", description: "Tab / autocomplete" }, + "tui.input.copy": { defaultKeys: "ctrl+c", description: "Copy selection" }, + "tui.select.up": { defaultKeys: "up", description: "Move selection up" }, + "tui.select.down": { defaultKeys: "down", description: "Move selection down" }, + "tui.select.pageUp": { defaultKeys: "pageUp", description: "Selection page up" }, + "tui.select.pageDown": { + defaultKeys: "pageDown", + description: "Selection page down", + }, + "tui.select.confirm": { defaultKeys: "enter", description: "Confirm selection" }, + "tui.select.cancel": { + defaultKeys: ["escape", "ctrl+c"], + description: "Cancel selection", + }, +} as const satisfies KeybindingDefinitions; + +export interface KeybindingConflict { + key: KeyId; + keybindings: string[]; +} + +function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] { + if (keys === undefined) return []; + const keyList = Array.isArray(keys) ? keys : [keys]; + const seen = new Set(); + const result: KeyId[] = []; + for (const key of keyList) { + if (!seen.has(key)) { + seen.add(key); + result.push(key); + } + } + return result; +} + +export class KeybindingsManager { + private definitions: KeybindingDefinitions; + private userBindings: KeybindingsConfig; + private keysById = new Map(); + private conflicts: KeybindingConflict[] = []; + + constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) { + this.definitions = definitions; + this.userBindings = userBindings; + this.rebuild(); + } + + private rebuild(): void { + this.keysById.clear(); + this.conflicts = []; + + const userClaims = new Map>(); + for (const [keybinding, keys] of Object.entries(this.userBindings)) { + if (!(keybinding in this.definitions)) continue; + for (const key of normalizeKeys(keys)) { + const claimants = userClaims.get(key) ?? new Set(); + claimants.add(keybinding as Keybinding); + userClaims.set(key, claimants); + } + } + + for (const [key, keybindings] of userClaims) { + if (keybindings.size > 1) { + this.conflicts.push({ key, keybindings: [...keybindings] }); + } + } + + for (const [id, definition] of Object.entries(this.definitions)) { + const userKeys = this.userBindings[id]; + const keys = userKeys === undefined ? normalizeKeys(definition.defaultKeys) : normalizeKeys(userKeys); + this.keysById.set(id as Keybinding, keys); + } + } + + matches(data: string, keybinding: Keybinding): boolean { + const keys = this.keysById.get(keybinding) ?? []; + for (const key of keys) { + if (matchesKey(data, key)) return true; + } + return false; + } + + getKeys(keybinding: Keybinding): KeyId[] { + return [...(this.keysById.get(keybinding) ?? [])]; + } + + getDefinition(keybinding: Keybinding): KeybindingDefinition { + return this.definitions[keybinding]!; + } + + getConflicts(): KeybindingConflict[] { + return this.conflicts.map((conflict) => ({ ...conflict, keybindings: [...conflict.keybindings] })); + } + + setUserBindings(userBindings: KeybindingsConfig): void { + this.userBindings = userBindings; + this.rebuild(); + } + + getUserBindings(): KeybindingsConfig { + return { ...this.userBindings }; + } + + getResolvedBindings(): KeybindingsConfig { + const resolved: KeybindingsConfig = {}; + for (const id of Object.keys(this.definitions)) { + const keys = this.keysById.get(id as Keybinding) ?? []; + resolved[id] = keys.length === 1 ? keys[0]! : [...keys]; + } + return resolved; + } +} + +let globalKeybindings: KeybindingsManager | null = null; + +export function setKeybindings(keybindings: KeybindingsManager): void { + globalKeybindings = keybindings; +} + +export function getKeybindings(): KeybindingsManager { + if (!globalKeybindings) { + globalKeybindings = new KeybindingsManager(TUI_KEYBINDINGS); + } + return globalKeybindings; +} diff --git a/packages/pi-tui/src/keys.ts b/packages/pi-tui/src/keys.ts new file mode 100644 index 000000000..a543db6d5 --- /dev/null +++ b/packages/pi-tui/src/keys.ts @@ -0,0 +1,1400 @@ +/** + * Keyboard input handling for terminal applications. + * + * Supports both legacy terminal sequences and Kitty keyboard protocol. + * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + * Reference: https://github.com/sst/opentui/blob/7da92b4088aebfe27b9f691c04163a48821e49fd/packages/core/src/lib/parse.keypress.ts + * + * Symbol keys are also supported, however some ctrl+symbol combos + * overlap with ASCII codes, e.g. ctrl+[ = ESC. + * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#legacy-ctrl-mapping-of-ascii-keys + * Those can still be * used for ctrl+shift combos + * + * API: + * - matchesKey(data, keyId) - Check if input matches a key identifier + * - parseKey(data) - Parse input and return the key identifier + * - Key - Helper object for creating typed key identifiers + * - setKittyProtocolActive(active) - Set global Kitty protocol state + * - isKittyProtocolActive() - Query global Kitty protocol state + */ + +// ============================================================================= +// Global Kitty Protocol State +// ============================================================================= + +let _kittyProtocolActive = false; + +/** + * Set the global Kitty keyboard protocol state. + * Called by ProcessTerminal after detecting protocol support. + */ +export function setKittyProtocolActive(active: boolean): void { + _kittyProtocolActive = active; +} + +/** + * Query whether Kitty keyboard protocol is currently active. + */ +export function isKittyProtocolActive(): boolean { + return _kittyProtocolActive; +} + +// ============================================================================= +// Type-Safe Key Identifiers +// ============================================================================= + +type Letter = + | "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j" + | "k" + | "l" + | "m" + | "n" + | "o" + | "p" + | "q" + | "r" + | "s" + | "t" + | "u" + | "v" + | "w" + | "x" + | "y" + | "z"; + +type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"; + +type SymbolKey = + | "`" + | "-" + | "=" + | "[" + | "]" + | "\\" + | ";" + | "'" + | "," + | "." + | "/" + | "!" + | "@" + | "#" + | "$" + | "%" + | "^" + | "&" + | "*" + | "(" + | ")" + | "_" + | "+" + | "|" + | "~" + | "{" + | "}" + | ":" + | "<" + | ">" + | "?"; + +type SpecialKey = + | "escape" + | "esc" + | "enter" + | "return" + | "tab" + | "space" + | "backspace" + | "delete" + | "insert" + | "clear" + | "home" + | "end" + | "pageUp" + | "pageDown" + | "up" + | "down" + | "left" + | "right" + | "f1" + | "f2" + | "f3" + | "f4" + | "f5" + | "f6" + | "f7" + | "f8" + | "f9" + | "f10" + | "f11" + | "f12"; + +type BaseKey = Letter | Digit | SymbolKey | SpecialKey; +type ModifierName = "ctrl" | "shift" | "alt" | "super"; + +type ModifiedKeyId = { + [M in RemainingModifiers]: `${M}+${Key}` | `${M}+${ModifiedKeyId>}`; +}[RemainingModifiers]; + +/** + * Union type of all valid key identifiers. + * Provides autocomplete and catches typos at compile time. + */ +export type KeyId = BaseKey | ModifiedKeyId; + +/** + * Helper object for creating typed key identifiers with autocomplete. + * + * Usage: + * - Key.escape, Key.enter, Key.tab, etc. for special keys + * - Key.backtick, Key.comma, Key.period, etc. for symbol keys + * - Key.ctrl("c"), Key.alt("x"), Key.super("k") for single modifiers + * - Key.ctrlShift("p"), Key.ctrlAlt("x"), Key.ctrlSuper("k") for combined modifiers + */ +export const Key = { + // Special keys + escape: "escape" as const, + esc: "esc" as const, + enter: "enter" as const, + return: "return" as const, + tab: "tab" as const, + space: "space" as const, + backspace: "backspace" as const, + delete: "delete" as const, + insert: "insert" as const, + clear: "clear" as const, + home: "home" as const, + end: "end" as const, + pageUp: "pageUp" as const, + pageDown: "pageDown" as const, + up: "up" as const, + down: "down" as const, + left: "left" as const, + right: "right" as const, + f1: "f1" as const, + f2: "f2" as const, + f3: "f3" as const, + f4: "f4" as const, + f5: "f5" as const, + f6: "f6" as const, + f7: "f7" as const, + f8: "f8" as const, + f9: "f9" as const, + f10: "f10" as const, + f11: "f11" as const, + f12: "f12" as const, + + // Symbol keys + backtick: "`" as const, + hyphen: "-" as const, + equals: "=" as const, + leftbracket: "[" as const, + rightbracket: "]" as const, + backslash: "\\" as const, + semicolon: ";" as const, + quote: "'" as const, + comma: "," as const, + period: "." as const, + slash: "/" as const, + exclamation: "!" as const, + at: "@" as const, + hash: "#" as const, + dollar: "$" as const, + percent: "%" as const, + caret: "^" as const, + ampersand: "&" as const, + asterisk: "*" as const, + leftparen: "(" as const, + rightparen: ")" as const, + underscore: "_" as const, + plus: "+" as const, + pipe: "|" as const, + tilde: "~" as const, + leftbrace: "{" as const, + rightbrace: "}" as const, + colon: ":" as const, + lessthan: "<" as const, + greaterthan: ">" as const, + question: "?" as const, + + // Single modifiers + ctrl: (key: K): `ctrl+${K}` => `ctrl+${key}`, + shift: (key: K): `shift+${K}` => `shift+${key}`, + alt: (key: K): `alt+${K}` => `alt+${key}`, + super: (key: K): `super+${K}` => `super+${key}`, + + // Combined modifiers + ctrlShift: (key: K): `ctrl+shift+${K}` => `ctrl+shift+${key}`, + shiftCtrl: (key: K): `shift+ctrl+${K}` => `shift+ctrl+${key}`, + ctrlAlt: (key: K): `ctrl+alt+${K}` => `ctrl+alt+${key}`, + altCtrl: (key: K): `alt+ctrl+${K}` => `alt+ctrl+${key}`, + shiftAlt: (key: K): `shift+alt+${K}` => `shift+alt+${key}`, + altShift: (key: K): `alt+shift+${K}` => `alt+shift+${key}`, + ctrlSuper: (key: K): `ctrl+super+${K}` => `ctrl+super+${key}`, + superCtrl: (key: K): `super+ctrl+${K}` => `super+ctrl+${key}`, + shiftSuper: (key: K): `shift+super+${K}` => `shift+super+${key}`, + superShift: (key: K): `super+shift+${K}` => `super+shift+${key}`, + altSuper: (key: K): `alt+super+${K}` => `alt+super+${key}`, + superAlt: (key: K): `super+alt+${K}` => `super+alt+${key}`, + + // Triple modifiers + ctrlShiftAlt: (key: K): `ctrl+shift+alt+${K}` => `ctrl+shift+alt+${key}`, + ctrlShiftSuper: (key: K): `ctrl+shift+super+${K}` => `ctrl+shift+super+${key}`, +} as const; + +// ============================================================================= +// Constants +// ============================================================================= + +const SYMBOL_KEYS = new Set([ + "`", + "-", + "=", + "[", + "]", + "\\", + ";", + "'", + ",", + ".", + "/", + "!", + "@", + "#", + "$", + "%", + "^", + "&", + "*", + "(", + ")", + "_", + "+", + "|", + "~", + "{", + "}", + ":", + "<", + ">", + "?", +]); + +const MODIFIERS = { + shift: 1, + alt: 2, + ctrl: 4, + super: 8, +} as const; + +const LOCK_MASK = 64 + 128; // Caps Lock + Num Lock + +const CODEPOINTS = { + escape: 27, + tab: 9, + enter: 13, + space: 32, + backspace: 127, + kpEnter: 57414, // Numpad Enter (Kitty protocol) +} as const; + +const ARROW_CODEPOINTS = { + up: -1, + down: -2, + right: -3, + left: -4, +} as const; + +const FUNCTIONAL_CODEPOINTS = { + delete: -10, + insert: -11, + pageUp: -12, + pageDown: -13, + home: -14, + end: -15, +} as const; + +const KITTY_FUNCTIONAL_KEY_EQUIVALENTS = new Map([ + [57399, 48], // KP_0 -> 0 + [57400, 49], // KP_1 -> 1 + [57401, 50], // KP_2 -> 2 + [57402, 51], // KP_3 -> 3 + [57403, 52], // KP_4 -> 4 + [57404, 53], // KP_5 -> 5 + [57405, 54], // KP_6 -> 6 + [57406, 55], // KP_7 -> 7 + [57407, 56], // KP_8 -> 8 + [57408, 57], // KP_9 -> 9 + [57409, 46], // KP_DECIMAL -> . + [57410, 47], // KP_DIVIDE -> / + [57411, 42], // KP_MULTIPLY -> * + [57412, 45], // KP_SUBTRACT -> - + [57413, 43], // KP_ADD -> + + [57415, 61], // KP_EQUAL -> = + [57416, 44], // KP_SEPARATOR -> , + [57417, ARROW_CODEPOINTS.left], + [57418, ARROW_CODEPOINTS.right], + [57419, ARROW_CODEPOINTS.up], + [57420, ARROW_CODEPOINTS.down], + [57421, FUNCTIONAL_CODEPOINTS.pageUp], + [57422, FUNCTIONAL_CODEPOINTS.pageDown], + [57423, FUNCTIONAL_CODEPOINTS.home], + [57424, FUNCTIONAL_CODEPOINTS.end], + [57425, FUNCTIONAL_CODEPOINTS.insert], + [57426, FUNCTIONAL_CODEPOINTS.delete], +]); + +function normalizeKittyFunctionalCodepoint(codepoint: number): number { + return KITTY_FUNCTIONAL_KEY_EQUIVALENTS.get(codepoint) ?? codepoint; +} + +function normalizeShiftedLetterIdentityCodepoint(codepoint: number, modifier: number): number { + const effectiveModifier = modifier & ~LOCK_MASK; + if ((effectiveModifier & MODIFIERS.shift) !== 0 && codepoint >= 65 && codepoint <= 90) { + return codepoint + 32; + } + return codepoint; +} + +const LEGACY_KEY_SEQUENCES = { + up: ["\x1b[A", "\x1bOA"], + down: ["\x1b[B", "\x1bOB"], + right: ["\x1b[C", "\x1bOC"], + left: ["\x1b[D", "\x1bOD"], + home: ["\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"], + end: ["\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"], + insert: ["\x1b[2~"], + delete: ["\x1b[3~"], + pageUp: ["\x1b[5~", "\x1b[[5~"], + pageDown: ["\x1b[6~", "\x1b[[6~"], + clear: ["\x1b[E", "\x1bOE"], + f1: ["\x1bOP", "\x1b[11~", "\x1b[[A"], + f2: ["\x1bOQ", "\x1b[12~", "\x1b[[B"], + f3: ["\x1bOR", "\x1b[13~", "\x1b[[C"], + f4: ["\x1bOS", "\x1b[14~", "\x1b[[D"], + f5: ["\x1b[15~", "\x1b[[E"], + f6: ["\x1b[17~"], + f7: ["\x1b[18~"], + f8: ["\x1b[19~"], + f9: ["\x1b[20~"], + f10: ["\x1b[21~"], + f11: ["\x1b[23~"], + f12: ["\x1b[24~"], +} as const; + +const LEGACY_SHIFT_SEQUENCES = { + up: ["\x1b[a"], + down: ["\x1b[b"], + right: ["\x1b[c"], + left: ["\x1b[d"], + clear: ["\x1b[e"], + insert: ["\x1b[2$"], + delete: ["\x1b[3$"], + pageUp: ["\x1b[5$"], + pageDown: ["\x1b[6$"], + home: ["\x1b[7$"], + end: ["\x1b[8$"], +} as const; + +const LEGACY_CTRL_SEQUENCES = { + up: ["\x1bOa"], + down: ["\x1bOb"], + right: ["\x1bOc"], + left: ["\x1bOd"], + clear: ["\x1bOe"], + insert: ["\x1b[2^"], + delete: ["\x1b[3^"], + pageUp: ["\x1b[5^"], + pageDown: ["\x1b[6^"], + home: ["\x1b[7^"], + end: ["\x1b[8^"], +} as const; + +const LEGACY_SEQUENCE_KEY_IDS: Record = { + "\x1bOA": "up", + "\x1bOB": "down", + "\x1bOC": "right", + "\x1bOD": "left", + "\x1bOH": "home", + "\x1bOF": "end", + "\x1b[E": "clear", + "\x1bOE": "clear", + "\x1bOe": "ctrl+clear", + "\x1b[e": "shift+clear", + "\x1b[2~": "insert", + "\x1b[2$": "shift+insert", + "\x1b[2^": "ctrl+insert", + "\x1b[3$": "shift+delete", + "\x1b[3^": "ctrl+delete", + "\x1b[[5~": "pageUp", + "\x1b[[6~": "pageDown", + "\x1b[a": "shift+up", + "\x1b[b": "shift+down", + "\x1b[c": "shift+right", + "\x1b[d": "shift+left", + "\x1bOa": "ctrl+up", + "\x1bOb": "ctrl+down", + "\x1bOc": "ctrl+right", + "\x1bOd": "ctrl+left", + "\x1b[5$": "shift+pageUp", + "\x1b[6$": "shift+pageDown", + "\x1b[7$": "shift+home", + "\x1b[8$": "shift+end", + "\x1b[5^": "ctrl+pageUp", + "\x1b[6^": "ctrl+pageDown", + "\x1b[7^": "ctrl+home", + "\x1b[8^": "ctrl+end", + "\x1bOP": "f1", + "\x1bOQ": "f2", + "\x1bOR": "f3", + "\x1bOS": "f4", + "\x1b[11~": "f1", + "\x1b[12~": "f2", + "\x1b[13~": "f3", + "\x1b[14~": "f4", + "\x1b[[A": "f1", + "\x1b[[B": "f2", + "\x1b[[C": "f3", + "\x1b[[D": "f4", + "\x1b[[E": "f5", + "\x1b[15~": "f5", + "\x1b[17~": "f6", + "\x1b[18~": "f7", + "\x1b[19~": "f8", + "\x1b[20~": "f9", + "\x1b[21~": "f10", + "\x1b[23~": "f11", + "\x1b[24~": "f12", + "\x1bb": "alt+left", + "\x1bf": "alt+right", + "\x1bp": "alt+up", + "\x1bn": "alt+down", +} as const; + +type LegacyModifierKey = keyof typeof LEGACY_SHIFT_SEQUENCES; + +const matchesLegacySequence = (data: string, sequences: readonly string[]): boolean => sequences.includes(data); + +const matchesLegacyModifierSequence = (data: string, key: LegacyModifierKey, modifier: number): boolean => { + if (modifier === MODIFIERS.shift) { + return matchesLegacySequence(data, LEGACY_SHIFT_SEQUENCES[key]); + } + if (modifier === MODIFIERS.ctrl) { + return matchesLegacySequence(data, LEGACY_CTRL_SEQUENCES[key]); + } + return false; +}; + +// ============================================================================= +// Kitty Protocol Parsing +// ============================================================================= + +/** + * Event types from Kitty keyboard protocol (flag 2) + * 1 = key press, 2 = key repeat, 3 = key release + */ +export type KeyEventType = "press" | "repeat" | "release"; + +interface ParsedKittySequence { + codepoint: number; + shiftedKey?: number; // Shifted version of the key (when shift is pressed) + baseLayoutKey?: number; // Key in standard PC-101 layout (for non-Latin layouts) + modifier: number; + eventType: KeyEventType; +} + +interface ParsedModifyOtherKeysSequence { + codepoint: number; + modifier: number; +} + +// Store the last parsed event type for isKeyRelease() to query +let _lastEventType: KeyEventType = "press"; + +/** + * Check if the last parsed key event was a key release. + * Only meaningful when Kitty keyboard protocol with flag 2 is active. + */ +export function isKeyRelease(data: string): boolean { + // Don't treat bracketed paste content as key release, even if it contains + // patterns like ":3F" (e.g., bluetooth MAC addresses like "90:62:3F:A5"). + // Terminal.ts re-wraps paste content with bracketed paste markers before + // passing to TUI, so pasted data will always contain \x1b[200~. + if (data.includes("\x1b[200~")) { + return false; + } + + // Quick check: release events with flag 2 contain ":3" + // Format: \x1b[;:3u + if ( + data.includes(":3u") || + data.includes(":3~") || + data.includes(":3A") || + data.includes(":3B") || + data.includes(":3C") || + data.includes(":3D") || + data.includes(":3H") || + data.includes(":3F") + ) { + return true; + } + return false; +} + +/** + * Check if the last parsed key event was a key repeat. + * Only meaningful when Kitty keyboard protocol with flag 2 is active. + */ +export function isKeyRepeat(data: string): boolean { + // Don't treat bracketed paste content as key repeat, even if it contains + // patterns like ":2F". See isKeyRelease() for details. + if (data.includes("\x1b[200~")) { + return false; + } + + if ( + data.includes(":2u") || + data.includes(":2~") || + data.includes(":2A") || + data.includes(":2B") || + data.includes(":2C") || + data.includes(":2D") || + data.includes(":2H") || + data.includes(":2F") + ) { + return true; + } + return false; +} + +function parseEventType(eventTypeStr: string | undefined): KeyEventType { + if (!eventTypeStr) return "press"; + const eventType = parseInt(eventTypeStr, 10); + if (eventType === 2) return "repeat"; + if (eventType === 3) return "release"; + return "press"; +} + +function parseKittySequence(data: string): ParsedKittySequence | null { + // CSI u format with alternate keys (flag 4): + // \x1b[u + // \x1b[;u + // \x1b[;:u + // \x1b[:;u + // \x1b[::;u + // \x1b[::;u (no shifted key, only base) + // + // With flag 2, event type is appended after modifier colon: 1=press, 2=repeat, 3=release + // With flag 4, alternate keys are appended after codepoint with colons + const csiUMatch = data.match(/^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/); + if (csiUMatch) { + const codepoint = parseInt(csiUMatch[1]!, 10); + const shiftedKey = csiUMatch[2] && csiUMatch[2].length > 0 ? parseInt(csiUMatch[2], 10) : undefined; + const baseLayoutKey = csiUMatch[3] ? parseInt(csiUMatch[3], 10) : undefined; + const modValue = csiUMatch[4] ? parseInt(csiUMatch[4], 10) : 1; + const eventType = parseEventType(csiUMatch[5]); + _lastEventType = eventType; + return { codepoint, shiftedKey, baseLayoutKey, modifier: modValue - 1, eventType }; + } + + // Arrow keys with modifier: \x1b[1;A/B/C/D or \x1b[1;:A/B/C/D + const arrowMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([ABCD])$/); + if (arrowMatch) { + const modValue = parseInt(arrowMatch[1]!, 10); + const eventType = parseEventType(arrowMatch[2]); + const arrowCodes: Record = { A: -1, B: -2, C: -3, D: -4 }; + _lastEventType = eventType; + return { codepoint: arrowCodes[arrowMatch[3]!]!, modifier: modValue - 1, eventType }; + } + + // Functional keys: \x1b[~ or \x1b[;~ or \x1b[;:~ + const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?~$/); + if (funcMatch) { + const keyNum = parseInt(funcMatch[1]!, 10); + const modValue = funcMatch[2] ? parseInt(funcMatch[2], 10) : 1; + const eventType = parseEventType(funcMatch[3]); + const funcCodes: Record = { + 2: FUNCTIONAL_CODEPOINTS.insert, + 3: FUNCTIONAL_CODEPOINTS.delete, + 5: FUNCTIONAL_CODEPOINTS.pageUp, + 6: FUNCTIONAL_CODEPOINTS.pageDown, + 7: FUNCTIONAL_CODEPOINTS.home, + 8: FUNCTIONAL_CODEPOINTS.end, + }; + const codepoint = funcCodes[keyNum]; + if (codepoint !== undefined) { + _lastEventType = eventType; + return { codepoint, modifier: modValue - 1, eventType }; + } + } + + // Home/End with modifier: \x1b[1;H/F or \x1b[1;:H/F + const homeEndMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([HF])$/); + if (homeEndMatch) { + const modValue = parseInt(homeEndMatch[1]!, 10); + const eventType = parseEventType(homeEndMatch[2]); + const codepoint = homeEndMatch[3] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end; + _lastEventType = eventType; + return { codepoint, modifier: modValue - 1, eventType }; + } + + return null; +} + +function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean { + const parsed = parseKittySequence(data); + if (!parsed) return false; + const actualMod = parsed.modifier & ~LOCK_MASK; + const expectedMod = expectedModifier & ~LOCK_MASK; + + // Check if modifiers match + if (actualMod !== expectedMod) return false; + + const normalizedCodepoint = normalizeShiftedLetterIdentityCodepoint( + normalizeKittyFunctionalCodepoint(parsed.codepoint), + parsed.modifier, + ); + const normalizedExpectedCodepoint = normalizeShiftedLetterIdentityCodepoint( + normalizeKittyFunctionalCodepoint(expectedCodepoint), + expectedModifier, + ); + + // Primary match: codepoint matches directly after normalizing functional keys + if (normalizedCodepoint === normalizedExpectedCodepoint) return true; + + // Alternate match: use base layout key for non-Latin keyboard layouts. + // This allows Ctrl+С (Cyrillic) to match Ctrl+c (Latin) when terminal reports + // the base layout key (the key in standard PC-101 layout). + // + // Only fall back to base layout key when the codepoint is NOT already a + // recognized Latin letter (a-z) or symbol (e.g., /, -, [, ;, etc.). + // When the codepoint is a recognized key, it is authoritative regardless + // of physical key position. This prevents remapped layouts (Dvorak, Colemak, + // xremap, etc.) from causing false matches: both letters and symbols move + // to different physical positions, so Ctrl+K could falsely match Ctrl+V + // (letter remapping) and Ctrl+/ could falsely match Ctrl+[ (symbol remapping) + // if the base layout key were always considered. + if (parsed.baseLayoutKey !== undefined && parsed.baseLayoutKey === expectedCodepoint) { + const cp = normalizedCodepoint; + const isLatinLetter = cp >= 97 && cp <= 122; // a-z + const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(cp)); + if (!isLatinLetter && !isKnownSymbol) return true; + } + + return false; +} + +function parseModifyOtherKeysSequence(data: string): ParsedModifyOtherKeysSequence | null { + const match = data.match(/^\x1b\[27;(\d+);(\d+)~$/); + if (!match) return null; + const modValue = parseInt(match[1]!, 10); + const codepoint = parseInt(match[2]!, 10); + return { codepoint, modifier: modValue - 1 }; +} + +/** + * Match xterm modifyOtherKeys format: CSI 27 ; modifiers ; keycode ~ + * This is used by terminals when Kitty protocol is not enabled. + * Modifier values are 1-indexed: 2=shift, 3=alt, 5=ctrl, etc. + */ +function matchesModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean { + const parsed = parseModifyOtherKeysSequence(data); + if (!parsed) return false; + return parsed.codepoint === expectedKeycode && parsed.modifier === expectedModifier; +} + +function isWindowsTerminalSession(): boolean { + return ( + Boolean(process.env['WT_SESSION']) && !process.env['SSH_CONNECTION'] && !process.env['SSH_CLIENT'] && !process.env['SSH_TTY'] + ); +} + +/** + * Raw 0x08 (BS) is ambiguous in legacy terminals. + * + * - Windows Terminal uses it for Ctrl+Backspace. + * - Some legacy terminals and tmux setups send it for plain Backspace. + * + * Prefer explicit Kitty / CSI-u / modifyOtherKeys sequences whenever they are + * available. Fall back to a Windows Terminal heuristic only for raw BS bytes. + */ +function matchesRawBackspace(data: string, expectedModifier: number): boolean { + if (data === "\x7f") return expectedModifier === 0; + if (data !== "\x08") return false; + return isWindowsTerminalSession() ? expectedModifier === MODIFIERS.ctrl : expectedModifier === 0; +} + +// ============================================================================= +// Generic Key Matching +// ============================================================================= + +/** + * Get the control character for a key. + * Uses the universal formula: code & 0x1f (mask to lower 5 bits) + * + * Works for: + * - Letters a-z → 1-26 + * - Symbols [\]_ → 27, 28, 29, 31 + * - Also maps - to same as _ (same physical key on US keyboards) + */ +function rawCtrlChar(key: string): string | null { + const char = key.toLowerCase(); + const code = char.charCodeAt(0); + if ((code >= 97 && code <= 122) || char === "[" || char === "\\" || char === "]" || char === "_") { + return String.fromCharCode(code & 0x1f); + } + // Handle - as _ (same physical key on US keyboards) + if (char === "-") { + return String.fromCharCode(31); // Same as Ctrl+_ + } + return null; +} + +function isDigitKey(key: string): boolean { + return key >= "0" && key <= "9"; +} + +function matchesPrintableModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean { + if (expectedModifier === 0) return false; + const parsed = parseModifyOtherKeysSequence(data); + if (!parsed || parsed.modifier !== expectedModifier) return false; + return ( + normalizeShiftedLetterIdentityCodepoint(parsed.codepoint, parsed.modifier) === + normalizeShiftedLetterIdentityCodepoint(expectedKeycode, expectedModifier) + ); +} + +function formatKeyNameWithModifiers(keyName: string, modifier: number): string | undefined { + const mods: string[] = []; + const effectiveMod = modifier & ~LOCK_MASK; + const supportedModifierMask = MODIFIERS.shift | MODIFIERS.ctrl | MODIFIERS.alt | MODIFIERS.super; + if ((effectiveMod & ~supportedModifierMask) !== 0) return undefined; + if (effectiveMod & MODIFIERS.shift) mods.push("shift"); + if (effectiveMod & MODIFIERS.ctrl) mods.push("ctrl"); + if (effectiveMod & MODIFIERS.alt) mods.push("alt"); + if (effectiveMod & MODIFIERS.super) mods.push("super"); + return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName; +} + +function parseKeyId( + keyId: string, +): { key: string; ctrl: boolean; shift: boolean; alt: boolean; super: boolean } | null { + const parts = keyId.toLowerCase().split("+"); + const key = parts[parts.length - 1]; + if (!key) return null; + return { + key, + ctrl: parts.includes("ctrl"), + shift: parts.includes("shift"), + alt: parts.includes("alt"), + super: parts.includes("super"), + }; +} + +/** + * Match input data against a key identifier string. + * + * Supported key identifiers: + * - Single keys: "escape", "tab", "enter", "backspace", "delete", "home", "end", "space" + * - Arrow keys: "up", "down", "left", "right" + * - Ctrl combinations: "ctrl+c", "ctrl+z", etc. + * - Shift combinations: "shift+tab", "shift+enter" + * - Alt combinations: "alt+enter", "alt+backspace" + * - Super combinations: "super+k", "super+enter" + * - Combined modifiers: "shift+ctrl+p", "ctrl+alt+x", "ctrl+super+k" + * + * Use the Key helper for autocomplete: Key.ctrl("c"), Key.escape, Key.ctrlShift("p"), Key.super("k") + * + * @param data - Raw input data from terminal + * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c")) + */ +export function matchesKey(data: string, keyId: KeyId): boolean { + const parsed = parseKeyId(keyId); + if (!parsed) return false; + + const { key, ctrl, shift, alt, super: superModifier } = parsed; + let modifier = 0; + if (shift) modifier |= MODIFIERS.shift; + if (alt) modifier |= MODIFIERS.alt; + if (ctrl) modifier |= MODIFIERS.ctrl; + if (superModifier) modifier |= MODIFIERS.super; + + switch (key) { + case "escape": + case "esc": + if (modifier !== 0) return false; + return ( + data === "\x1b" || + matchesKittySequence(data, CODEPOINTS.escape, 0) || + matchesModifyOtherKeys(data, CODEPOINTS.escape, 0) + ); + + case "space": + if (!_kittyProtocolActive) { + if (modifier === MODIFIERS.ctrl && data === "\x00") { + return true; + } + if (modifier === MODIFIERS.alt && data === "\x1b ") { + return true; + } + } + if (modifier === 0) { + return ( + data === " " || + matchesKittySequence(data, CODEPOINTS.space, 0) || + matchesModifyOtherKeys(data, CODEPOINTS.space, 0) + ); + } + return ( + matchesKittySequence(data, CODEPOINTS.space, modifier) || + matchesModifyOtherKeys(data, CODEPOINTS.space, modifier) + ); + + case "tab": + if (modifier === MODIFIERS.shift) { + return ( + data === "\x1b[Z" || + matchesKittySequence(data, CODEPOINTS.tab, MODIFIERS.shift) || + matchesModifyOtherKeys(data, CODEPOINTS.tab, MODIFIERS.shift) + ); + } + if (modifier === 0) { + return data === "\t" || matchesKittySequence(data, CODEPOINTS.tab, 0); + } + return ( + matchesKittySequence(data, CODEPOINTS.tab, modifier) || + matchesModifyOtherKeys(data, CODEPOINTS.tab, modifier) + ); + + case "enter": + case "return": + if (modifier === MODIFIERS.shift) { + // CSI u sequences (standard Kitty protocol) + if ( + matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) || + matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.shift) + ) { + return true; + } + // xterm modifyOtherKeys format (fallback when Kitty protocol not enabled) + if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.shift)) { + return true; + } + // When Kitty protocol is active, legacy sequences are custom terminal mappings + // \x1b\r = Kitty's "map shift+enter send_text all \e\r" + // \n = Ghostty's "keybind = shift+enter=text:\n" + if (_kittyProtocolActive) { + return data === "\x1b\r" || data === "\n"; + } + return false; + } + if (modifier === MODIFIERS.alt) { + // CSI u sequences (standard Kitty protocol) + if ( + matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) || + matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.alt) + ) { + return true; + } + // xterm modifyOtherKeys format (fallback when Kitty protocol not enabled) + if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.alt)) { + return true; + } + // \x1b\r is alt+enter only in legacy mode (no Kitty protocol) + // When Kitty protocol is active, alt+enter comes as CSI u sequence + if (!_kittyProtocolActive) { + return data === "\x1b\r"; + } + return false; + } + if (modifier === 0) { + return ( + data === "\r" || + (!_kittyProtocolActive && data === "\n") || + data === "\x1bOM" || // SS3 M (numpad enter in some terminals) + matchesKittySequence(data, CODEPOINTS.enter, 0) || + matchesKittySequence(data, CODEPOINTS.kpEnter, 0) + ); + } + return ( + matchesKittySequence(data, CODEPOINTS.enter, modifier) || + matchesKittySequence(data, CODEPOINTS.kpEnter, modifier) || + matchesModifyOtherKeys(data, CODEPOINTS.enter, modifier) + ); + + case "backspace": + if (modifier === MODIFIERS.alt) { + if (data === "\x1b\x7f" || data === "\x1b\b") { + return true; + } + return ( + matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt) || + matchesModifyOtherKeys(data, CODEPOINTS.backspace, MODIFIERS.alt) + ); + } + if (modifier === MODIFIERS.ctrl) { + // Legacy raw 0x08 is ambiguous: it can be Ctrl+Backspace on Windows + // Terminal or plain Backspace on other terminals, while also + // overlapping with Ctrl+H. + if (matchesRawBackspace(data, MODIFIERS.ctrl)) return true; + return ( + matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.ctrl) || + matchesModifyOtherKeys(data, CODEPOINTS.backspace, MODIFIERS.ctrl) + ); + } + if (modifier === 0) { + return ( + matchesRawBackspace(data, 0) || + matchesKittySequence(data, CODEPOINTS.backspace, 0) || + matchesModifyOtherKeys(data, CODEPOINTS.backspace, 0) + ); + } + return ( + matchesKittySequence(data, CODEPOINTS.backspace, modifier) || + matchesModifyOtherKeys(data, CODEPOINTS.backspace, modifier) + ); + + case "insert": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.insert) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.insert, 0) + ); + } + if (matchesLegacyModifierSequence(data, "insert", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.insert, modifier); + + case "delete": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.delete) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0) + ); + } + if (matchesLegacyModifierSequence(data, "delete", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, modifier); + + case "clear": + if (modifier === 0) { + return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.clear); + } + return matchesLegacyModifierSequence(data, "clear", modifier); + + case "home": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.home) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, 0) + ); + } + if (matchesLegacyModifierSequence(data, "home", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, modifier); + + case "end": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.end) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, 0) + ); + } + if (matchesLegacyModifierSequence(data, "end", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, modifier); + + case "pageup": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageUp) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, 0) + ); + } + if (matchesLegacyModifierSequence(data, "pageUp", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, modifier); + + case "pagedown": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageDown) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, 0) + ); + } + if (matchesLegacyModifierSequence(data, "pageDown", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, modifier); + + case "up": + if (modifier === MODIFIERS.alt) { + return data === "\x1bp" || matchesKittySequence(data, ARROW_CODEPOINTS.up, MODIFIERS.alt); + } + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.up) || + matchesKittySequence(data, ARROW_CODEPOINTS.up, 0) + ); + } + if (matchesLegacyModifierSequence(data, "up", modifier)) { + return true; + } + return matchesKittySequence(data, ARROW_CODEPOINTS.up, modifier); + + case "down": + if (modifier === MODIFIERS.alt) { + return data === "\x1bn" || matchesKittySequence(data, ARROW_CODEPOINTS.down, MODIFIERS.alt); + } + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.down) || + matchesKittySequence(data, ARROW_CODEPOINTS.down, 0) + ); + } + if (matchesLegacyModifierSequence(data, "down", modifier)) { + return true; + } + return matchesKittySequence(data, ARROW_CODEPOINTS.down, modifier); + + case "left": + if (modifier === MODIFIERS.alt) { + return ( + data === "\x1b[1;3D" || + (!_kittyProtocolActive && data === "\x1bB") || + data === "\x1bb" || + matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.alt) + ); + } + if (modifier === MODIFIERS.ctrl) { + return ( + data === "\x1b[1;5D" || + matchesLegacyModifierSequence(data, "left", MODIFIERS.ctrl) || + matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.ctrl) + ); + } + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.left) || + matchesKittySequence(data, ARROW_CODEPOINTS.left, 0) + ); + } + if (matchesLegacyModifierSequence(data, "left", modifier)) { + return true; + } + return matchesKittySequence(data, ARROW_CODEPOINTS.left, modifier); + + case "right": + if (modifier === MODIFIERS.alt) { + return ( + data === "\x1b[1;3C" || + (!_kittyProtocolActive && data === "\x1bF") || + data === "\x1bf" || + matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.alt) + ); + } + if (modifier === MODIFIERS.ctrl) { + return ( + data === "\x1b[1;5C" || + matchesLegacyModifierSequence(data, "right", MODIFIERS.ctrl) || + matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.ctrl) + ); + } + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.right) || + matchesKittySequence(data, ARROW_CODEPOINTS.right, 0) + ); + } + if (matchesLegacyModifierSequence(data, "right", modifier)) { + return true; + } + return matchesKittySequence(data, ARROW_CODEPOINTS.right, modifier); + + case "f1": + case "f2": + case "f3": + case "f4": + case "f5": + case "f6": + case "f7": + case "f8": + case "f9": + case "f10": + case "f11": + case "f12": { + if (modifier !== 0) { + return false; + } + const functionKey = key as keyof typeof LEGACY_KEY_SEQUENCES; + return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES[functionKey]); + } + } + + // Handle single letter/digit keys and symbols + if (key.length === 1 && ((key >= "a" && key <= "z") || isDigitKey(key) || SYMBOL_KEYS.has(key))) { + const codepoint = key.charCodeAt(0); + const rawCtrl = rawCtrlChar(key); + const isLetter = key >= "a" && key <= "z"; + const isDigit = isDigitKey(key); + + if (modifier === MODIFIERS.ctrl + MODIFIERS.alt && !_kittyProtocolActive && rawCtrl) { + // Legacy: ctrl+alt+key is ESC followed by the control character. + // If that legacy form does not match, continue so CSI-u and + // modifyOtherKeys sequences from tmux can still be recognized. + if (data === `\x1b${rawCtrl}`) return true; + } + + if (modifier === MODIFIERS.alt && !_kittyProtocolActive && (isLetter || isDigit)) { + // Legacy: alt+letter/digit is ESC followed by the key + if (data === `\x1b${key}`) return true; + } + + if (modifier === MODIFIERS.ctrl) { + // Legacy: ctrl+key sends the control character + if (rawCtrl && data === rawCtrl) return true; + return ( + matchesKittySequence(data, codepoint, MODIFIERS.ctrl) || + matchesPrintableModifyOtherKeys(data, codepoint, MODIFIERS.ctrl) + ); + } + + if (modifier === MODIFIERS.shift + MODIFIERS.ctrl) { + return ( + matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl) || + matchesPrintableModifyOtherKeys(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl) + ); + } + + if (modifier === MODIFIERS.shift) { + // Legacy: shift+letter produces uppercase + if (isLetter && data === key.toUpperCase()) return true; + return ( + matchesKittySequence(data, codepoint, MODIFIERS.shift) || + matchesPrintableModifyOtherKeys(data, codepoint, MODIFIERS.shift) + ); + } + + if (modifier !== 0) { + return ( + matchesKittySequence(data, codepoint, modifier) || + matchesPrintableModifyOtherKeys(data, codepoint, modifier) + ); + } + + // Check both raw char and Kitty sequence (needed for release events) + return data === key || matchesKittySequence(data, codepoint, 0); + } + + return false; +} + +/** + * Parse input data and return the key identifier if recognized. + * + * @param data - Raw input data from terminal + * @returns Key identifier string (e.g., "ctrl+c") or undefined + */ +function formatParsedKey(codepoint: number, modifier: number, baseLayoutKey?: number): string | undefined { + const normalizedCodepoint = normalizeKittyFunctionalCodepoint(codepoint); + const identityCodepoint = normalizeShiftedLetterIdentityCodepoint(normalizedCodepoint, modifier); + + // Use base layout key only when codepoint is not a recognized Latin + // letter (a-z), digit (0-9), or symbol (/, -, [, ;, etc.). For those, + // the codepoint is authoritative regardless of physical key position. + // This prevents remapped layouts (Dvorak, Colemak, xremap, etc.) from + // reporting the wrong key name based on the QWERTY physical position. + const isLatinLetter = identityCodepoint >= 97 && identityCodepoint <= 122; // a-z + const isDigit = identityCodepoint >= 48 && identityCodepoint <= 57; // 0-9 + const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(identityCodepoint)); + const effectiveCodepoint = + isLatinLetter || isDigit || isKnownSymbol ? identityCodepoint : (baseLayoutKey ?? identityCodepoint); + + let keyName: string | undefined; + if (effectiveCodepoint === CODEPOINTS.escape) keyName = "escape"; + else if (effectiveCodepoint === CODEPOINTS.tab) keyName = "tab"; + else if (effectiveCodepoint === CODEPOINTS.enter || effectiveCodepoint === CODEPOINTS.kpEnter) keyName = "enter"; + else if (effectiveCodepoint === CODEPOINTS.space) keyName = "space"; + else if (effectiveCodepoint === CODEPOINTS.backspace) keyName = "backspace"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.insert) keyName = "insert"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = "pageUp"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = "pageDown"; + else if (effectiveCodepoint === ARROW_CODEPOINTS.up) keyName = "up"; + else if (effectiveCodepoint === ARROW_CODEPOINTS.down) keyName = "down"; + else if (effectiveCodepoint === ARROW_CODEPOINTS.left) keyName = "left"; + else if (effectiveCodepoint === ARROW_CODEPOINTS.right) keyName = "right"; + else if (effectiveCodepoint >= 48 && effectiveCodepoint <= 57) keyName = String.fromCharCode(effectiveCodepoint); + else if (effectiveCodepoint >= 97 && effectiveCodepoint <= 122) keyName = String.fromCharCode(effectiveCodepoint); + else if (SYMBOL_KEYS.has(String.fromCharCode(effectiveCodepoint))) keyName = String.fromCharCode(effectiveCodepoint); + + if (!keyName) return undefined; + return formatKeyNameWithModifiers(keyName, modifier); +} + +export function parseKey(data: string): string | undefined { + const kitty = parseKittySequence(data); + if (kitty) { + return formatParsedKey(kitty.codepoint, kitty.modifier, kitty.baseLayoutKey); + } + + const modifyOtherKeys = parseModifyOtherKeysSequence(data); + if (modifyOtherKeys) { + return formatParsedKey(modifyOtherKeys.codepoint, modifyOtherKeys.modifier); + } + + // Mode-aware legacy sequences + // When Kitty protocol is active, ambiguous sequences are interpreted as custom terminal mappings: + // - \x1b\r = shift+enter (Kitty mapping), not alt+enter + // - \n = shift+enter (Ghostty mapping) + if (_kittyProtocolActive) { + if (data === "\x1b\r" || data === "\n") return "shift+enter"; + } + + const legacySequenceKeyId = LEGACY_SEQUENCE_KEY_IDS[data]; + if (legacySequenceKeyId) return legacySequenceKeyId; + + // Legacy sequences (used when Kitty protocol is not active, or for unambiguous sequences) + if (data === "\x1b") return "escape"; + if (data === "\x1c") return "ctrl+\\"; + if (data === "\x1d") return "ctrl+]"; + if (data === "\x1f") return "ctrl+-"; + if (data === "\x1b\x1b") return "ctrl+alt+["; + if (data === "\x1b\x1c") return "ctrl+alt+\\"; + if (data === "\x1b\x1d") return "ctrl+alt+]"; + if (data === "\x1b\x1f") return "ctrl+alt+-"; + if (data === "\t") return "tab"; + if (data === "\r" || (!_kittyProtocolActive && data === "\n") || data === "\x1bOM") return "enter"; + if (data === "\x00") return "ctrl+space"; + if (data === " ") return "space"; + if (data === "\x7f") return "backspace"; + if (data === "\x08") return isWindowsTerminalSession() ? "ctrl+backspace" : "backspace"; + if (data === "\x1b[Z") return "shift+tab"; + if (!_kittyProtocolActive && data === "\x1b\r") return "alt+enter"; + if (!_kittyProtocolActive && data === "\x1b ") return "alt+space"; + if (data === "\x1b\x7f" || data === "\x1b\b") return "alt+backspace"; + if (!_kittyProtocolActive && data === "\x1bB") return "alt+left"; + if (!_kittyProtocolActive && data === "\x1bF") return "alt+right"; + if (!_kittyProtocolActive && data.length === 2 && data[0] === "\x1b") { + const code = data.charCodeAt(1); + if (code >= 1 && code <= 26) { + return `ctrl+alt+${String.fromCharCode(code + 96)}`; + } + // Legacy alt+letter/digit (ESC followed by the key) + if ((code >= 97 && code <= 122) || (code >= 48 && code <= 57)) { + return `alt+${String.fromCharCode(code)}`; + } + } + if (data === "\x1b[A") return "up"; + if (data === "\x1b[B") return "down"; + if (data === "\x1b[C") return "right"; + if (data === "\x1b[D") return "left"; + if (data === "\x1b[H" || data === "\x1bOH") return "home"; + if (data === "\x1b[F" || data === "\x1bOF") return "end"; + if (data === "\x1b[3~") return "delete"; + if (data === "\x1b[5~") return "pageUp"; + if (data === "\x1b[6~") return "pageDown"; + + // Raw Ctrl+letter + if (data.length === 1) { + const code = data.charCodeAt(0); + if (code >= 1 && code <= 26) { + return `ctrl+${String.fromCharCode(code + 96)}`; + } + if (code >= 32 && code <= 126) { + return data; + } + } + + return undefined; +} + +// ============================================================================= +// Kitty CSI-u Printable Decoding +// ============================================================================= + +const KITTY_CSI_U_REGEX = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/; +const KITTY_PRINTABLE_ALLOWED_MODIFIERS = MODIFIERS.shift | LOCK_MASK; + +/** + * Decode a Kitty CSI-u sequence into a printable character, if applicable. + * + * When Kitty keyboard protocol flag 1 (disambiguate) is active, terminals send + * CSI-u sequences for all keys, including plain printable characters. This + * function extracts the printable character from such sequences. + * + * Only accepts plain or Shift-modified keys. Rejects Ctrl, Alt, and unsupported + * modifier combinations (those are handled by keybinding matching instead). + * Prefers the shifted keycode when Shift is held and a shifted key is reported. + * + * @param data - Raw input data from terminal + * @returns The printable character, or undefined if not a printable CSI-u sequence + */ +export function decodeKittyPrintable(data: string): string | undefined { + const match = data.match(KITTY_CSI_U_REGEX); + if (!match) return undefined; + + // CSI-u groups: [:[:]];[:]u + const codepoint = Number.parseInt(match[1] ?? "", 10); + if (!Number.isFinite(codepoint)) return undefined; + + const shiftedKey = match[2] && match[2].length > 0 ? Number.parseInt(match[2], 10) : undefined; + const modValue = match[4] ? Number.parseInt(match[4], 10) : 1; + // Modifiers are 1-indexed in CSI-u; normalize to our bitmask. + const modifier = Number.isFinite(modValue) ? modValue - 1 : 0; + + // Only accept printable CSI-u input for plain or Shift-modified text keys. + // Reject unsupported modifier bits (e.g. Super/Meta) to avoid inserting + // characters from modifier-only terminal events. + if ((modifier & ~KITTY_PRINTABLE_ALLOWED_MODIFIERS) !== 0) return undefined; + if (modifier & (MODIFIERS.alt | MODIFIERS.ctrl)) return undefined; + + // Prefer the shifted keycode when Shift is held. + let effectiveCodepoint = codepoint; + if (modifier & MODIFIERS.shift && typeof shiftedKey === "number") { + effectiveCodepoint = shiftedKey; + } + effectiveCodepoint = normalizeKittyFunctionalCodepoint(effectiveCodepoint); + // Drop control characters or invalid codepoints. + if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32) return undefined; + + try { + return String.fromCodePoint(effectiveCodepoint); + } catch { + return undefined; + } +} + +function decodeModifyOtherKeysPrintable(data: string): string | undefined { + const parsed = parseModifyOtherKeysSequence(data); + if (!parsed) return undefined; + const modifier = parsed.modifier & ~LOCK_MASK; + if ((modifier & ~MODIFIERS.shift) !== 0) return undefined; + if (!Number.isFinite(parsed.codepoint) || parsed.codepoint < 32) return undefined; + + try { + return String.fromCodePoint(parsed.codepoint); + } catch { + return undefined; + } +} + +export function decodePrintableKey(data: string): string | undefined { + return decodeKittyPrintable(data) ?? decodeModifyOtherKeysPrintable(data); +} diff --git a/packages/pi-tui/src/kill-ring.ts b/packages/pi-tui/src/kill-ring.ts new file mode 100644 index 000000000..2292f91aa --- /dev/null +++ b/packages/pi-tui/src/kill-ring.ts @@ -0,0 +1,46 @@ +/** + * Ring buffer for Emacs-style kill/yank operations. + * + * Tracks killed (deleted) text entries. Consecutive kills can accumulate + * into a single entry. Supports yank (paste most recent) and yank-pop + * (cycle through older entries). + */ +export class KillRing { + private ring: string[] = []; + + /** + * Add text to the kill ring. + * + * @param text - The killed text to add + * @param opts - Push options + * @param opts.prepend - If accumulating, prepend (backward deletion) or append (forward deletion) + * @param opts.accumulate - Merge with the most recent entry instead of creating a new one + */ + push(text: string, opts: { prepend: boolean; accumulate?: boolean }): void { + if (!text) return; + + if (opts.accumulate && this.ring.length > 0) { + const last = this.ring.pop()!; + this.ring.push(opts.prepend ? text + last : last + text); + } else { + this.ring.push(text); + } + } + + /** Get most recent entry without modifying the ring. */ + peek(): string | undefined { + return this.ring.length > 0 ? this.ring[this.ring.length - 1] : undefined; + } + + /** Move last entry to front (for yank-pop cycling). */ + rotate(): void { + if (this.ring.length > 1) { + const last = this.ring.pop()!; + this.ring.unshift(last); + } + } + + get length(): number { + return this.ring.length; + } +} diff --git a/packages/pi-tui/src/native-modifiers.ts b/packages/pi-tui/src/native-modifiers.ts new file mode 100644 index 000000000..e2cd631cb --- /dev/null +++ b/packages/pi-tui/src/native-modifiers.ts @@ -0,0 +1,59 @@ +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const cjsRequire = createRequire(import.meta.url); + +export type ModifierKey = "shift" | "command" | "control" | "option"; + +type NativeModifiersHelper = { + isModifierPressed: (name: ModifierKey) => boolean; +}; + +let nativeModifiersHelper: NativeModifiersHelper | null | undefined; + +function isNativeModifiersHelper(value: unknown): value is NativeModifiersHelper { + if (typeof value !== "object" || value === null) return false; + const candidate = (value as { isModifierPressed?: unknown }).isModifierPressed; + return typeof candidate === "function"; +} + +function loadNativeModifiersHelper(): NativeModifiersHelper | undefined { + if (nativeModifiersHelper !== undefined) return nativeModifiersHelper ?? undefined; + nativeModifiersHelper = null; + if (process.platform !== "darwin") return undefined; + const arch = process.arch; + if (arch !== "x64" && arch !== "arm64") return undefined; + + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const nativePath = path.join("native", "darwin", "prebuilds", `darwin-${arch}`, "darwin-modifiers.node"); + const candidates = [ + path.join(moduleDir, "..", nativePath), + path.join(moduleDir, nativePath), + path.join(path.dirname(process.execPath), nativePath), + ]; + + for (const modulePath of candidates) { + try { + const helper = cjsRequire(modulePath) as unknown; + if (isNativeModifiersHelper(helper)) { + nativeModifiersHelper = helper; + return helper; + } + } catch { + // Try the next possible packaging location. + } + } + + return undefined; +} + +export function isNativeModifierPressed(key: ModifierKey): boolean { + const helper = loadNativeModifiersHelper(); + if (!helper) return false; + try { + return helper.isModifierPressed(key) === true; + } catch { + return false; + } +} diff --git a/packages/pi-tui/src/stdin-buffer.ts b/packages/pi-tui/src/stdin-buffer.ts new file mode 100644 index 000000000..89ddfdf38 --- /dev/null +++ b/packages/pi-tui/src/stdin-buffer.ts @@ -0,0 +1,434 @@ +/** + * StdinBuffer buffers input and emits complete sequences. + * + * This is necessary because stdin data events can arrive in partial chunks, + * especially for escape sequences like mouse events. Without buffering, + * partial sequences can be misinterpreted as regular keypresses. + * + * For example, the mouse SGR sequence `\x1b[<35;20;5m` might arrive as: + * - Event 1: `\x1b` + * - Event 2: `[<35` + * - Event 3: `;20;5m` + * + * The buffer accumulates these until a complete sequence is detected. + * Call the `process()` method to feed input data. + * + * Based on code from OpenTUI (https://github.com/anomalyco/opentui) + * MIT License - Copyright (c) 2025 opentui + */ + +import { EventEmitter } from "events"; + +const ESC = "\x1b"; +const BRACKETED_PASTE_START = "\x1b[200~"; +const BRACKETED_PASTE_END = "\x1b[201~"; + +/** + * Check if a string is a complete escape sequence or needs more data + */ +function isCompleteSequence(data: string): "complete" | "incomplete" | "not-escape" { + if (!data.startsWith(ESC)) { + return "not-escape"; + } + + if (data.length === 1) { + return "incomplete"; + } + + const afterEsc = data.slice(1); + + // CSI sequences: ESC [ + if (afterEsc.startsWith("[")) { + // Check for old-style mouse sequence: ESC[M + 3 bytes + if (afterEsc.startsWith("[M")) { + // Old-style mouse needs ESC[M + 3 bytes = 6 total + return data.length >= 6 ? "complete" : "incomplete"; + } + return isCompleteCsiSequence(data); + } + + // OSC sequences: ESC ] + if (afterEsc.startsWith("]")) { + return isCompleteOscSequence(data); + } + + // DCS sequences: ESC P ... ESC \ (includes XTVersion responses) + if (afterEsc.startsWith("P")) { + return isCompleteDcsSequence(data); + } + + // APC sequences: ESC _ ... ESC \ (includes Kitty graphics responses) + if (afterEsc.startsWith("_")) { + return isCompleteApcSequence(data); + } + + // SS3 sequences: ESC O + if (afterEsc.startsWith("O")) { + // ESC O followed by a single character + return afterEsc.length >= 2 ? "complete" : "incomplete"; + } + + // Meta key sequences: ESC followed by a single character + if (afterEsc.length === 1) { + return "complete"; + } + + // Unknown escape sequence - treat as complete + return "complete"; +} + +/** + * Check if CSI sequence is complete + * CSI sequences: ESC [ ... followed by a final byte (0x40-0x7E) + */ +function isCompleteCsiSequence(data: string): "complete" | "incomplete" { + if (!data.startsWith(`${ESC}[`)) { + return "complete"; + } + + // Need at least ESC [ and one more character + if (data.length < 3) { + return "incomplete"; + } + + const payload = data.slice(2); + + // CSI sequences end with a byte in the range 0x40-0x7E (@-~) + // This includes all letters and several special characters + const lastChar = payload[payload.length - 1]!; + const lastCharCode = lastChar.charCodeAt(0); + + if (lastCharCode >= 0x40 && lastCharCode <= 0x7e) { + // Special handling for SGR mouse sequences + // Format: ESC[ /^\d+$/.test(p))) { + return "complete"; + } + } + + return "incomplete"; + } + + return "complete"; + } + + return "incomplete"; +} + +/** + * Check if OSC sequence is complete + * OSC sequences: ESC ] ... ST (where ST is ESC \ or BEL) + */ +function isCompleteOscSequence(data: string): "complete" | "incomplete" { + if (!data.startsWith(`${ESC}]`)) { + return "complete"; + } + + // OSC sequences end with ST (ESC \) or BEL (\x07) + if (data.endsWith(`${ESC}\\`) || data.endsWith("\x07")) { + return "complete"; + } + + return "incomplete"; +} + +/** + * Check if DCS (Device Control String) sequence is complete + * DCS sequences: ESC P ... ST (where ST is ESC \) + * Used for XTVersion responses like ESC P >| ... ESC \ + */ +function isCompleteDcsSequence(data: string): "complete" | "incomplete" { + if (!data.startsWith(`${ESC}P`)) { + return "complete"; + } + + // DCS sequences end with ST (ESC \) + if (data.endsWith(`${ESC}\\`)) { + return "complete"; + } + + return "incomplete"; +} + +/** + * Check if APC (Application Program Command) sequence is complete + * APC sequences: ESC _ ... ST (where ST is ESC \) + * Used for Kitty graphics responses like ESC _ G ... ESC \ + */ +function isCompleteApcSequence(data: string): "complete" | "incomplete" { + if (!data.startsWith(`${ESC}_`)) { + return "complete"; + } + + // APC sequences end with ST (ESC \) + if (data.endsWith(`${ESC}\\`)) { + return "complete"; + } + + return "incomplete"; +} + +/** + * Split accumulated buffer into complete sequences + */ +function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | undefined { + const match = sequence.match(/^\x1b\[(\d+)(?::\d*)?(?::\d+)?u$/); + if (!match) return undefined; + + const codepoint = parseInt(match[1]!, 10); + return codepoint >= 32 ? codepoint : undefined; +} + +function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } { + const sequences: string[] = []; + let pos = 0; + + while (pos < buffer.length) { + const remaining = buffer.slice(pos); + + // Try to extract a sequence starting at this position + if (remaining.startsWith(ESC)) { + // Find the end of this escape sequence + let seqEnd = 1; + while (seqEnd <= remaining.length) { + const candidate = remaining.slice(0, seqEnd); + const status = isCompleteSequence(candidate); + + if (status === "complete") { + // WezTerm with enable_kitty_keyboard sends the Escape key press as a + // raw '\x1b' byte (simple text path in encode_kitty, ignoring + // DISAMBIGUATE_ESCAPE_CODES) and the release as a full Kitty CSI-u + // sequence. These arrive concatenated as '\x1b\x1b[27;...u'. + // The buffer would normally treat '\x1b\x1b' as a complete meta-key + // sequence (ESC + single char), leaving '[27;...u' to be typed as + // plain text. If the character immediately following '\x1b\x1b' + // would begin a new escape sequence, emit only the first ESC and + // restart from the second. + if (candidate === "\x1b\x1b") { + const nextChar = remaining[seqEnd]; + if ( + nextChar === "[" || // CSI + nextChar === "]" || // OSC + nextChar === "O" || // SS3 + nextChar === "P" || // DCS + nextChar === "_" // APC + ) { + sequences.push(ESC); + pos += 1; + break; + } + } + sequences.push(candidate); + pos += seqEnd; + break; + } else if (status === "incomplete") { + seqEnd++; + } else { + // Should not happen when starting with ESC + sequences.push(candidate); + pos += seqEnd; + break; + } + } + + if (seqEnd > remaining.length) { + return { sequences, remainder: remaining }; + } + } else { + // Not an escape sequence - take a single character + sequences.push(remaining[0]!); + pos++; + } + } + + return { sequences, remainder: "" }; +} + +export type StdinBufferOptions = { + /** + * Maximum time to wait for sequence completion (default: 10ms) + * After this time, the buffer is flushed even if incomplete + */ + timeout?: number; +}; + +export type StdinBufferEventMap = { + data: [string]; + paste: [string]; +}; + +/** + * Buffers stdin input and emits complete sequences via the 'data' event. + * Handles partial escape sequences that arrive across multiple chunks. + */ +export class StdinBuffer extends EventEmitter { + private buffer: string = ""; + private timeout: ReturnType | null = null; + private readonly timeoutMs: number; + private pasteMode: boolean = false; + private pasteBuffer: string = ""; + private pendingKittyPrintableCodepoint: number | undefined; + + constructor(options: StdinBufferOptions = {}) { + super(); + this.timeoutMs = options.timeout ?? 10; + } + + public process(data: string | Buffer): void { + // Clear any pending timeout + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + + // Handle high-byte conversion (for compatibility with parseKeypress) + // If buffer has single byte > 127, convert to ESC + (byte - 128) + let str: string; + if (Buffer.isBuffer(data)) { + if (data.length === 1 && data[0]! > 127) { + const byte = data[0]! - 128; + str = `\x1b${String.fromCharCode(byte)}`; + } else { + str = data.toString(); + } + } else { + str = data; + } + + if (str.length === 0 && this.buffer.length === 0) { + this.emitDataSequence(""); + return; + } + + this.buffer += str; + + if (this.pasteMode) { + this.pasteBuffer += this.buffer; + this.buffer = ""; + + const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END); + if (endIndex !== -1) { + const pastedContent = this.pasteBuffer.slice(0, endIndex); + const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length); + + this.pasteMode = false; + this.pasteBuffer = ""; + this.pendingKittyPrintableCodepoint = undefined; + + this.emit("paste", pastedContent); + + if (remaining.length > 0) { + this.process(remaining); + } + } + return; + } + + const startIndex = this.buffer.indexOf(BRACKETED_PASTE_START); + if (startIndex !== -1) { + if (startIndex > 0) { + const beforePaste = this.buffer.slice(0, startIndex); + const result = extractCompleteSequences(beforePaste); + for (const sequence of result.sequences) { + this.emitDataSequence(sequence); + } + } + + this.pendingKittyPrintableCodepoint = undefined; + this.buffer = this.buffer.slice(startIndex + BRACKETED_PASTE_START.length); + this.pasteMode = true; + this.pasteBuffer = this.buffer; + this.buffer = ""; + + const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END); + if (endIndex !== -1) { + const pastedContent = this.pasteBuffer.slice(0, endIndex); + const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length); + + this.pasteMode = false; + this.pasteBuffer = ""; + this.pendingKittyPrintableCodepoint = undefined; + + this.emit("paste", pastedContent); + + if (remaining.length > 0) { + this.process(remaining); + } + } + return; + } + + const result = extractCompleteSequences(this.buffer); + this.buffer = result.remainder; + + for (const sequence of result.sequences) { + this.emitDataSequence(sequence); + } + + if (this.buffer.length > 0) { + this.timeout = setTimeout(() => { + const flushed = this.flush(); + + for (const sequence of flushed) { + this.emitDataSequence(sequence); + } + }, this.timeoutMs); + } + } + + private emitDataSequence(sequence: string): void { + const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined; + if (rawCodepoint !== undefined && rawCodepoint === this.pendingKittyPrintableCodepoint) { + this.pendingKittyPrintableCodepoint = undefined; + return; + } + + this.pendingKittyPrintableCodepoint = parseUnmodifiedKittyPrintableCodepoint(sequence); + this.emit("data", sequence); + } + + flush(): string[] { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + + if (this.buffer.length === 0) { + return []; + } + + const sequences = [this.buffer]; + this.buffer = ""; + this.pendingKittyPrintableCodepoint = undefined; + return sequences; + } + + clear(): void { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + this.buffer = ""; + this.pasteMode = false; + this.pasteBuffer = ""; + this.pendingKittyPrintableCodepoint = undefined; + } + + getBuffer(): string { + return this.buffer; + } + + destroy(): void { + this.clear(); + } +} diff --git a/packages/pi-tui/src/terminal-colors.ts b/packages/pi-tui/src/terminal-colors.ts new file mode 100644 index 000000000..fec02c6b9 --- /dev/null +++ b/packages/pi-tui/src/terminal-colors.ts @@ -0,0 +1,73 @@ +export interface RgbColor { + r: number; + g: number; + b: number; +} + +export type TerminalColorScheme = "dark" | "light"; + +function hexToRgb(hex: string): RgbColor { + const normalized = hex.startsWith("#") ? hex.slice(1) : hex; + const r = parseInt(normalized.slice(0, 2), 16); + const g = parseInt(normalized.slice(2, 4), 16); + const b = parseInt(normalized.slice(4, 6), 16); + return { r, g, b }; +} + +function parseOscHexChannel(channel: string): number | undefined { + if (!/^[0-9a-f]+$/i.test(channel)) { + return undefined; + } + const max = 16 ** channel.length - 1; + if (max <= 0) { + return undefined; + } + return Math.round((parseInt(channel, 16) / max) * 255); +} + +const OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN = /^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i; +const COLOR_SCHEME_REPORT_PATTERN = /^\x1b\[\?997;(1|2)n$/; + +export function isOsc11BackgroundColorResponse(data: string): boolean { + return OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN.test(data); +} + +export function parseOsc11BackgroundColor(data: string): RgbColor | undefined { + const match = data.match(OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN); + if (!match) { + return undefined; + } + + const value = match[1]!.trim(); + if (value.startsWith("#")) { + const hex = value.slice(1); + if (/^[0-9a-f]{6}$/i.test(hex)) { + return hexToRgb(value); + } + if (/^[0-9a-f]{12}$/i.test(hex)) { + const r = parseOscHexChannel(hex.slice(0, 4)); + const g = parseOscHexChannel(hex.slice(4, 8)); + const b = parseOscHexChannel(hex.slice(8, 12)); + return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; + } + return undefined; + } + + const rgbValue = value.replace(/^rgba?:/i, ""); + const [red, green, blue] = rgbValue.split("/"); + if (red === undefined || green === undefined || blue === undefined) { + return undefined; + } + const r = parseOscHexChannel(red); + const g = parseOscHexChannel(green); + const b = parseOscHexChannel(blue); + return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; +} + +export function parseTerminalColorSchemeReport(data: string): TerminalColorScheme | undefined { + const match = data.match(COLOR_SCHEME_REPORT_PATTERN); + if (!match) { + return undefined; + } + return match[1] === "2" ? "light" : "dark"; +} diff --git a/packages/pi-tui/src/terminal-image.ts b/packages/pi-tui/src/terminal-image.ts new file mode 100644 index 000000000..e7878de7a --- /dev/null +++ b/packages/pi-tui/src/terminal-image.ts @@ -0,0 +1,488 @@ +import { execSync } from "node:child_process"; + +export type ImageProtocol = "kitty" | "iterm2" | null; + +export interface TerminalCapabilities { + images: ImageProtocol; + trueColor: boolean; + hyperlinks: boolean; +} + +export interface CellDimensions { + widthPx: number; + heightPx: number; +} + +export interface ImageDimensions { + widthPx: number; + heightPx: number; +} + +export interface ImageRenderOptions { + maxWidthCells?: number; + maxHeightCells?: number; + preserveAspectRatio?: boolean; + /** Kitty image ID. If provided, reuses/replaces existing image with this ID. */ + imageId?: number; + /** Whether Kitty should apply its default cursor movement after placement. */ + moveCursor?: boolean; +} + +let cachedCapabilities: TerminalCapabilities | null = null; + +// Default cell dimensions - updated by TUI when terminal responds to query +let cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }; + +export function getCellDimensions(): CellDimensions { + return cellDimensions; +} + +export function setCellDimensions(dims: CellDimensions): void { + cellDimensions = dims; +} + +/** + * Checks whether the attached tmux client forwards OSC 8 hyperlinks to the + * outer terminal. tmux only re-emits them when its `client_termfeatures` lists + * `hyperlinks`, and strips them otherwise. On any error fallbacks `false`. + */ +function probeTmuxHyperlinks(): boolean { + try { + const termfeatures = execSync("tmux display-message -p '#{client_termfeatures}'", { + encoding: "utf8", + timeout: 250, + stdio: ["ignore", "pipe", "ignore"], + }); + return termfeatures + .split(",") + .map((feature) => feature.trim()) + .includes("hyperlinks"); + } catch { + return false; + } +} + +export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeTmuxHyperlinks): TerminalCapabilities { + const termProgram = process.env['TERM_PROGRAM']?.toLowerCase() || ""; + const terminalEmulator = process.env['TERMINAL_EMULATOR']?.toLowerCase() || ""; + const term = process.env['TERM']?.toLowerCase() || ""; + const colorTerm = process.env['COLORTERM']?.toLowerCase() || ""; + const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit"; + + // Emit OSC 8 hyperlinks only when tmux confirms it forwards. + // Image protocols are unreliable under tmux, so leave `images: null`. + if (process.env['TMUX'] || term.startsWith("tmux")) { + return { images: null, trueColor: hasTrueColorHint, hyperlinks: tmuxForwardsHyperlink() }; + } + + // screen does not forward OSC 8 hyperlinks, so keep them off there. + if (term.startsWith("screen")) { + return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }; + } + + if (process.env['KITTY_WINDOW_ID'] || termProgram === "kitty") { + return { images: "kitty", trueColor: true, hyperlinks: true }; + } + + if (termProgram === "ghostty" || term.includes("ghostty") || process.env['GHOSTTY_RESOURCES_DIR']) { + return { images: "kitty", trueColor: true, hyperlinks: true }; + } + + if (process.env['WEZTERM_PANE'] || termProgram === "wezterm") { + return { images: "kitty", trueColor: true, hyperlinks: true }; + } + + // Warp supports the Kitty graphics protocol and OSC 8 hyperlinks. + if (termProgram === "warpterminal" || process.env['WARP_SESSION_ID'] || process.env['WARP_TERMINAL_SESSION_UUID']) { + return { images: "kitty", trueColor: true, hyperlinks: true }; + } + + if (process.env['ITERM_SESSION_ID'] || termProgram === "iterm.app") { + return { images: "iterm2", trueColor: true, hyperlinks: true }; + } + + if (process.env['WT_SESSION']) { + return { images: null, trueColor: true, hyperlinks: true }; + } + + if (termProgram === "vscode") { + return { images: null, trueColor: true, hyperlinks: true }; + } + + if (termProgram === "alacritty") { + return { images: null, trueColor: true, hyperlinks: true }; + } + + if (terminalEmulator === "jetbrains-jediterm") { + return { images: null, trueColor: true, hyperlinks: false }; + } + + // Unknown terminal: be conservative. OSC 8 is rendered invisibly as "just + // text" on terminals that swallow it, which means the URL disappears from + // the rendered output. Default to the legacy `text (url)` behavior unless we + // have positively identified a hyperlink-capable terminal above. + return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }; +} + +export function getCapabilities(): TerminalCapabilities { + if (!cachedCapabilities) { + cachedCapabilities = detectCapabilities(); + } + return cachedCapabilities; +} + +export function resetCapabilitiesCache(): void { + cachedCapabilities = null; +} + +/** Override the cached capabilities. Useful in tests to exercise both code paths. */ +export function setCapabilities(caps: TerminalCapabilities): void { + cachedCapabilities = caps; +} + +const KITTY_PREFIX = "\x1b_G"; +const ITERM2_PREFIX = "\x1b]1337;File="; + +export function isImageLine(line: string): boolean { + // Fast path: sequence at line start (single-row images) + if (line.startsWith(KITTY_PREFIX) || line.startsWith(ITERM2_PREFIX)) { + return true; + } + // Slow path: sequence elsewhere (multi-row images have cursor-up prefix) + return line.includes(KITTY_PREFIX) || line.includes(ITERM2_PREFIX); +} + +/** + * Generate a random image ID for Kitty graphics protocol. + * Uses random IDs to avoid collisions between different module instances + * (e.g., main app vs extensions). + */ +export function allocateImageId(): number { + // Use random ID in range [1, 0xffffffff] to avoid collisions + return Math.floor(Math.random() * 0xfffffffe) + 1; +} + +export function encodeKitty( + base64Data: string, + options: { + columns?: number; + rows?: number; + imageId?: number; + /** Whether Kitty should apply its default cursor movement after placement. Default: true. */ + moveCursor?: boolean; + } = {}, +): string { + const CHUNK_SIZE = 4096; + + const params: string[] = ["a=T", "f=100", "q=2"]; + + if (options.moveCursor === false) params.push("C=1"); + if (options.columns) params.push(`c=${options.columns}`); + if (options.rows) params.push(`r=${options.rows}`); + if (options.imageId) params.push(`i=${options.imageId}`); + + if (base64Data.length <= CHUNK_SIZE) { + return `\x1b_G${params.join(",")};${base64Data}\x1b\\`; + } + + const chunks: string[] = []; + let offset = 0; + let isFirst = true; + + while (offset < base64Data.length) { + const chunk = base64Data.slice(offset, offset + CHUNK_SIZE); + const isLast = offset + CHUNK_SIZE >= base64Data.length; + + if (isFirst) { + chunks.push(`\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`); + isFirst = false; + } else if (isLast) { + chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`); + } else { + chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`); + } + + offset += CHUNK_SIZE; + } + + return chunks.join(""); +} + +/** + * Delete a Kitty graphics image by ID. + * Uses uppercase 'I' to also free the image data. + */ +export function deleteKittyImage(imageId: number): string { + return `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`; +} + +/** + * Delete all visible Kitty graphics images. + * Uses uppercase 'A' to also free the image data. + */ +export function deleteAllKittyImages(): string { + return "\x1b_Ga=d,d=A,q=2\x1b\\"; +} + +export function encodeITerm2( + base64Data: string, + options: { + width?: number | string; + height?: number | string; + name?: string; + preserveAspectRatio?: boolean; + inline?: boolean; + } = {}, +): string { + const params: string[] = [`inline=${options.inline !== false ? 1 : 0}`]; + + if (options.width !== undefined) params.push(`width=${options.width}`); + if (options.height !== undefined) params.push(`height=${options.height}`); + if (options.name) { + const nameBase64 = Buffer.from(options.name).toString("base64"); + params.push(`name=${nameBase64}`); + } + if (options.preserveAspectRatio === false) { + params.push("preserveAspectRatio=0"); + } + + return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`; +} + +export interface ImageCellSize { + columns: number; + rows: number; +} + +export function calculateImageCellSize( + imageDimensions: ImageDimensions, + maxWidthCells: number, + maxHeightCells?: number, + cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }, +): ImageCellSize { + const maxWidth = Math.max(1, Math.floor(maxWidthCells)); + const maxHeight = maxHeightCells === undefined ? undefined : Math.max(1, Math.floor(maxHeightCells)); + const imageWidth = Math.max(1, imageDimensions.widthPx); + const imageHeight = Math.max(1, imageDimensions.heightPx); + + const widthScale = (maxWidth * cellDimensions.widthPx) / imageWidth; + const heightScale = maxHeight === undefined ? widthScale : (maxHeight * cellDimensions.heightPx) / imageHeight; + const scale = Math.min(widthScale, heightScale); + + const scaledWidthPx = imageWidth * scale; + const scaledHeightPx = imageHeight * scale; + const columns = Math.ceil(scaledWidthPx / cellDimensions.widthPx); + const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx); + + return { + columns: Math.max(1, Math.min(maxWidth, columns)), + rows: Math.max(1, maxHeight === undefined ? rows : Math.min(maxHeight, rows)), + }; +} + +export function calculateImageRows( + imageDimensions: ImageDimensions, + targetWidthCells: number, + cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }, +): number { + return calculateImageCellSize(imageDimensions, targetWidthCells, undefined, cellDimensions).rows; +} + +export function getPngDimensions(base64Data: string): ImageDimensions | null { + try { + const buffer = Buffer.from(base64Data, "base64"); + + if (buffer.length < 24) { + return null; + } + + if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) { + return null; + } + + const width = buffer.readUInt32BE(16); + const height = buffer.readUInt32BE(20); + + return { widthPx: width, heightPx: height }; + } catch { + return null; + } +} + +export function getJpegDimensions(base64Data: string): ImageDimensions | null { + try { + const buffer = Buffer.from(base64Data, "base64"); + + if (buffer.length < 2) { + return null; + } + + if (buffer[0] !== 0xff || buffer[1] !== 0xd8) { + return null; + } + + let offset = 2; + while (offset < buffer.length - 9) { + if (buffer[offset] !== 0xff) { + offset++; + continue; + } + + const marker = buffer[offset + 1]!; + + if (marker >= 0xc0 && marker <= 0xc2) { + const height = buffer.readUInt16BE(offset + 5); + const width = buffer.readUInt16BE(offset + 7); + return { widthPx: width, heightPx: height }; + } + + if (offset + 3 >= buffer.length) { + return null; + } + const length = buffer.readUInt16BE(offset + 2); + if (length < 2) { + return null; + } + offset += 2 + length; + } + + return null; + } catch { + return null; + } +} + +export function getGifDimensions(base64Data: string): ImageDimensions | null { + try { + const buffer = Buffer.from(base64Data, "base64"); + + if (buffer.length < 10) { + return null; + } + + const sig = buffer.slice(0, 6).toString("ascii"); + if (sig !== "GIF87a" && sig !== "GIF89a") { + return null; + } + + const width = buffer.readUInt16LE(6); + const height = buffer.readUInt16LE(8); + + return { widthPx: width, heightPx: height }; + } catch { + return null; + } +} + +export function getWebpDimensions(base64Data: string): ImageDimensions | null { + try { + const buffer = Buffer.from(base64Data, "base64"); + + if (buffer.length < 30) { + return null; + } + + const riff = buffer.slice(0, 4).toString("ascii"); + const webp = buffer.slice(8, 12).toString("ascii"); + if (riff !== "RIFF" || webp !== "WEBP") { + return null; + } + + const chunk = buffer.slice(12, 16).toString("ascii"); + if (chunk === "VP8 ") { + if (buffer.length < 30) return null; + const width = buffer.readUInt16LE(26) & 0x3fff; + const height = buffer.readUInt16LE(28) & 0x3fff; + return { widthPx: width, heightPx: height }; + } else if (chunk === "VP8L") { + if (buffer.length < 25) return null; + const bits = buffer.readUInt32LE(21); + const width = (bits & 0x3fff) + 1; + const height = ((bits >> 14) & 0x3fff) + 1; + return { widthPx: width, heightPx: height }; + } else if (chunk === "VP8X") { + if (buffer.length < 30) return null; + const width = (buffer[24]! | (buffer[25]! << 8) | (buffer[26]! << 16)) + 1; + const height = (buffer[27]! | (buffer[28]! << 8) | (buffer[29]! << 16)) + 1; + return { widthPx: width, heightPx: height }; + } + + return null; + } catch { + return null; + } +} + +export function getImageDimensions(base64Data: string, mimeType: string): ImageDimensions | null { + if (mimeType === "image/png") { + return getPngDimensions(base64Data); + } + if (mimeType === "image/jpeg") { + return getJpegDimensions(base64Data); + } + if (mimeType === "image/gif") { + return getGifDimensions(base64Data); + } + if (mimeType === "image/webp") { + return getWebpDimensions(base64Data); + } + return null; +} + +export function renderImage( + base64Data: string, + imageDimensions: ImageDimensions, + options: ImageRenderOptions = {}, +): { sequence: string; rows: number; imageId?: number } | null { + const caps = getCapabilities(); + + if (!caps.images) { + return null; + } + + const maxWidth = options.maxWidthCells ?? 80; + const size = calculateImageCellSize(imageDimensions, maxWidth, options.maxHeightCells, getCellDimensions()); + + if (caps.images === "kitty") { + const sequence = encodeKitty(base64Data, { + columns: size.columns, + rows: size.rows, + imageId: options.imageId, + moveCursor: options.moveCursor, + }); + return { sequence, rows: size.rows, imageId: options.imageId }; + } + + if (caps.images === "iterm2") { + const sequence = encodeITerm2(base64Data, { + width: size.columns, + height: "auto", + preserveAspectRatio: options.preserveAspectRatio ?? true, + }); + return { sequence, rows: size.rows }; + } + + return null; +} + +/** + * Wrap text in an OSC 8 hyperlink sequence. + * The text is rendered as a clickable hyperlink in terminals that support OSC 8 + * (Ghostty, Kitty, WezTerm, iTerm2, VSCode, and others). + * In terminals that do not support OSC 8, the escape sequences are ignored + * and only the plain text is displayed. + * + * @param text - The visible text to display + * @param url - The URL to link to + */ +export function hyperlink(text: string, url: string): string { + return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`; +} + +export function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string { + const parts: string[] = []; + if (filename) parts.push(filename); + parts.push(`[${mimeType}]`); + if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`); + return `[Image: ${parts.join(" ")}]`; +} diff --git a/packages/pi-tui/src/terminal.ts b/packages/pi-tui/src/terminal.ts new file mode 100644 index 000000000..3caba9789 --- /dev/null +++ b/packages/pi-tui/src/terminal.ts @@ -0,0 +1,531 @@ +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { setKittyProtocolActive } from "./keys.ts"; +import { isNativeModifierPressed } from "./native-modifiers.ts"; +import { StdinBuffer } from "./stdin-buffer.ts"; + +const cjsRequire = createRequire(import.meta.url); + +const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000; +const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07"; +const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07"; +const APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u"; +const DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS = 7; +const KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS = 150; +const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u\x1b[c`; + +export type KeyboardProtocolNegotiationSequence = + | { type: "kitty-flags"; flags: number } + | { type: "device-attributes" }; + +export function parseKeyboardProtocolNegotiationSequence( + sequence: string, +): KeyboardProtocolNegotiationSequence | undefined { + const kittyFlags = sequence.match(/^\x1b\[\?(\d+)u$/); + if (kittyFlags) { + return { type: "kitty-flags", flags: Number.parseInt(kittyFlags[1]!, 10) }; + } + if (/^\x1b\[\?[\d;]*c$/.test(sequence)) { + return { type: "device-attributes" }; + } + return undefined; +} + +function isKeyboardProtocolNegotiationSequencePrefix(sequence: string): boolean { + return sequence === "\x1b[" || /^\x1b\[\?[\d;]*$/.test(sequence); +} + +export function isAppleTerminalSession(): boolean { + return process.platform === "darwin" && process.env['TERM_PROGRAM'] === "Apple_Terminal"; +} + +export function normalizeAppleTerminalInput(data: string, isAppleTerminal: boolean, isShiftPressed: boolean): string { + if (isAppleTerminal && data === "\r" && isShiftPressed) return APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE; + return data; +} + +/** + * Minimal terminal interface for TUI + */ +export interface Terminal { + // Start the terminal with input and resize handlers + start(onInput: (data: string) => void, onResize: () => void): void; + + // Stop the terminal and restore state + stop(): void; + + /** + * Drain stdin before exiting to prevent Kitty key release events from + * leaking to the parent shell over slow SSH connections. + * @param maxMs - Maximum time to drain (default: 1000ms) + * @param idleMs - Exit early if no input arrives within this time (default: 50ms) + */ + drainInput(maxMs?: number, idleMs?: number): Promise; + + // Write output to terminal + write(data: string): void; + + // Get terminal dimensions + get columns(): number; + get rows(): number; + + // Whether Kitty keyboard protocol is active + get kittyProtocolActive(): boolean; + + // Cursor positioning (relative to current position) + moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines + + // Cursor visibility + hideCursor(): void; // Hide the cursor + showCursor(): void; // Show the cursor + + // Clear operations + 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 + + // Progress indicator (OSC 9;4) + setProgress(active: boolean): void; +} + +/** + * Real terminal using process.stdin/stdout + */ +export class ProcessTerminal implements Terminal { + private wasRaw = false; + private inputHandler?: (data: string) => void; + private resizeHandler?: () => void; + private _kittyProtocolActive = false; + private _modifyOtherKeysActive = false; + private keyboardProtocolPushed = false; + private keyboardProtocolNegotiationBuffer = ""; + private keyboardProtocolBufferFlushTimer?: ReturnType; + private stdinBuffer?: StdinBuffer; + private stdinDataHandler?: (data: string) => void; + private progressInterval?: ReturnType; + private writeLogPath = (() => { + const env = process.env['PI_TUI_WRITE_LOG'] || ""; + if (!env) return ""; + try { + if (fs.statSync(env).isDirectory()) { + const now = new Date(); + const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}_${String(now.getHours()).padStart(2, "0")}-${String(now.getMinutes()).padStart(2, "0")}-${String(now.getSeconds()).padStart(2, "0")}`; + return path.join(env, `tui-${ts}-${process.pid}.log`); + } + } catch { + // Not an existing directory - use as-is (file path) + } + return env; + })(); + + get kittyProtocolActive(): boolean { + return this._kittyProtocolActive; + } + + get modifyOtherKeysActive(): boolean { + return this._modifyOtherKeysActive; + } + + start(onInput: (data: string) => void, onResize: () => void): void { + this.inputHandler = onInput; + this.resizeHandler = onResize; + + // Save previous state and enable raw mode + this.wasRaw = process.stdin.isRaw || false; + if (process.stdin.setRawMode) { + process.stdin.setRawMode(true); + } + process.stdin.setEncoding("utf8"); + process.stdin.resume(); + + // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~ + process.stdout.write("\x1b[?2004h"); + + // Set up resize handler immediately + process.stdout.on("resize", this.resizeHandler); + + // Refresh terminal dimensions - they may be stale after suspend/resume + // (SIGWINCH is lost while process is stopped). Unix only. + if (process.platform !== "win32") { + process.kill(process.pid, "SIGWINCH"); + } + + // On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends + // VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console + // events that lose modifier information. Must run AFTER setRawMode(true) + // since that resets console mode flags. + this.enableWindowsVTInput(); + + // Query Kitty keyboard protocol and fall back to modifyOtherKeys when DA confirms no Kitty response. + // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + this.queryAndEnableKittyProtocol(); + } + + /** + * Set up StdinBuffer to split batched input into individual sequences. + * This ensures components receive single events, making matchesKey/isKeyRelease work correctly. + * + * Also watches for Kitty protocol response and enables it when detected. + * This is done here (after stdinBuffer parsing) rather than on raw stdin + * to handle the case where the response arrives split across multiple events. + */ + private setupStdinBuffer(): void { + this.stdinBuffer = new StdinBuffer({ timeout: 10 }); + + // Forward individual sequences to the input handler + this.stdinBuffer.on("data", (sequence) => { + const negotiationSequence = this.readKeyboardProtocolNegotiationSequence(sequence); + if (negotiationSequence === "pending") { + this.scheduleKeyboardProtocolNegotiationBufferFlush(); + return; // Wait briefly for the rest of a split Kitty response. + } + if (this.handleKeyboardProtocolNegotiationSequence(negotiationSequence)) { + return; + } + + this.forwardInputSequence(sequence); + }); + + // Re-wrap paste content with bracketed paste markers for existing editor handling + this.stdinBuffer.on("paste", (content) => { + if (this.inputHandler) { + this.inputHandler(`\x1b[200~${content}\x1b[201~`); + } + }); + + // Handler that pipes stdin data through the buffer + this.stdinDataHandler = (data: string) => { + this.stdinBuffer!.process(data); + }; + } + + /** + * Query terminal for Kitty keyboard protocol support and enable it if available. + * + * Kitty's progressive enhancement detection requires requesting the desired + * flags before querying them. The trailing DA query is a sentinel supported by + * terminals that do not know Kitty keyboard protocol; receiving DA before a + * Kitty response enables modifyOtherKeys fallback without a startup timeout. + * + * The requested flags are: + * - 1 = disambiguate escape codes + * - 2 = report event types (press/repeat/release) + * - 4 = report alternate keys (shifted key, base layout key) + */ + private queryAndEnableKittyProtocol(): void { + this.setupStdinBuffer(); + process.stdin.on("data", this.stdinDataHandler!); + this.keyboardProtocolPushed = true; + this.clearKeyboardProtocolNegotiationBuffer(); + process.stdout.write(KITTY_KEYBOARD_PROTOCOL_QUERY); + } + + private handleKeyboardProtocolNegotiationSequence( + negotiationSequence: KeyboardProtocolNegotiationSequence | undefined, + ): boolean { + if (!negotiationSequence) return false; + this.clearKeyboardProtocolNegotiationBuffer(); + if (negotiationSequence.type === "kitty-flags") { + if (negotiationSequence.flags !== 0) { + this.disableModifyOtherKeys(); + if (!this._kittyProtocolActive) { + this._kittyProtocolActive = true; + setKittyProtocolActive(true); + } + } else { + this.enableModifyOtherKeys(); + } + return true; + } + + if (!this._kittyProtocolActive) { + this.enableModifyOtherKeys(); + } + return true; + } + + private readKeyboardProtocolNegotiationSequence( + sequence: string, + ): KeyboardProtocolNegotiationSequence | "pending" | undefined { + if (this.keyboardProtocolNegotiationBuffer) { + const bufferedSequence = this.keyboardProtocolNegotiationBuffer + sequence; + const negotiationSequence = parseKeyboardProtocolNegotiationSequence(bufferedSequence); + if (negotiationSequence) { + this.clearKeyboardProtocolNegotiationBuffer(); + return negotiationSequence; + } + if (isKeyboardProtocolNegotiationSequencePrefix(bufferedSequence)) { + this.setKeyboardProtocolNegotiationBuffer(bufferedSequence); + return "pending"; + } + this.flushKeyboardProtocolNegotiationBufferAsInput(); + } + + const negotiationSequence = parseKeyboardProtocolNegotiationSequence(sequence); + if (negotiationSequence) return negotiationSequence; + if (isKeyboardProtocolNegotiationSequencePrefix(sequence)) { + this.setKeyboardProtocolNegotiationBuffer(sequence); + return "pending"; + } + return undefined; + } + + private setKeyboardProtocolNegotiationBuffer(sequence: string): void { + this.clearKeyboardProtocolNegotiationBufferFlushTimer(); + this.keyboardProtocolNegotiationBuffer = sequence; + } + + private clearKeyboardProtocolNegotiationBuffer(): void { + this.clearKeyboardProtocolNegotiationBufferFlushTimer(); + this.keyboardProtocolNegotiationBuffer = ""; + } + + private flushKeyboardProtocolNegotiationBufferAsInput(): void { + if (!this.keyboardProtocolNegotiationBuffer) return; + const sequence = this.keyboardProtocolNegotiationBuffer; + this.clearKeyboardProtocolNegotiationBuffer(); + this.forwardInputSequence(sequence); + } + + private scheduleKeyboardProtocolNegotiationBufferFlush(): void { + if (!this.keyboardProtocolNegotiationBuffer || this.keyboardProtocolBufferFlushTimer) return; + this.keyboardProtocolBufferFlushTimer = setTimeout(() => { + this.keyboardProtocolBufferFlushTimer = undefined; + this.flushKeyboardProtocolNegotiationBufferAsInput(); + }, KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS); + } + + private clearKeyboardProtocolNegotiationBufferFlushTimer(): void { + if (!this.keyboardProtocolBufferFlushTimer) return; + clearTimeout(this.keyboardProtocolBufferFlushTimer); + this.keyboardProtocolBufferFlushTimer = undefined; + } + + private forwardInputSequence(sequence: string): void { + if (!this.inputHandler) return; + const isAppleTerminal = sequence === "\r" && isAppleTerminalSession(); + const input = normalizeAppleTerminalInput( + sequence, + isAppleTerminal, + isAppleTerminal && isNativeModifierPressed("shift"), + ); + this.inputHandler(input); + } + + private enableModifyOtherKeys(): void { + if (this._kittyProtocolActive || this._modifyOtherKeysActive) return; + process.stdout.write("\x1b[>4;2m"); + this._modifyOtherKeysActive = true; + } + + private disableModifyOtherKeys(): void { + if (!this._modifyOtherKeysActive) return; + process.stdout.write("\x1b[>4;0m"); + this._modifyOtherKeysActive = false; + } + + /** + * On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT (0x0200) to the stdin + * console handle so the terminal sends VT sequences for modified keys + * (e.g. \x1b[Z for Shift+Tab). Without this, libuv's ReadConsoleInputW + * discards modifier state and Shift+Tab arrives as plain \t. + */ + private enableWindowsVTInput(): void { + if (process.platform !== "win32") return; + try { + const arch = process.arch; + if (arch !== "x64" && arch !== "arm64") return; + + // Dynamic require so non-Windows and bundled/browser paths never load the + // native helper. In the npm package native/ is next to dist/; in compiled + // binary archives native/ is copied next to the executable. + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node"); + const candidates = [ + path.join(moduleDir, "..", nativePath), + path.join(moduleDir, nativePath), + path.join(path.dirname(process.execPath), nativePath), + ]; + for (const modulePath of candidates) { + try { + const helper = cjsRequire(modulePath) as { enableVirtualTerminalInput?: () => boolean }; + helper.enableVirtualTerminalInput?.(); + return; + } catch { + // Try the next possible packaging location. + } + } + } catch { + // Native helper not available — Shift+Tab won't be distinguishable from Tab. + } + } + + async drainInput(maxMs = 1000, idleMs = 50): Promise { + const shouldDisableKittyProtocol = this.keyboardProtocolPushed || this._kittyProtocolActive; + this.clearKeyboardProtocolNegotiationBuffer(); + if (shouldDisableKittyProtocol) { + // Disable Kitty keyboard protocol first so any late key releases + // do not generate new Kitty escape sequences. + process.stdout.write("\x1b[ { + lastDataTime = Date.now(); + }; + + process.stdin.on("data", onData); + const endTime = Date.now() + maxMs; + + try { + while (true) { + const now = Date.now(); + const timeLeft = endTime - now; + if (timeLeft <= 0) break; + if (now - lastDataTime >= idleMs) break; + await new Promise((resolve) => setTimeout(resolve, Math.min(idleMs, timeLeft))); + } + } finally { + process.stdin.removeListener("data", onData); + this.inputHandler = previousHandler; + } + } + + stop(): void { + if (this.clearProgressInterval()) { + process.stdout.write(TERMINAL_PROGRESS_CLEAR_SEQUENCE); + } + + // Disable bracketed paste mode + process.stdout.write("\x1b[?2004l"); + + const shouldDisableKittyProtocol = this.keyboardProtocolPushed || this._kittyProtocolActive; + this.clearKeyboardProtocolNegotiationBuffer(); + + // Disable Kitty keyboard protocol if not already done by drainInput() + if (shouldDisableKittyProtocol) { + process.stdout.write("\x1b[ 0) { + // Move down + process.stdout.write(`\x1b[${lines}B`); + } else if (lines < 0) { + // Move up + process.stdout.write(`\x1b[${-lines}A`); + } + // lines === 0: no movement + } + + hideCursor(): void { + process.stdout.write("\x1b[?25l"); + } + + showCursor(): void { + process.stdout.write("\x1b[?25h"); + } + + clearLine(): void { + process.stdout.write("\x1b[K"); + } + + clearFromCursor(): void { + process.stdout.write("\x1b[J"); + } + + 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`); + } + + setProgress(active: boolean): void { + if (active) { + // OSC 9;4;3 - indeterminate progress + process.stdout.write(TERMINAL_PROGRESS_ACTIVE_SEQUENCE); + if (!this.progressInterval) { + this.progressInterval = setInterval(() => { + process.stdout.write(TERMINAL_PROGRESS_ACTIVE_SEQUENCE); + }, TERMINAL_PROGRESS_KEEPALIVE_MS); + } + } else { + this.clearProgressInterval(); + // OSC 9;4;0 - clear progress + process.stdout.write(TERMINAL_PROGRESS_CLEAR_SEQUENCE); + } + } + + private clearProgressInterval(): boolean { + if (!this.progressInterval) return false; + clearInterval(this.progressInterval); + this.progressInterval = undefined; + return true; + } +} diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts new file mode 100644 index 000000000..8959da4b0 --- /dev/null +++ b/packages/pi-tui/src/tui.ts @@ -0,0 +1,1737 @@ +/** + * Minimal TUI implementation with differential rendering + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { performance } from "node:perf_hooks"; +import { isKeyRelease, matchesKey } from "./keys.ts"; +import type { Terminal } from "./terminal.ts"; +import { + isOsc11BackgroundColorResponse, + parseOsc11BackgroundColor, + parseTerminalColorSchemeReport, + type RgbColor, + type TerminalColorScheme, +} from "./terminal-colors.ts"; +import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts"; +import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts"; + +const KITTY_SEQUENCE_PREFIX = "\x1b_G"; + +interface KittyImageHeader { + ids: number[]; + rows: number; +} + +function parseKittyImageHeader(line: string): KittyImageHeader | undefined { + const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX); + if (sequenceStart === -1) return undefined; + + const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length; + const paramsEnd = line.indexOf(";", paramsStart); + if (paramsEnd === -1) return undefined; + + const ids: number[] = []; + let rows = 1; + const params = line.slice(paramsStart, paramsEnd); + for (const param of params.split(",")) { + const [key, value] = param.split("=", 2); + if (value === undefined) continue; + const numberValue = Number(value); + if (!Number.isInteger(numberValue) || numberValue <= 0 || numberValue > 0xffffffff) continue; + if (key === "i") { + ids.push(numberValue); + } else if (key === "r") { + rows = numberValue; + } + } + return { ids, rows }; +} + +function extractKittyImageIds(line: string): number[] { + return parseKittyImageHeader(line)?.ids ?? []; +} + +function extractKittyImageRows(line: string): number { + return parseKittyImageHeader(line)?.rows ?? 1; +} + +/** + * Component interface - all components must implement this + */ +export interface Component { + /** + * Render the component to lines for the given viewport width + * @param width - Current viewport width + * @returns Array of strings, each representing a line + */ + render(width: number): string[]; + + /** + * Optional handler for keyboard input when component has focus + */ + handleInput?(data: string): void; + + /** + * If true, component receives key release events (Kitty protocol). + * Default is false - release events are filtered out. + */ + wantsKeyRelease?: boolean; + + /** + * Invalidate any cached rendering state. + * Called when theme changes or when component needs to re-render from scratch. + */ + invalidate(): void; +} + +type InputListenerResult = { consume?: boolean; data?: string } | undefined; +type InputListener = (data: string) => InputListenerResult; +type PendingOsc11BackgroundQuery = { + settled: boolean; + resolve: ((rgb: RgbColor | undefined) => void) | undefined; + timer: NodeJS.Timeout | undefined; +}; + +/** + * Interface for components that can receive focus and display a hardware cursor. + * When focused, the component should emit CURSOR_MARKER at the cursor position + * in its render output. TUI will find this marker and position the hardware + * cursor there for proper IME candidate window positioning. + */ +export interface Focusable { + /** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */ + focused: boolean; +} + +/** Type guard to check if a component implements Focusable */ +export function isFocusable(component: Component | null): component is Component & Focusable { + return component !== null && "focused" in component; +} + +/** + * Cursor position marker - APC (Application Program Command) sequence. + * This is a zero-width escape sequence that terminals ignore. + * Components emit this at the cursor position when focused. + * TUI finds and strips this marker, then positions the hardware cursor there. + */ +export const CURSOR_MARKER = "\x1b_pi:c\x07"; + +export { visibleWidth }; + +/** + * Anchor position for overlays + */ +export type OverlayAnchor = + | "center" + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right" + | "top-center" + | "bottom-center" + | "left-center" + | "right-center"; + +/** + * Margin configuration for overlays + */ +export interface OverlayMargin { + top?: number; + right?: number; + bottom?: number; + left?: number; +} + +/** Value that can be absolute (number) or percentage (string like "50%") */ +export type SizeValue = number | `${number}%`; + +/** Parse a SizeValue into absolute value given a reference size */ +function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined { + if (value === undefined) return undefined; + if (typeof value === "number") return value; + // Parse percentage string like "50%" + const match = value.match(/^(\d+(?:\.\d+)?)%$/); + if (match) { + return Math.floor((referenceSize * parseFloat(match[1]!)) / 100); + } + return undefined; +} + +function isTermuxSession(): boolean { + return Boolean(process.env['TERMUX_VERSION']); +} + +/** + * Options for overlay positioning and sizing. + * Values can be absolute numbers or percentage strings (e.g., "50%"). + */ +export interface OverlayOptions { + // === Sizing === + /** Width in columns, or percentage of terminal width (e.g., "50%") */ + width?: SizeValue; + /** Minimum width in columns */ + minWidth?: number; + /** Maximum height in rows, or percentage of terminal height (e.g., "50%") */ + maxHeight?: SizeValue; + + // === Positioning - anchor-based === + /** Anchor point for positioning (default: 'center') */ + anchor?: OverlayAnchor; + /** Horizontal offset from anchor position (positive = right) */ + offsetX?: number; + /** Vertical offset from anchor position (positive = down) */ + offsetY?: number; + + // === Positioning - percentage or absolute === + /** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */ + row?: SizeValue; + /** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */ + col?: SizeValue; + + // === Margin from terminal edges === + /** Margin from terminal edges. Number applies to all sides. */ + margin?: OverlayMargin | number; + + // === Visibility === + /** + * Control overlay visibility based on terminal dimensions. + * If provided, overlay is only rendered when this returns true. + * Called each render cycle with current terminal dimensions. + */ + visible?: (termWidth: number, termHeight: number) => boolean; + /** If true, don't capture keyboard focus when shown */ + nonCapturing?: boolean; +} + +/** Options for {@link OverlayHandle.unfocus}. */ +export interface OverlayUnfocusOptions { + /** Explicit target to focus after releasing this overlay. */ + target: Component | null; +} + +/** + * Handle returned by showOverlay for controlling the overlay + */ +export interface OverlayHandle { + /** Permanently remove the overlay (cannot be shown again) */ + hide(): void; + /** Temporarily hide or show the overlay */ + setHidden(hidden: boolean): void; + /** Check if overlay is temporarily hidden */ + isHidden(): boolean; + /** Focus this overlay and bring it to the visual front */ + focus(): void; + /** Release focus to the next visible capturing overlay or previous target, or to an explicit target when provided */ + unfocus(options?: OverlayUnfocusOptions): void; + /** Check if this overlay currently has focus */ + isFocused(): boolean; +} + +type OverlayStackEntry = { + component: Component; + options?: OverlayOptions; + preFocus: Component | null; + hidden: boolean; + focusOrder: number; +}; + +type OverlayBlockedFocusResume = { status: "restore-overlay" } | { status: "focus-target"; target: Component | null }; +type EligibleOverlayFocusRestoreState = { status: "eligible"; overlay: OverlayStackEntry }; +type BlockedOverlayFocusRestoreState = { + status: "blocked"; + overlay: OverlayStackEntry; + blockedBy: Component; + resume: OverlayBlockedFocusResume; +}; +type ActiveOverlayFocusRestoreState = EligibleOverlayFocusRestoreState | BlockedOverlayFocusRestoreState; +type OverlayFocusRestoreState = { status: "inactive" } | ActiveOverlayFocusRestoreState; +type OverlayFocusRestorePolicy = "clear" | "preserve"; + +/** + * Container - a component that contains other components + */ +export class Container implements Component { + children: Component[] = []; + + addChild(component: Component): void { + this.children.push(component); + } + + removeChild(component: Component): void { + const index = this.children.indexOf(component); + if (index !== -1) { + this.children.splice(index, 1); + } + } + + clear(): void { + this.children = []; + } + + invalidate(): void { + for (const child of this.children) { + child.invalidate?.(); + } + } + + render(width: number): string[] { + const lines: string[] = []; + for (const child of this.children) { + const childLines = child.render(width); + for (const line of childLines) { + lines.push(line); + } + } + return lines; + } +} + +/** + * TUI - Main class for managing terminal UI with differential rendering + */ +export class TUI extends Container { + public terminal: Terminal; + private previousLines: string[] = []; + private previousKittyImageIds = new Set(); + private previousWidth = 0; + private previousHeight = 0; + private focusedComponent: Component | null = null; + private inputListeners = new Set(); + + /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ + public onDebug?: () => void; + private renderRequested = false; + private renderTimer: NodeJS.Timeout | undefined; + private lastRenderAt = 0; + private static readonly MIN_RENDER_INTERVAL_MS = 16; + private cursorRow = 0; // Logical cursor row (end of rendered content) + private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning) + private showHardwareCursor = process.env['PI_HARDWARE_CURSOR'] === "1"; + private clearOnShrink = process.env['PI_CLEAR_ON_SHRINK'] === "1"; // Clear empty rows when content shrinks (default: off) + private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered) + private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves + private fullRedrawCount = 0; + private stopped = false; + private pendingOsc11BackgroundReplies = 0; + private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = []; + private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>(); + private terminalColorSchemeNotificationsEnabled = false; + + // Overlay stack for modal components rendered on top of base content + private focusOrderCounter = 0; + private overlayStack: OverlayStackEntry[] = []; + private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" }; + + constructor(terminal: Terminal, showHardwareCursor?: boolean) { + super(); + this.terminal = terminal; + if (showHardwareCursor !== undefined) { + this.showHardwareCursor = showHardwareCursor; + } + } + + get fullRedraws(): number { + return this.fullRedrawCount; + } + + getShowHardwareCursor(): boolean { + return this.showHardwareCursor; + } + + setShowHardwareCursor(enabled: boolean): void { + if (this.showHardwareCursor === enabled) return; + this.showHardwareCursor = enabled; + if (!enabled) { + this.terminal.hideCursor(); + } + this.requestRender(); + } + + getClearOnShrink(): boolean { + return this.clearOnShrink; + } + + /** + * Set whether to trigger full re-render when content shrinks. + * When true (default), empty rows are cleared when content shrinks. + * When false, empty rows remain (reduces redraws on slower terminals). + */ + setClearOnShrink(enabled: boolean): void { + this.clearOnShrink = enabled; + } + + setFocus(component: Component | null): void { + this.setFocusInternal({ component, overlayFocusRestore: "clear" }); + } + + private setFocusInternal({ + component, + overlayFocusRestore, + }: { + component: Component | null; + overlayFocusRestore: OverlayFocusRestorePolicy; + }): void { + const previousFocus = this.focusedComponent; + let nextFocus = component; + const previousFocusedOverlay = previousFocus + ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry)) + : undefined; + const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false; + const restoreState = this.getVisibleOverlayFocusRestore(); + if (nextFocus && !nextFocusIsOverlay) { + if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) { + if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) { + nextFocus = this.resolveBlockedOverlayFocusResume(restoreState); + } else { + this.overlayFocusRestore = { + status: "blocked", + overlay: restoreState.overlay, + blockedBy: nextFocus, + resume: restoreState.resume, + }; + } + } else if ( + previousFocusedOverlay && + restoreState.status !== "inactive" && + restoreState.overlay === previousFocusedOverlay && + !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus) + ) { + this.overlayFocusRestore = { + status: "blocked", + overlay: previousFocusedOverlay, + blockedBy: nextFocus, + resume: { status: "restore-overlay" }, + }; + } + } else if (nextFocus === null) { + if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) { + nextFocus = this.resolveBlockedOverlayFocusResume(restoreState); + } else if (overlayFocusRestore === "clear") { + this.clearOverlayFocusRestore(); + } + } + + if (isFocusable(this.focusedComponent)) { + this.focusedComponent.focused = false; + } + + this.focusedComponent = nextFocus; + + if (isFocusable(nextFocus)) { + nextFocus.focused = true; + } + + const focusedOverlay = nextFocus + ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry)) + : undefined; + if (focusedOverlay) { + this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay }; + } + } + + private clearOverlayFocusRestore(): void { + this.overlayFocusRestore = { status: "inactive" }; + } + + private clearOverlayFocusRestoreFor(overlay: OverlayStackEntry): void { + if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) { + this.clearOverlayFocusRestore(); + } + } + + private resolveBlockedOverlayFocusResume(restoreState: BlockedOverlayFocusRestoreState): Component | null { + if (restoreState.resume.status === "restore-overlay") return restoreState.overlay.component; + this.clearOverlayFocusRestore(); + return restoreState.resume.target; + } + + private getVisibleOverlayFocusRestore(): OverlayFocusRestoreState { + const restoreState = this.overlayFocusRestore; + if (restoreState.status === "inactive") return restoreState; + if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) { + return { status: "inactive" }; + } + return restoreState; + } + + private isOverlayFocusAncestor(entry: OverlayStackEntry, component: Component): boolean { + const visited = new Set(); + let current = entry.preFocus; + while (current && !visited.has(current)) { + visited.add(current); + if (current === component) return true; + current = this.overlayStack.find((overlay) => overlay.component === current)?.preFocus ?? null; + } + return false; + } + + private retargetOverlayPreFocus(removed: OverlayStackEntry): void { + for (const overlay of this.overlayStack) { + if (overlay !== removed && overlay.preFocus === removed.component) { + overlay.preFocus = removed.preFocus; + } + } + } + + private isComponentMounted(component: Component): boolean { + return this.children.some((child) => this.containsComponent(child, component)); + } + + private containsComponent(root: Component, target: Component): boolean { + if (root === target) return true; + if (!(root instanceof Container)) return false; + return root.children.some((child) => this.containsComponent(child, target)); + } + + /** + * Show an overlay component with configurable positioning and sizing. + * Returns a handle to control the overlay's visibility. + */ + showOverlay(component: Component, options?: OverlayOptions): OverlayHandle { + const entry: OverlayStackEntry = { + component, + ...(options === undefined ? {} : { options }), + preFocus: this.focusedComponent, + hidden: false, + focusOrder: ++this.focusOrderCounter, + }; + this.overlayStack.push(entry); + // Only focus if overlay is actually visible + if (!options?.nonCapturing && this.isOverlayVisible(entry)) { + this.setFocus(component); + } + this.terminal.hideCursor(); + this.requestRender(); + + // Return handle for controlling this overlay + return { + hide: () => { + const index = this.overlayStack.indexOf(entry); + if (index !== -1) { + this.clearOverlayFocusRestoreFor(entry); + this.retargetOverlayPreFocus(entry); + this.overlayStack.splice(index, 1); + // Restore focus if this overlay had focus + if (this.focusedComponent === component) { + const topVisible = this.getTopmostVisibleOverlay(); + this.setFocus(topVisible?.component ?? entry.preFocus); + } + if (this.overlayStack.length === 0) this.terminal.hideCursor(); + this.requestRender(); + } + }, + setHidden: (hidden: boolean) => { + if (entry.hidden === hidden) return; + entry.hidden = hidden; + // Update focus when hiding/showing + if (hidden) { + this.clearOverlayFocusRestoreFor(entry); + // If this overlay had focus, move focus to next visible or preFocus + if (this.focusedComponent === component) { + const topVisible = this.getTopmostVisibleOverlay(); + this.setFocus(topVisible?.component ?? entry.preFocus); + } + } else { + // Restore focus to this overlay when showing (if it's actually visible) + if (!options?.nonCapturing && this.isOverlayVisible(entry)) { + entry.focusOrder = ++this.focusOrderCounter; + this.setFocus(component); + } + } + this.requestRender(); + }, + isHidden: () => entry.hidden, + focus: () => { + if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry)) return; + entry.focusOrder = ++this.focusOrderCounter; + this.setFocus(component); + this.requestRender(); + }, + unfocus: (unfocusOptions) => { + const isFocused = this.focusedComponent === component; + const restoreState = this.overlayFocusRestore; + const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry; + if (!isFocused && !hasPendingRestore) return; + if ( + restoreState.status === "blocked" && + restoreState.overlay === entry && + this.focusedComponent === restoreState.blockedBy + ) { + if (unfocusOptions) { + this.overlayFocusRestore = { + status: "blocked", + overlay: entry, + blockedBy: restoreState.blockedBy, + resume: { status: "focus-target", target: unfocusOptions.target }, + }; + } else { + this.clearOverlayFocusRestore(); + } + this.requestRender(); + return; + } + this.clearOverlayFocusRestoreFor(entry); + if (isFocused || unfocusOptions) { + const topVisible = this.getTopmostVisibleOverlay(); + const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus; + this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget); + } + this.requestRender(); + }, + isFocused: () => this.focusedComponent === component, + }; + } + + /** Hide the topmost overlay and restore previous focus. */ + hideOverlay(): void { + const overlay = this.overlayStack[this.overlayStack.length - 1]; + if (!overlay) return; + this.clearOverlayFocusRestoreFor(overlay); + this.retargetOverlayPreFocus(overlay); + this.overlayStack.pop(); + if (this.focusedComponent === overlay.component) { + // Find topmost visible overlay, or fall back to preFocus + const topVisible = this.getTopmostVisibleOverlay(); + this.setFocus(topVisible?.component ?? overlay.preFocus); + } + if (this.overlayStack.length === 0) this.terminal.hideCursor(); + this.requestRender(); + } + + /** Check if there are any visible overlays */ + hasOverlay(): boolean { + return this.overlayStack.some((o) => this.isOverlayVisible(o)); + } + + /** Check if an overlay entry is currently visible */ + private isOverlayVisible(entry: OverlayStackEntry): boolean { + if (entry.hidden) return false; + if (entry.options?.visible) { + return entry.options.visible(this.terminal.columns, this.terminal.rows); + } + return true; + } + + /** Find the visual-frontmost visible capturing overlay, if any */ + private getTopmostVisibleOverlay(): OverlayStackEntry | undefined { + let topmost: OverlayStackEntry | undefined; + for (const overlay of this.overlayStack) { + if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay)) continue; + if (!topmost || overlay.focusOrder > topmost.focusOrder) { + topmost = overlay; + } + } + return topmost; + } + + override invalidate(): void { + super.invalidate(); + for (const overlay of this.overlayStack) overlay.component.invalidate?.(); + } + + start(): void { + this.stopped = false; + this.terminal.start( + (data) => this.handleInput(data), + () => this.requestRender(), + ); + this.terminal.hideCursor(); + if (this.terminalColorSchemeNotificationsEnabled) { + this.terminal.write("\x1b[?2031h"); + } + this.queryCellSize(); + this.requestRender(); + } + + addInputListener(listener: InputListener): () => void { + this.inputListeners.add(listener); + return () => { + this.inputListeners.delete(listener); + }; + } + + removeInputListener(listener: InputListener): void { + this.inputListeners.delete(listener); + } + + onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void { + this.terminalColorSchemeListeners.add(listener); + return () => { + this.terminalColorSchemeListeners.delete(listener); + }; + } + + setTerminalColorSchemeNotifications(enabled: boolean): void { + if (this.terminalColorSchemeNotificationsEnabled === enabled) { + return; + } + this.terminalColorSchemeNotificationsEnabled = enabled; + if (!this.stopped) { + this.terminal.write(enabled ? "\x1b[?2031h" : "\x1b[?2031l"); + } + } + + private queryCellSize(): void { + // Only query if terminal supports images (cell size is only used for image rendering) + if (!getCapabilities().images) { + return; + } + // Query terminal for cell size in pixels: CSI 16 t + // Response format: CSI 6 ; height ; width t + this.terminal.write("\x1b[16t"); + } + + stop(): void { + this.stopped = true; + if (this.renderTimer) { + clearTimeout(this.renderTimer); + this.renderTimer = undefined; + } + if (this.terminalColorSchemeNotificationsEnabled) { + this.terminal.write("\x1b[?2031l"); + } + // Move cursor to the end of the content to prevent overwriting/artifacts on exit + if (this.previousLines.length > 0) { + const targetRow = this.previousLines.length; // Line after the last content + const lineDiff = targetRow - this.hardwareCursorRow; + if (lineDiff > 0) { + this.terminal.write(`\x1b[${lineDiff}B`); + } else if (lineDiff < 0) { + this.terminal.write(`\x1b[${-lineDiff}A`); + } + this.terminal.write("\r\n"); + } + + this.terminal.showCursor(); + this.terminal.stop(); + } + + requestRender(force = false): void { + if (force) { + this.previousLines = []; + this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear + this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear + this.cursorRow = 0; + this.hardwareCursorRow = 0; + this.maxLinesRendered = 0; + this.previousViewportTop = 0; + if (this.renderTimer) { + clearTimeout(this.renderTimer); + this.renderTimer = undefined; + } + this.renderRequested = true; + process.nextTick(() => { + if (this.stopped || !this.renderRequested) { + return; + } + this.renderRequested = false; + this.lastRenderAt = performance.now(); + this.doRender(); + }); + return; + } + if (this.renderRequested) return; + this.renderRequested = true; + process.nextTick(() => this.scheduleRender()); + } + + private scheduleRender(): void { + if (this.stopped || this.renderTimer || !this.renderRequested) { + return; + } + const elapsed = performance.now() - this.lastRenderAt; + const delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed); + this.renderTimer = setTimeout(() => { + this.renderTimer = undefined; + if (this.stopped || !this.renderRequested) { + return; + } + this.renderRequested = false; + this.lastRenderAt = performance.now(); + this.doRender(); + if (this.renderRequested) { + this.scheduleRender(); + } + }, delay); + } + + private handleInput(data: string): void { + if (this.consumeOsc11BackgroundResponse(data)) { + return; + } + if (this.consumeTerminalColorSchemeReport(data)) { + return; + } + + if (this.inputListeners.size > 0) { + let current = data; + for (const listener of this.inputListeners) { + const result = listener(current); + if (result?.consume) { + return; + } + if (result?.data !== undefined) { + current = result.data; + } + } + if (current.length === 0) { + return; + } + data = current; + } + + // Consume terminal cell size responses without blocking unrelated input. + if (this.consumeCellSizeResponse(data)) { + return; + } + + // Global debug key handler (Shift+Ctrl+D) + if (matchesKey(data, "shift+ctrl+d") && this.onDebug) { + this.onDebug(); + return; + } + + // If focused component is an overlay, verify it's still visible + // (visibility can change due to terminal resize or visible() callback) + const focusedOverlay = this.overlayStack.find((o) => o.component === this.focusedComponent); + if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) { + // Focused overlay is no longer visible, redirect to topmost visible overlay + const topVisible = this.getTopmostVisibleOverlay(); + if (topVisible) { + this.setFocus(topVisible.component); + } else { + this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" }); + } + } + + const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent); + if (!focusIsOverlay) { + const restoreState = this.getVisibleOverlayFocusRestore(); + if (restoreState.status === "eligible") { + this.setFocus(restoreState.overlay.component); + } else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) { + if (restoreState.resume.status === "restore-overlay") { + this.setFocus(restoreState.overlay.component); + } else { + this.clearOverlayFocusRestore(); + this.setFocus(restoreState.resume.target); + } + } + } + + // Pass input to focused component (including Ctrl+C) + // The focused component can decide how to handle Ctrl+C + if (this.focusedComponent?.handleInput) { + // Filter out key release events unless component opts in + if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) { + return; + } + this.focusedComponent.handleInput(data); + this.requestRender(); + } + } + + private consumeOsc11BackgroundResponse(data: string): boolean { + if (this.pendingOsc11BackgroundReplies <= 0) { + return false; + } + + if (!isOsc11BackgroundColorResponse(data)) { + return false; + } + + const rgb = parseOsc11BackgroundColor(data); + this.pendingOsc11BackgroundReplies -= 1; + const query = this.pendingOsc11BackgroundQueries.shift(); + if (query && !query.settled) { + query.settled = true; + if (query.timer) { + clearTimeout(query.timer); + query.timer = undefined; + } + query.resolve?.(rgb); + query.resolve = undefined; + } + return true; + } + + private consumeTerminalColorSchemeReport(data: string): boolean { + const scheme = parseTerminalColorSchemeReport(data); + if (!scheme) { + return false; + } + + for (const listener of this.terminalColorSchemeListeners) { + listener(scheme); + } + return true; + } + + private consumeCellSizeResponse(data: string): boolean { + // Response format: ESC [ 6 ; height ; width t + const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/); + if (!match) { + return false; + } + + const heightPx = parseInt(match[1]!, 10); + const widthPx = parseInt(match[2]!, 10); + if (heightPx <= 0 || widthPx <= 0) { + return true; + } + + setCellDimensions({ widthPx, heightPx }); + // Invalidate all components so images re-render with correct dimensions. + this.invalidate(); + this.requestRender(); + return true; + } + + /** + * Resolve overlay layout from options. + * Returns { width, row, col, maxHeight } for rendering. + */ + private resolveOverlayLayout( + options: OverlayOptions | undefined, + overlayHeight: number, + termWidth: number, + termHeight: number, + ): { width: number; row: number; col: number; maxHeight: number | undefined } { + const opt = options ?? {}; + + // Parse margin (clamp to non-negative) + const margin = + typeof opt.margin === "number" + ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin } + : (opt.margin ?? {}); + const marginTop = Math.max(0, margin.top ?? 0); + const marginRight = Math.max(0, margin.right ?? 0); + const marginBottom = Math.max(0, margin.bottom ?? 0); + const marginLeft = Math.max(0, margin.left ?? 0); + + // Available space after margins + const availWidth = Math.max(1, termWidth - marginLeft - marginRight); + const availHeight = Math.max(1, termHeight - marginTop - marginBottom); + + // === Resolve width === + let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth); + // Apply minWidth + if (opt.minWidth !== undefined) { + width = Math.max(width, opt.minWidth); + } + // Clamp to available space + width = Math.max(1, Math.min(width, availWidth)); + + // === Resolve maxHeight === + let maxHeight = parseSizeValue(opt.maxHeight, termHeight); + // Clamp to available space + if (maxHeight !== undefined) { + maxHeight = Math.max(1, Math.min(maxHeight, availHeight)); + } + + // Effective overlay height (may be clamped by maxHeight) + const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight; + + // === Resolve position === + let row: number; + let col: number; + + if (opt.row !== undefined) { + if (typeof opt.row === "string") { + // Percentage: 0% = top, 100% = bottom (overlay stays within bounds) + const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/); + if (match) { + const maxRow = Math.max(0, availHeight - effectiveHeight); + const percent = parseFloat(match[1]!) / 100; + row = marginTop + Math.floor(maxRow * percent); + } else { + // Invalid format, fall back to center + row = this.resolveAnchorRow("center", effectiveHeight, availHeight, marginTop); + } + } else { + // Absolute row position + row = opt.row; + } + } else { + // Anchor-based (default: center) + const anchor = opt.anchor ?? "center"; + row = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop); + } + + if (opt.col !== undefined) { + if (typeof opt.col === "string") { + // Percentage: 0% = left, 100% = right (overlay stays within bounds) + const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/); + if (match) { + const maxCol = Math.max(0, availWidth - width); + const percent = parseFloat(match[1]!) / 100; + col = marginLeft + Math.floor(maxCol * percent); + } else { + // Invalid format, fall back to center + col = this.resolveAnchorCol("center", width, availWidth, marginLeft); + } + } else { + // Absolute column position + col = opt.col; + } + } else { + // Anchor-based (default: center) + const anchor = opt.anchor ?? "center"; + col = this.resolveAnchorCol(anchor, width, availWidth, marginLeft); + } + + // Apply offsets + if (opt.offsetY !== undefined) row += opt.offsetY; + if (opt.offsetX !== undefined) col += opt.offsetX; + + // Clamp to terminal bounds (respecting margins) + row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight)); + col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width)); + + return { width, row, col, maxHeight }; + } + + private resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number { + switch (anchor) { + case "top-left": + case "top-center": + case "top-right": + return marginTop; + case "bottom-left": + case "bottom-center": + case "bottom-right": + return marginTop + availHeight - height; + case "left-center": + case "center": + case "right-center": + return marginTop + Math.floor((availHeight - height) / 2); + } + } + + private resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number { + switch (anchor) { + case "top-left": + case "left-center": + case "bottom-left": + return marginLeft; + case "top-right": + case "right-center": + case "bottom-right": + return marginLeft + availWidth - width; + case "top-center": + case "center": + case "bottom-center": + return marginLeft + Math.floor((availWidth - width) / 2); + } + } + + /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */ + private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] { + if (this.overlayStack.length === 0) return lines; + const result = [...lines]; + + // Pre-render all visible overlays and calculate positions + const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = []; + let minLinesNeeded = result.length; + + const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e)); + visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder); + for (const entry of visibleEntries) { + const { component, options } = entry; + + // Get layout with height=0 first to determine width and maxHeight + // (width and maxHeight don't depend on overlay height) + const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight); + + // Render component at calculated width + let overlayLines = component.render(width); + + // Apply maxHeight if specified + if (maxHeight !== undefined && overlayLines.length > maxHeight) { + overlayLines = overlayLines.slice(0, maxHeight); + } + + // Get final row/col with actual overlay height + const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight); + + rendered.push({ overlayLines, row, col, w: width }); + minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length); + } + + // Pad to at least terminal height so overlays have screen-relative positions. + // Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing + // inflation that pushed content into scrollback on terminal widen. + const workingHeight = Math.max(result.length, termHeight, minLinesNeeded); + + // Extend result with empty lines if content is too short for overlay placement or working area + while (result.length < workingHeight) { + result.push(""); + } + + const viewportStart = Math.max(0, workingHeight - termHeight); + + // Composite each overlay + for (const { overlayLines, row, col, w } of rendered) { + for (let i = 0; i < overlayLines.length; i++) { + const idx = viewportStart + row + i; + if (idx >= 0 && idx < result.length) { + // Defensive: truncate overlay line to declared width before compositing + // (components should already respect width, but this ensures it) + const truncatedOverlayLine = + visibleWidth(overlayLines[i]!) > w ? sliceByColumn(overlayLines[i]!, 0, w, true) : overlayLines[i]!; + result[idx] = this.compositeLineAt(result[idx]!, truncatedOverlayLine, col, w, termWidth); + } + } + } + + return result; + } + + private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; + + private applyLineResets(lines: string[]): string[] { + const reset = TUI.SEGMENT_RESET; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + if (!isImageLine(line)) { + lines[i] = normalizeTerminalOutput(line) + reset; + } + } + return lines; + } + + private collectKittyImageIds(lines: string[]): Set { + const ids = new Set(); + for (const line of lines) { + for (const id of extractKittyImageIds(line)) { + ids.add(id); + } + } + return ids; + } + + private deleteKittyImages(ids: Iterable): string { + let buffer = ""; + for (const id of ids) { + buffer += deleteKittyImage(id); + } + return buffer; + } + + private getKittyImageReservedRows(lines: string[], index: number, maxIndex = lines.length - 1): number { + const rows = extractKittyImageRows(lines[index] ?? ""); + if (rows <= 1) return 1; + + const maxRows = Math.min(rows, maxIndex - index + 1, lines.length - index); + let reservedRows = 1; + while (reservedRows < maxRows) { + const line = lines[index + reservedRows] ?? ""; + if (isImageLine(line) || visibleWidth(line) > 0) break; + reservedRows++; + } + return reservedRows; + } + + private expandChangedRangeForKittyImages( + firstChanged: number, + lastChanged: number, + newLines: string[], + ): { firstChanged: number; lastChanged: number } { + let expandedFirstChanged = firstChanged; + let expandedLastChanged = lastChanged; + const expandForLines = (lines: string[]): void => { + for (let i = 0; i < lines.length; i++) { + if (extractKittyImageIds(lines[i]!).length === 0) continue; + const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1; + if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) { + expandedFirstChanged = Math.min(expandedFirstChanged, i); + expandedLastChanged = Math.max(expandedLastChanged, blockEnd); + } + } + }; + + expandForLines(this.previousLines); + expandForLines(newLines); + return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged }; + } + + private deleteChangedKittyImages(firstChanged: number, lastChanged: number): string { + if (firstChanged < 0 || lastChanged < firstChanged) return ""; + + const ids = new Set(); + const maxLine = Math.min(lastChanged, this.previousLines.length - 1); + for (let i = firstChanged; i <= maxLine; i++) { + for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) { + ids.add(id); + } + } + + return this.deleteKittyImages(ids); + } + + /** Splice overlay content into a base line at a specific column. Single-pass optimized. */ + private compositeLineAt( + baseLine: string, + overlayLine: string, + startCol: number, + overlayWidth: number, + totalWidth: number, + ): string { + if (isImageLine(baseLine)) return baseLine; + + // Single pass through baseLine extracts both before and after segments + const afterStart = startCol + overlayWidth; + const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true); + + // Extract overlay with width tracking (strict=true to exclude wide chars at boundary) + const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true); + + // Pad segments to target widths + const beforePad = Math.max(0, startCol - base.beforeWidth); + const overlayPad = Math.max(0, overlayWidth - overlay.width); + const actualBeforeWidth = Math.max(startCol, base.beforeWidth); + const actualOverlayWidth = Math.max(overlayWidth, overlay.width); + const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth); + const afterPad = Math.max(0, afterTarget - base.afterWidth); + + // Compose result + const r = TUI.SEGMENT_RESET; + const result = + base.before + + " ".repeat(beforePad) + + r + + overlay.text + + " ".repeat(overlayPad) + + r + + base.after + + " ".repeat(afterPad); + + // CRITICAL: Always verify and truncate to terminal width. + // This is the final safeguard against width overflow which would crash the TUI. + // Width tracking can drift from actual visible width due to: + // - Complex ANSI/OSC sequences (hyperlinks, colors) + // - Wide characters at segment boundaries + // - Edge cases in segment extraction + const resultWidth = visibleWidth(result); + if (resultWidth <= totalWidth) { + return result; + } + // Truncate with strict=true to ensure we don't exceed totalWidth + return sliceByColumn(result, 0, totalWidth, true); + } + + /** + * Find and extract cursor position from rendered lines. + * Searches for CURSOR_MARKER, calculates its position, and strips it from the output. + * Only scans the bottom terminal height lines (visible viewport). + * @param lines - Rendered lines to search + * @param height - Terminal height (visible viewport size) + * @returns Cursor position { row, col } or null if no marker found + */ + private extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null { + // Only scan the bottom `height` lines (visible viewport) + const viewportTop = Math.max(0, lines.length - height); + for (let row = lines.length - 1; row >= viewportTop; row--) { + const line = lines[row]!; + const markerIndex = line.indexOf(CURSOR_MARKER); + if (markerIndex !== -1) { + // Calculate visual column (width of text before marker) + const beforeMarker = line.slice(0, markerIndex); + const col = visibleWidth(beforeMarker); + + // Strip marker from the line + lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length); + + return { row, col }; + } + } + return null; + } + + private doRender(): void { + if (this.stopped) return; + const width = this.terminal.columns; + const height = this.terminal.rows; + const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width; + const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height; + const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height; + let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop; + let viewportTop = prevViewportTop; + let hardwareCursorRow = this.hardwareCursorRow; + const computeLineDiff = (targetRow: number): number => { + const currentScreenRow = hardwareCursorRow - prevViewportTop; + const targetScreenRow = targetRow - viewportTop; + return targetScreenRow - currentScreenRow; + }; + + // Render all components to get new lines + let newLines = this.render(width); + + // Composite overlays into the rendered lines (before differential compare) + if (this.overlayStack.length > 0) { + newLines = this.compositeOverlays(newLines, width, height); + } + + // Extract cursor position before applying line resets (marker must be found first) + const cursorPos = this.extractCursorPosition(newLines, height); + + newLines = this.applyLineResets(newLines); + + // Helper to clear scrollback and viewport and render all new lines + const fullRender = (clear: boolean): void => { + this.fullRedrawCount += 1; + let buffer = "\x1b[?2026h"; // Begin synchronized output + if (clear) { + buffer += this.deleteKittyImages(this.previousKittyImageIds); + buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback + } + for (let i = 0; i < newLines.length; i++) { + if (i > 0) buffer += "\r\n"; + const line = newLines[i]!; + const isImage = isImageLine(line); + const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1; + if (imageReservedRows > 1 && imageReservedRows <= height) { + for (let row = 1; row < imageReservedRows; row++) { + buffer += "\r\n"; + } + buffer += `\x1b[${imageReservedRows - 1}A`; + buffer += line; + buffer += `\x1b[${imageReservedRows - 1}B`; + i += imageReservedRows - 1; + continue; + } + buffer += line; + } + buffer += "\x1b[?2026l"; // End synchronized output + this.terminal.write(buffer); + this.cursorRow = Math.max(0, newLines.length - 1); + this.hardwareCursorRow = this.cursorRow; + // Reset max lines when clearing, otherwise track growth + if (clear) { + this.maxLinesRendered = newLines.length; + } else { + this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); + } + const bufferLength = Math.max(height, newLines.length); + this.previousViewportTop = Math.max(0, bufferLength - height); + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; + this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousWidth = width; + this.previousHeight = height; + }; + + const debugRedraw = process.env['PI_DEBUG_REDRAW'] === "1"; + const logRedraw = (reason: string): void => { + if (!debugRedraw) return; + const logPath = path.join(os.homedir(), ".pi", "agent", "pi-debug.log"); + const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`; + fs.appendFileSync(logPath, msg); + }; + + // First render - just output everything without clearing (assumes clean screen) + if (this.previousLines.length === 0 && !widthChanged && !heightChanged) { + logRedraw("first render"); + fullRender(false); + return; + } + + // Width changes always need a full re-render because wrapping changes. + if (widthChanged) { + logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`); + fullRender(true); + return; + } + + // Height changes normally need a full re-render to keep the visible viewport aligned, + // but Termux changes height when the software keyboard shows or hides. + // In that environment, a full redraw causes the entire history to replay on every toggle. + if (heightChanged && !isTermuxSession()) { + logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`); + fullRender(true); + return; + } + + // Content shrunk below the working area and no overlays - re-render to clear empty rows + // (overlays need the padding, so only do this when no overlays are active) + // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var + if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) { + logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`); + fullRender(true); + return; + } + + // Find first and last changed lines + let firstChanged = -1; + let lastChanged = -1; + const maxLines = Math.max(newLines.length, this.previousLines.length); + for (let i = 0; i < maxLines; i++) { + const oldLine = i < this.previousLines.length ? this.previousLines[i] : ""; + const newLine = i < newLines.length ? newLines[i] : ""; + + if (oldLine !== newLine) { + if (firstChanged === -1) { + firstChanged = i; + } + lastChanged = i; + } + } + const appendedLines = newLines.length > this.previousLines.length; + if (appendedLines) { + if (firstChanged === -1) { + firstChanged = this.previousLines.length; + } + lastChanged = newLines.length - 1; + } + if (firstChanged !== -1) { + const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines); + firstChanged = expandedRange.firstChanged; + lastChanged = expandedRange.lastChanged; + } + const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0; + + // No changes - but still need to update hardware cursor position if it moved + if (firstChanged === -1) { + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousViewportTop = prevViewportTop; + this.previousHeight = height; + return; + } + + // All changes are in deleted lines (nothing to render, just clear) + if (firstChanged >= newLines.length) { + if (this.previousLines.length > newLines.length) { + let buffer = "\x1b[?2026h"; + buffer += this.deleteChangedKittyImages(firstChanged, lastChanged); + // Move to end of new content (clamp to 0 for empty content) + const targetRow = Math.max(0, newLines.length - 1); + if (targetRow < prevViewportTop) { + logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`); + fullRender(true); + return; + } + const lineDiff = computeLineDiff(targetRow); + if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`; + else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`; + buffer += "\r"; + // Clear extra lines without scrolling + const extraLines = this.previousLines.length - newLines.length; + if (extraLines > height) { + logRedraw(`extraLines > height (${extraLines} > ${height})`); + fullRender(true); + return; + } + const clearStartOffset = newLines.length === 0 ? 0 : 1; + if (extraLines > 0 && clearStartOffset > 0) { + buffer += `\x1b[${clearStartOffset}B`; + } + for (let i = 0; i < extraLines; i++) { + buffer += "\r\x1b[2K"; + if (i < extraLines - 1) buffer += "\x1b[1B"; + } + const moveBack = Math.max(0, extraLines - 1 + clearStartOffset); + if (moveBack > 0) { + buffer += `\x1b[${moveBack}A`; + } + buffer += "\x1b[?2026l"; + this.terminal.write(buffer); + this.cursorRow = targetRow; + this.hardwareCursorRow = targetRow; + } + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; + this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousWidth = width; + this.previousHeight = height; + this.previousViewportTop = prevViewportTop; + return; + } + + // Differential rendering can only touch what was actually visible. + // If the first changed line is above the viewport, a destructive full + // redraw would clear scrollback and yank the user's viewport (on Windows + // Terminal, ESC[3J while scrolled into scrollback jumps to the absolute + // top — microsoft/Terminal#20370). Clamp the diff to the visible viewport + // and skip above-viewport changes: scrollback keeps stale bytes, but the + // user's scroll position is preserved. + if (firstChanged < prevViewportTop) { + let visibleFirstChanged = -1; + for (let i = prevViewportTop; i <= lastChanged; i++) { + const oldLine = i < this.previousLines.length ? this.previousLines[i] : ""; + const newLine = i < newLines.length ? newLines[i] : ""; + if (oldLine !== newLine) { + visibleFirstChanged = i; + break; + } + } + if (visibleFirstChanged === -1) { + logRedraw(`all changes above viewport (firstChanged=${firstChanged} < ${prevViewportTop})`); + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; + this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousWidth = width; + this.previousHeight = height; + this.previousViewportTop = prevViewportTop; + return; + } + logRedraw(`clamped firstChanged ${firstChanged} -> ${visibleFirstChanged} (viewportTop=${prevViewportTop})`); + firstChanged = visibleFirstChanged; + } + + // Render from first changed line to end + // Build buffer with all updates wrapped in synchronized output + let buffer = "\x1b[?2026h"; // Begin synchronized output + buffer += this.deleteChangedKittyImages(firstChanged, lastChanged); + const prevViewportBottom = prevViewportTop + height - 1; + const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged; + if (moveTargetRow > prevViewportBottom) { + const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop)); + const moveToBottom = height - 1 - currentScreenRow; + if (moveToBottom > 0) { + buffer += `\x1b[${moveToBottom}B`; + } + const scroll = moveTargetRow - prevViewportBottom; + buffer += "\r\n".repeat(scroll); + prevViewportTop += scroll; + viewportTop += scroll; + hardwareCursorRow = moveTargetRow; + } + + // Move cursor to first changed line (use hardwareCursorRow for actual position) + const lineDiff = computeLineDiff(moveTargetRow); + if (lineDiff > 0) { + buffer += `\x1b[${lineDiff}B`; // Move down + } else if (lineDiff < 0) { + buffer += `\x1b[${-lineDiff}A`; // Move up + } + + buffer += appendStart ? "\r\n" : "\r"; // Move to column 0 + + // Only render changed lines (firstChanged to lastChanged), not all lines to end + // This reduces flicker when only a single line changes (e.g., spinner animation) + const renderEnd = Math.min(lastChanged, newLines.length - 1); + for (let i = firstChanged; i <= renderEnd; i++) { + if (i > firstChanged) buffer += "\r\n"; + const line = newLines[i]!; + const isImage = isImageLine(line); + const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1; + if (imageReservedRows > 1) { + const imageStartScreenRow = i - viewportTop; + if (imageStartScreenRow < 0 || imageStartScreenRow + imageReservedRows > height) { + logRedraw( + `kitty image pre-clear would scroll (${imageStartScreenRow} + ${imageReservedRows} > ${height})`, + ); + fullRender(true); + return; + } + + buffer += "\x1b[2K"; + for (let row = 1; row < imageReservedRows; row++) { + buffer += "\r\n\x1b[2K"; + } + buffer += `\x1b[${imageReservedRows - 1}A`; + buffer += line; + buffer += `\x1b[${imageReservedRows - 1}B`; + i += imageReservedRows - 1; + continue; + } + + buffer += "\x1b[2K"; // Clear current line + if (!isImage && visibleWidth(line) > width) { + // Log all lines to crash file for debugging + const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log"); + const crashData = [ + `Crash at ${new Date().toISOString()}`, + `Terminal width: ${width}`, + `Line ${i} visible width: ${visibleWidth(line)}`, + "", + "=== All rendered lines ===", + ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`), + "", + ].join("\n"); + fs.mkdirSync(path.dirname(crashLogPath), { recursive: true }); + fs.writeFileSync(crashLogPath, crashData); + + // Clean up terminal state before throwing + this.stop(); + + const errorMsg = [ + `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`, + "", + "This is likely caused by a custom TUI component not truncating its output.", + "Use visibleWidth() to measure and truncateToWidth() to truncate lines.", + "", + `Debug log written to: ${crashLogPath}`, + ].join("\n"); + throw new Error(errorMsg); + } + buffer += line; + } + + // Track where cursor ended up after rendering + let finalCursorRow = renderEnd; + + // If we had more lines before, clear them and move cursor back + if (this.previousLines.length > newLines.length) { + // Move to end of new content first if we stopped before it + if (renderEnd < newLines.length - 1) { + const moveDown = newLines.length - 1 - renderEnd; + buffer += `\x1b[${moveDown}B`; + finalCursorRow = newLines.length - 1; + } + const extraLines = this.previousLines.length - newLines.length; + for (let i = newLines.length; i < this.previousLines.length; i++) { + buffer += "\r\n\x1b[2K"; + } + // Move cursor back to end of new content + buffer += `\x1b[${extraLines}A`; + } + + buffer += "\x1b[?2026l"; // End synchronized output + + if (process.env['PI_TUI_DEBUG'] === "1") { + const debugDir = "/tmp/tui"; + fs.mkdirSync(debugDir, { recursive: true }); + const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`); + const debugData = [ + `firstChanged: ${firstChanged}`, + `viewportTop: ${viewportTop}`, + `cursorRow: ${this.cursorRow}`, + `height: ${height}`, + `lineDiff: ${lineDiff}`, + `hardwareCursorRow: ${hardwareCursorRow}`, + `renderEnd: ${renderEnd}`, + `finalCursorRow: ${finalCursorRow}`, + `cursorPos: ${JSON.stringify(cursorPos)}`, + `newLines.length: ${newLines.length}`, + `previousLines.length: ${this.previousLines.length}`, + "", + "=== newLines ===", + JSON.stringify(newLines, null, 2), + "", + "=== previousLines ===", + JSON.stringify(this.previousLines, null, 2), + "", + "=== buffer ===", + JSON.stringify(buffer), + ].join("\n"); + fs.writeFileSync(debugPath, debugData); + } + + // Write entire buffer at once + this.terminal.write(buffer); + + // Track cursor position for next render + // cursorRow tracks end of content (for viewport calculation) + // hardwareCursorRow tracks actual terminal cursor position (for movement) + this.cursorRow = Math.max(0, newLines.length - 1); + this.hardwareCursorRow = finalCursorRow; + // Track terminal's working area (grows but doesn't shrink unless cleared) + this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); + this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1); + + // Position hardware cursor for IME + this.positionHardwareCursor(cursorPos, newLines.length); + + this.previousLines = newLines; + this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousWidth = width; + this.previousHeight = height; + } + + /** + * Position the hardware cursor for IME candidate window. + * @param cursorPos The cursor position extracted from rendered output, or null + * @param totalLines Total number of rendered lines + */ + private positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void { + if (!cursorPos || totalLines <= 0) { + this.terminal.hideCursor(); + return; + } + + // Clamp cursor position to valid range + const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1)); + const targetCol = Math.max(0, cursorPos.col); + + // Move cursor from current position to target + const rowDelta = targetRow - this.hardwareCursorRow; + let buffer = ""; + if (rowDelta > 0) { + buffer += `\x1b[${rowDelta}B`; // Move down + } else if (rowDelta < 0) { + buffer += `\x1b[${-rowDelta}A`; // Move up + } + // Move to absolute column (1-indexed) + buffer += `\x1b[${targetCol + 1}G`; + + if (buffer) { + this.terminal.write(buffer); + } + + this.hardwareCursorRow = targetRow; + if (this.showHardwareCursor) { + this.terminal.showCursor(); + } else { + this.terminal.hideCursor(); + } + } + + /** + * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`). + * @param timeoutMs Query timeout in milliseconds. + * @returns Promise containing the parsed RGB color, or undefined if it times out or fails to parse. + */ + queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise { + return new Promise((resolve) => { + const query: PendingOsc11BackgroundQuery = { + settled: false, + resolve, + timer: undefined, + }; + + query.timer = setTimeout(() => { + if (query.settled) { + return; + } + query.settled = true; + query.timer = undefined; + query.resolve?.(undefined); + query.resolve = undefined; + }, timeoutMs); + this.pendingOsc11BackgroundQueries.push(query); + this.pendingOsc11BackgroundReplies += 1; + this.terminal.write("\x1b]11;?\x07"); + }); + } + + /** + * Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`). + * Terminals that support the color palette notification protocol reply with + * `CSI ? 997 ; 1 n` for dark or `CSI ? 997 ; 2 n` for light. + */ + queryTerminalColorScheme({ timeoutMs }: { timeoutMs: number }): Promise { + return new Promise((resolve) => { + let settled = false; + let timer: NodeJS.Timeout | undefined; + let unsubscribe: () => void = () => {}; + const settle = (scheme: TerminalColorScheme | undefined) => { + if (settled) return; + settled = true; + if (timer) { + clearTimeout(timer); + timer = undefined; + } + unsubscribe(); + resolve(scheme); + }; + + unsubscribe = this.onTerminalColorSchemeChange(settle); + timer = setTimeout(() => settle(undefined), timeoutMs); + this.terminal.write("\x1b[?996n"); + }); + } +} diff --git a/packages/pi-tui/src/undo-stack.ts b/packages/pi-tui/src/undo-stack.ts new file mode 100644 index 000000000..5b9a7e9ce --- /dev/null +++ b/packages/pi-tui/src/undo-stack.ts @@ -0,0 +1,28 @@ +/** + * Generic undo stack with clone-on-push semantics. + * + * Stores deep clones of state snapshots. Popped snapshots are returned + * directly (no re-cloning) since they are already detached. + */ +export class UndoStack { + private stack: S[] = []; + + /** Push a deep clone of the given state onto the stack. */ + push(state: S): void { + this.stack.push(structuredClone(state)); + } + + /** Pop and return the most recent snapshot, or undefined if empty. */ + pop(): S | undefined { + return this.stack.pop(); + } + + /** Remove all snapshots. */ + clear(): void { + this.stack.length = 0; + } + + get length(): number { + return this.stack.length; + } +} diff --git a/packages/pi-tui/src/utils.ts b/packages/pi-tui/src/utils.ts new file mode 100644 index 000000000..3823c386b --- /dev/null +++ b/packages/pi-tui/src/utils.ts @@ -0,0 +1,1188 @@ +import { eastAsianWidth } from "get-east-asian-width"; + +// segmenters (shared instance) +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +const wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" }); + +/** + * Get the shared grapheme segmenter instance. + */ +export function getGraphemeSegmenter(): Intl.Segmenter { + return graphemeSegmenter; +} + +/** + * Get the shared word segmenter instance. + */ +export function getWordSegmenter(): Intl.Segmenter { + return wordSegmenter; +} + +/** + * 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(); + +export const cjkBreakRegex = + /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo}]/u; + +function isPrintableAscii(str: string): boolean { + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code < 0x20 || code > 0x7e) { + return false; + } + } + return true; +} + +function truncateFragmentToWidth(text: string, maxWidth: number): { text: string; width: number } { + if (maxWidth <= 0 || text.length === 0) { + return { text: "", width: 0 }; + } + + if (isPrintableAscii(text)) { + const clipped = text.slice(0, maxWidth); + return { text: clipped, width: clipped.length }; + } + + const hasAnsi = text.includes("\x1b"); + const hasTabs = text.includes("\t"); + if (!hasAnsi && !hasTabs) { + let result = ""; + let width = 0; + for (const { segment } of graphemeSegmenter.segment(text)) { + const w = graphemeWidth(segment); + if (width + w > maxWidth) { + break; + } + result += segment; + width += w; + } + return { text: result, width }; + } + + let result = ""; + let width = 0; + let i = 0; + let pendingAnsi = ""; + + while (i < text.length) { + const ansi = extractAnsiCode(text, i); + if (ansi) { + pendingAnsi += ansi.code; + i += ansi.length; + continue; + } + + if (text[i] === "\t") { + if (width + 3 > maxWidth) { + break; + } + if (pendingAnsi) { + result += pendingAnsi; + pendingAnsi = ""; + } + result += "\t"; + width += 3; + i++; + continue; + } + + let end = i; + while (end < text.length && text[end] !== "\t") { + const nextAnsi = extractAnsiCode(text, end); + if (nextAnsi) { + break; + } + end++; + } + + for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) { + const w = graphemeWidth(segment); + if (width + w > maxWidth) { + return { text: result, width }; + } + if (pendingAnsi) { + result += pendingAnsi; + pendingAnsi = ""; + } + result += segment; + width += w; + } + i = end; + } + + return { text: result, width }; +} + +function finalizeTruncatedResult( + prefix: string, + prefixWidth: number, + ellipsis: string, + ellipsisWidth: number, + maxWidth: number, + pad: boolean, +): string { + const reset = "\x1b[0m"; + const visibleWidth = prefixWidth + ellipsisWidth; + let result: string; + + if (ellipsis.length > 0) { + result = `${prefix}${reset}${ellipsis}${reset}`; + } else { + result = `${prefix}${reset}`; + } + + return pad ? result + " ".repeat(Math.max(0, maxWidth - visibleWidth)) : result; +} + +/** + * 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 { + if (segment === "\t") { + return 3; + } + + // 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; + } + + // Regional indicator symbols (U+1F1E6..U+1F1FF) are often rendered as + // full-width emoji in terminals, even when isolated during streaming. + // Keep width conservative (2) to avoid terminal auto-wrap drift artifacts. + if (cp >= 0x1f1e6 && cp <= 0x1f1ff) { + return 2; + } + + let width = eastAsianWidth(cp); + + // Trailing halfwidth/fullwidth forms and AM vowels that segment with a base. + if (segment.length > 1) { + for (const char of segment.slice(1)) { + const c = char.codePointAt(0)!; + if (c >= 0xff00 && c <= 0xffef) { + width += eastAsianWidth(c); + } else if (c === 0x0e33 || c === 0x0eb3) { + width += 1; + } + } + } + + return width; +} + +/** + * Calculate the visible width of a string in terminal columns. + */ +export function visibleWidth(str: string): number { + if (str.length === 0) { + return 0; + } + + // Fast path: pure ASCII printable + if (isPrintableAscii(str)) { + 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 supported ANSI/OSC/APC escape sequences in one pass. + // This covers CSI styling/cursor codes, OSC hyperlinks and prompt markers, + // and APC sequences like CURSOR_MARKER. + let stripped = ""; + let i = 0; + while (i < clean.length) { + const ansi = extractAnsiCode(clean, i); + if (ansi) { + i += ansi.length; + continue; + } + stripped += clean[i]; + i++; + } + clean = stripped; + } + + // Calculate width + let width = 0; + for (const { segment } of graphemeSegmenter.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; +} + +/** + * Normalize text for terminal output without changing logical editor content. + * Some terminals render precomposed Thai/Lao AM vowels inconsistently during + * differential repaint. Their compatibility decompositions have the same cell + * width but avoid stale-cell artifacts in terminal renderers. + */ +const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/; +const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g; + +export function normalizeTerminalOutput(str: string): string { + if (!THAI_LAO_AM_REGEX.test(str)) return str; + return str.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) => (char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2")); +} + +/** + * Extract ANSI escape sequences from a string at the given position. + */ +export function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null { + if (pos >= str.length || str[pos] !== "\x1b") return null; + + const next = str[pos + 1]; + + // CSI sequence: ESC [ ... m/G/K/H/J + if (next === "[") { + let j = pos + 2; + while (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++; + if (j < str.length) return { code: str.substring(pos, j + 1), length: j + 1 - pos }; + return null; + } + + // OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \) + // Used for hyperlinks (OSC 8), window titles, etc. + if (next === "]") { + let j = pos + 2; + while (j < str.length) { + if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos }; + if (str[j] === "\x1b" && str[j + 1] === "\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos }; + j++; + } + return null; + } + + // APC sequence: ESC _ ... BEL or ESC _ ... ST (ESC \) + // Used for cursor marker and application-specific commands + if (next === "_") { + let j = pos + 2; + while (j < str.length) { + if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos }; + if (str[j] === "\x1b" && str[j + 1] === "\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos }; + j++; + } + return null; + } + + return null; +} + +type Osc8Terminator = "\x07" | "\x1b\\"; + +interface ActiveHyperlink { + params: string; + url: string; + terminator: Osc8Terminator; +} + +function parseOsc8Hyperlink(ansiCode: string): ActiveHyperlink | null | undefined { + if (!ansiCode.startsWith("\x1b]8;")) { + return undefined; + } + + const terminator: Osc8Terminator = ansiCode.endsWith("\x07") ? "\x07" : "\x1b\\"; + const body = ansiCode.slice(4, terminator === "\x07" ? -1 : -2); + const separatorIndex = body.indexOf(";"); + if (separatorIndex === -1) { + return undefined; + } + + const params = body.slice(0, separatorIndex); + const url = body.slice(separatorIndex + 1); + if (!url) { + return null; + } + return { params, url, terminator }; +} + +function formatOsc8Hyperlink(hyperlink: ActiveHyperlink): string { + return `\x1b]8;${hyperlink.params};${hyperlink.url}${hyperlink.terminator}`; +} + +function formatOsc8Close(terminator: Osc8Terminator): string { + return `\x1b]8;;${terminator}`; +} + +/** + * Track active ANSI SGR codes to preserve styling across line breaks. + */ +class AnsiCodeTracker { + // Track individual attributes separately so we can reset them specifically + private bold = false; + private dim = false; + private italic = false; + private underline = false; + private blink = false; + private inverse = false; + private hidden = false; + private strikethrough = false; + private fgColor: string | null = null; // Stores the full code like "31" or "38;5;240" + private bgColor: string | null = null; // Stores the full code like "41" or "48;5;240" + private activeHyperlink: ActiveHyperlink | null = null; + + process(ansiCode: string): void { + // OSC 8 hyperlink: \x1b]8;;\x1b\\ (open) or \x1b]8;;\x1b\\ (close). + // Preserve the original terminator because some terminals only make BEL-terminated + // links clickable. OAuth login URLs use BEL, so reopening wrapped lines with ST + // made only the first physical line clickable in those terminals. + const hyperlink = parseOsc8Hyperlink(ansiCode); + if (hyperlink !== undefined) { + this.activeHyperlink = hyperlink; + return; + } + + if (!ansiCode.endsWith("m")) { + return; + } + + // Extract the parameters between \x1b[ and m + const match = ansiCode.match(/\x1b\[([\d;]*)m/); + if (!match) return; + + const params = match[1]!; + if (params === "" || params === "0") { + // Full reset + this.reset(); + return; + } + + // Parse parameters (can be semicolon-separated) + const parts = params.split(";"); + let i = 0; + while (i < parts.length) { + const code = Number.parseInt(parts[i]!, 10); + + // Handle 256-color and RGB codes which consume multiple parameters + if (code === 38 || code === 48) { + // 38;5;N (256 color fg) or 38;2;R;G;B (RGB fg) + // 48;5;N (256 color bg) or 48;2;R;G;B (RGB bg) + if (parts[i + 1] === "5" && parts[i + 2] !== undefined) { + // 256 color: 38;5;N or 48;5;N + const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]}`; + if (code === 38) { + this.fgColor = colorCode; + } else { + this.bgColor = colorCode; + } + i += 3; + continue; + } else if (parts[i + 1] === "2" && parts[i + 4] !== undefined) { + // RGB color: 38;2;R;G;B or 48;2;R;G;B + const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]};${parts[i + 3]};${parts[i + 4]}`; + if (code === 38) { + this.fgColor = colorCode; + } else { + this.bgColor = colorCode; + } + i += 5; + continue; + } + } + + // Standard SGR codes + switch (code) { + case 0: + this.reset(); + break; + case 1: + this.bold = true; + break; + case 2: + this.dim = true; + break; + case 3: + this.italic = true; + break; + case 4: + this.underline = true; + break; + case 5: + this.blink = true; + break; + case 7: + this.inverse = true; + break; + case 8: + this.hidden = true; + break; + case 9: + this.strikethrough = true; + break; + case 21: + this.bold = false; + break; // Some terminals + case 22: + this.bold = false; + this.dim = false; + break; + case 23: + this.italic = false; + break; + case 24: + this.underline = false; + break; + case 25: + this.blink = false; + break; + case 27: + this.inverse = false; + break; + case 28: + this.hidden = false; + break; + case 29: + this.strikethrough = false; + break; + case 39: + this.fgColor = null; + break; // Default fg + case 49: + this.bgColor = null; + break; // Default bg + default: + // Standard foreground colors 30-37, 90-97 + if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { + this.fgColor = String(code); + } + // Standard background colors 40-47, 100-107 + else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { + this.bgColor = String(code); + } + break; + } + i++; + } + } + + private reset(): void { + this.bold = false; + this.dim = false; + this.italic = false; + this.underline = false; + this.blink = false; + this.inverse = false; + this.hidden = false; + this.strikethrough = false; + this.fgColor = null; + this.bgColor = null; + // SGR reset does not affect OSC 8 hyperlink state + } + + /** Clear all state for reuse. */ + clear(): void { + this.reset(); + this.activeHyperlink = null; + } + + getActiveCodes(): string { + const codes: string[] = []; + if (this.bold) codes.push("1"); + if (this.dim) codes.push("2"); + if (this.italic) codes.push("3"); + if (this.underline) codes.push("4"); + if (this.blink) codes.push("5"); + if (this.inverse) codes.push("7"); + if (this.hidden) codes.push("8"); + if (this.strikethrough) codes.push("9"); + if (this.fgColor) codes.push(this.fgColor); + if (this.bgColor) codes.push(this.bgColor); + + let result = codes.length > 0 ? `\x1b[${codes.join(";")}m` : ""; + if (this.activeHyperlink) { + result += formatOsc8Hyperlink(this.activeHyperlink); + } + return result; + } + + hasActiveCodes(): boolean { + return ( + this.bold || + this.dim || + this.italic || + this.underline || + this.blink || + this.inverse || + this.hidden || + this.strikethrough || + this.fgColor !== null || + this.bgColor !== null || + this.activeHyperlink !== null + ); + } + + /** + * Get reset codes for attributes that need to be turned off at line end. + * Underline must be closed to prevent bleeding into padding. + * Active OSC 8 hyperlinks must be closed and re-opened on the next line. + * Returns empty string if no attributes need closing. + */ + getLineEndReset(): string { + let result = ""; + if (this.underline) { + result += "\x1b[24m"; // Underline off only + } + if (this.activeHyperlink) { + result += formatOsc8Close(this.activeHyperlink.terminator); // Re-opened at line start via getActiveCodes() + } + return result; + } +} + +function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void { + let i = 0; + while (i < text.length) { + const ansiResult = extractAnsiCode(text, i); + if (ansiResult) { + tracker.process(ansiResult.code); + i += ansiResult.length; + } else { + i++; + } + } +} + +/** + * Split text into words while keeping ANSI codes attached. + */ +function splitIntoTokensWithAnsi(text: string): string[] { + const tokens: string[] = []; + let current = ""; + let pendingAnsi = ""; // ANSI codes waiting to be attached to next visible content + let currentKind: "space" | "word" | null = null; + let i = 0; + + const flushCurrent = (): void => { + if (!current) { + return; + } + tokens.push(current); + current = ""; + currentKind = null; + }; + + while (i < text.length) { + const ansiResult = extractAnsiCode(text, i); + if (ansiResult) { + // Hold ANSI codes separately - they'll be attached to the next visible char + pendingAnsi += ansiResult.code; + i += ansiResult.length; + continue; + } + + let end = i; + while (end < text.length && !extractAnsiCode(text, end)) { + end++; + } + + for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) { + const segmentIsSpace = segment === " "; + if (!segmentIsSpace && cjkBreakRegex.test(segment)) { + flushCurrent(); + const token = pendingAnsi + segment; + pendingAnsi = ""; + tokens.push(token); + continue; + } + + const segmentKind = segmentIsSpace ? "space" : "word"; + if (current && currentKind !== segmentKind) { + flushCurrent(); + } + + // Attach any pending ANSI codes to this visible character + if (pendingAnsi) { + current += pendingAnsi; + pendingAnsi = ""; + } + + currentKind = segmentKind; + current += segment; + } + + i = end; + } + + // Handle any remaining pending ANSI codes (attach to last token) + if (pendingAnsi) { + if (current) { + current += pendingAnsi; + } else if (tokens.length > 0) { + tokens[tokens.length - 1] += pendingAnsi; + } else { + current = pendingAnsi; + } + } + + if (current) { + tokens.push(current); + } + + return tokens; +} + +/** + * Wrap text with ANSI codes preserved. + * + * ONLY does word wrapping - NO padding, NO background colors. + * Returns lines where each line is <= width visible chars. + * Active ANSI codes are preserved across line breaks. + * + * @param text - Text to wrap (may contain ANSI codes and newlines) + * @param width - Maximum visible width per line + * @returns Array of wrapped lines (NOT padded to width) + */ +export function wrapTextWithAnsi(text: string, width: number): string[] { + if (!text) { + return [""]; + } + + // Handle newlines by processing each line separately + // Track ANSI state across lines so styles carry over after literal newlines + const inputLines = text.split("\n"); + const result: string[] = []; + const tracker = new AnsiCodeTracker(); + + for (const inputLine of inputLines) { + // Prepend active ANSI codes from previous lines (except for first line) + const prefix = result.length > 0 ? tracker.getActiveCodes() : ""; + const wrappedLines = wrapSingleLine(prefix + inputLine, width); + for (const wrappedLine of wrappedLines) { + result.push(wrappedLine); + } + // Update tracker with codes from this line for next iteration + updateTrackerFromText(inputLine, tracker); + } + + return result.length > 0 ? result : [""]; +} + +function wrapSingleLine(line: string, width: number): string[] { + if (!line) { + return [""]; + } + + const visibleLength = visibleWidth(line); + if (visibleLength <= width) { + return [line]; + } + + const wrapped: string[] = []; + const tracker = new AnsiCodeTracker(); + const tokens = splitIntoTokensWithAnsi(line); + + let currentLine = ""; + let currentVisibleLength = 0; + + for (const token of tokens) { + const tokenVisibleLength = visibleWidth(token); + const isWhitespace = token.trim() === ""; + + // Token itself is too long - break it character by character + if (tokenVisibleLength > width && !isWhitespace) { + if (currentLine) { + // Add specific reset for underline only (preserves background) + const lineEndReset = tracker.getLineEndReset(); + if (lineEndReset) { + currentLine += lineEndReset; + } + wrapped.push(currentLine); + currentLine = ""; + currentVisibleLength = 0; + } + + // Break long token - breakLongWord handles its own resets + const broken = breakLongWord(token, width, tracker); + for (let i = 0; i < broken.length - 1; i++) { + wrapped.push(broken[i]!); + } + currentLine = broken[broken.length - 1]!; + currentVisibleLength = visibleWidth(currentLine); + continue; + } + + // Check if adding this token would exceed width + const totalNeeded = currentVisibleLength + tokenVisibleLength; + + if (totalNeeded > width && currentVisibleLength > 0) { + // Trim trailing whitespace, then add underline reset (not full reset, to preserve background) + let lineToWrap = currentLine.trimEnd(); + const lineEndReset = tracker.getLineEndReset(); + if (lineEndReset) { + lineToWrap += lineEndReset; + } + wrapped.push(lineToWrap); + if (isWhitespace) { + // Don't start new line with whitespace + currentLine = tracker.getActiveCodes(); + currentVisibleLength = 0; + } else { + currentLine = tracker.getActiveCodes() + token; + currentVisibleLength = tokenVisibleLength; + } + } else { + // Add to current line + currentLine += token; + currentVisibleLength += tokenVisibleLength; + } + + updateTrackerFromText(token, tracker); + } + + if (currentLine) { + // No reset at end of final line - let caller handle it + wrapped.push(currentLine); + } + + // Trailing whitespace can cause lines to exceed the requested width + return wrapped.length > 0 ? wrapped.map((line) => line.trimEnd()) : [""]; +} + +export const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/; + +/** + * Check if a character is whitespace. + */ +export function isWhitespaceChar(char: string): boolean { + return /\s/.test(char); +} + +/** + * Check if a character is punctuation. + */ +export function isPunctuationChar(char: string): boolean { + return PUNCTUATION_REGEX.test(char); +} + +function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): string[] { + const lines: string[] = []; + let currentLine = tracker.getActiveCodes(); + let currentWidth = 0; + + // First, separate ANSI codes from visible content + // We need to handle ANSI codes specially since they're not graphemes + let i = 0; + const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = []; + + while (i < word.length) { + const ansiResult = extractAnsiCode(word, i); + if (ansiResult) { + segments.push({ type: "ansi", value: ansiResult.code }); + i += ansiResult.length; + } else { + // Find the next ANSI code or end of string + let end = i; + while (end < word.length) { + const nextAnsi = extractAnsiCode(word, end); + if (nextAnsi) break; + end++; + } + // Segment this non-ANSI portion into graphemes + const textPortion = word.slice(i, end); + for (const seg of graphemeSegmenter.segment(textPortion)) { + segments.push({ type: "grapheme", value: seg.segment }); + } + i = end; + } + } + + // Now process segments + for (const seg of segments) { + if (seg.type === "ansi") { + currentLine += seg.value; + tracker.process(seg.value); + continue; + } + + 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) { + // Add specific reset for underline only (preserves background) + const lineEndReset = tracker.getLineEndReset(); + if (lineEndReset) { + currentLine += lineEndReset; + } + lines.push(currentLine); + currentLine = tracker.getActiveCodes(); + currentWidth = 0; + } + + currentLine += grapheme; + currentWidth += graphemeWidth; + } + + if (currentLine) { + // No reset at end of final segment - caller handles continuation + lines.push(currentLine); + } + + return lines.length > 0 ? lines : [""]; +} + +/** + * Apply background color to a line, padding to full width. + * + * @param line - Line of text (may contain ANSI codes) + * @param width - Total width to pad to + * @param bgFn - Background color function + * @returns Line with background applied and padded to width + */ +export function applyBackgroundToLine(line: string, width: number, bgFn: (text: string) => string): string { + // Calculate padding needed + const visibleLen = visibleWidth(line); + const paddingNeeded = Math.max(0, width - visibleLen); + const padding = " ".repeat(paddingNeeded); + + // Apply background to content + padding + const withPadding = line + padding; + return bgFn(withPadding); +} + +/** + * Truncate text to fit within a maximum visible width, adding ellipsis if needed. + * Optionally pad with spaces to reach exactly maxWidth. + * Properly handles ANSI escape codes (they don't count toward width). + * + * @param text - Text to truncate (may contain ANSI codes) + * @param maxWidth - Maximum visible width + * @param ellipsis - Ellipsis string to append when truncating (default: "...") + * @param pad - If true, pad result with spaces to exactly maxWidth (default: false) + * @returns Truncated text, optionally padded to exactly maxWidth + */ +export function truncateToWidth( + text: string, + maxWidth: number, + ellipsis: string = "...", + pad: boolean = false, +): string { + if (maxWidth <= 0) { + return ""; + } + + if (text.length === 0) { + return pad ? " ".repeat(maxWidth) : ""; + } + + const ellipsisWidth = visibleWidth(ellipsis); + if (ellipsisWidth >= maxWidth) { + const textWidth = visibleWidth(text); + if (textWidth <= maxWidth) { + return pad ? text + " ".repeat(maxWidth - textWidth) : text; + } + + const clippedEllipsis = truncateFragmentToWidth(ellipsis, maxWidth); + if (clippedEllipsis.width === 0) { + return pad ? " ".repeat(maxWidth) : ""; + } + return finalizeTruncatedResult("", 0, clippedEllipsis.text, clippedEllipsis.width, maxWidth, pad); + } + + if (isPrintableAscii(text)) { + if (text.length <= maxWidth) { + return pad ? text + " ".repeat(maxWidth - text.length) : text; + } + const targetWidth = maxWidth - ellipsisWidth; + return finalizeTruncatedResult(text.slice(0, targetWidth), targetWidth, ellipsis, ellipsisWidth, maxWidth, pad); + } + + const targetWidth = maxWidth - ellipsisWidth; + let result = ""; + let pendingAnsi = ""; + let visibleSoFar = 0; + let keptWidth = 0; + let keepContiguousPrefix = true; + let overflowed = false; + let exhaustedInput = false; + const hasAnsi = text.includes("\x1b"); + const hasTabs = text.includes("\t"); + + if (!hasAnsi && !hasTabs) { + for (const { segment } of graphemeSegmenter.segment(text)) { + const width = graphemeWidth(segment); + if (keepContiguousPrefix && keptWidth + width <= targetWidth) { + result += segment; + keptWidth += width; + } else { + keepContiguousPrefix = false; + } + visibleSoFar += width; + if (visibleSoFar > maxWidth) { + overflowed = true; + break; + } + } + exhaustedInput = !overflowed; + } else { + let i = 0; + while (i < text.length) { + const ansi = extractAnsiCode(text, i); + if (ansi) { + pendingAnsi += ansi.code; + i += ansi.length; + continue; + } + + if (text[i] === "\t") { + if (keepContiguousPrefix && keptWidth + 3 <= targetWidth) { + if (pendingAnsi) { + result += pendingAnsi; + pendingAnsi = ""; + } + result += "\t"; + keptWidth += 3; + } else { + keepContiguousPrefix = false; + pendingAnsi = ""; + } + visibleSoFar += 3; + if (visibleSoFar > maxWidth) { + overflowed = true; + break; + } + i++; + continue; + } + + let end = i; + while (end < text.length && text[end] !== "\t") { + const nextAnsi = extractAnsiCode(text, end); + if (nextAnsi) { + break; + } + end++; + } + + for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) { + const width = graphemeWidth(segment); + if (keepContiguousPrefix && keptWidth + width <= targetWidth) { + if (pendingAnsi) { + result += pendingAnsi; + pendingAnsi = ""; + } + result += segment; + keptWidth += width; + } else { + keepContiguousPrefix = false; + pendingAnsi = ""; + } + + visibleSoFar += width; + if (visibleSoFar > maxWidth) { + overflowed = true; + break; + } + } + if (overflowed) { + break; + } + i = end; + } + exhaustedInput = i >= text.length; + } + + if (!overflowed && exhaustedInput) { + return pad ? text + " ".repeat(Math.max(0, maxWidth - visibleSoFar)) : text; + } + + return finalizeTruncatedResult(result, keptWidth, ellipsis, ellipsisWidth, maxWidth, pad); +} + +/** + * Extract a range of visible columns from a line. Handles ANSI codes and wide chars. + * @param strict - If true, exclude wide chars at boundary that would extend past the range + */ +export function sliceByColumn(line: string, startCol: number, length: number, strict = false): string { + return sliceWithWidth(line, startCol, length, strict).text; +} + +/** Like sliceByColumn but also returns the actual visible width of the result. */ +export function sliceWithWidth( + line: string, + startCol: number, + length: number, + strict = false, +): { text: string; width: number } { + if (length <= 0) return { text: "", width: 0 }; + const endCol = startCol + length; + let result = "", + resultWidth = 0, + currentCol = 0, + i = 0, + pendingAnsi = ""; + + while (i < line.length) { + const ansi = extractAnsiCode(line, i); + if (ansi) { + if (currentCol >= startCol && currentCol < endCol) result += ansi.code; + else if (currentCol < startCol) pendingAnsi += ansi.code; + i += ansi.length; + continue; + } + + let textEnd = i; + while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++; + + for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) { + const w = graphemeWidth(segment); + const inRange = currentCol >= startCol && currentCol < endCol; + const fits = !strict || currentCol + w <= endCol; + if (inRange && fits) { + if (pendingAnsi) { + result += pendingAnsi; + pendingAnsi = ""; + } + result += segment; + resultWidth += w; + } + currentCol += w; + if (currentCol >= endCol) break; + } + i = textEnd; + if (currentCol >= endCol) break; + } + return { text: result, width: resultWidth }; +} + +// Pooled tracker instance for extractSegments (avoids allocation per call) +const pooledStyleTracker = new AnsiCodeTracker(); + +/** + * Extract "before" and "after" segments from a line in a single pass. + * Used for overlay compositing where we need content before and after the overlay region. + * Preserves styling from before the overlay that should affect content after it. + */ +export function extractSegments( + line: string, + beforeEnd: number, + afterStart: number, + afterLen: number, + strictAfter = false, +): { before: string; beforeWidth: number; after: string; afterWidth: number } { + let before = "", + beforeWidth = 0, + after = "", + afterWidth = 0; + let currentCol = 0, + i = 0; + let pendingAnsiBefore = ""; + let afterStarted = false; + const afterEnd = afterStart + afterLen; + + // Track styling state so "after" inherits styling from before the overlay + pooledStyleTracker.clear(); + + while (i < line.length) { + const ansi = extractAnsiCode(line, i); + if (ansi) { + // Track all SGR codes to know styling state at afterStart + pooledStyleTracker.process(ansi.code); + // Include ANSI codes in their respective segments + if (currentCol < beforeEnd) { + pendingAnsiBefore += ansi.code; + } else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) { + // Only include after we've started "after" (styling already prepended) + after += ansi.code; + } + i += ansi.length; + continue; + } + + let textEnd = i; + while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++; + + for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) { + const w = graphemeWidth(segment); + + if (currentCol < beforeEnd && currentCol + w <= beforeEnd) { + if (pendingAnsiBefore) { + before += pendingAnsiBefore; + pendingAnsiBefore = ""; + } + before += segment; + beforeWidth += w; + } else if (currentCol >= afterStart && currentCol < afterEnd) { + const fits = !strictAfter || currentCol + w <= afterEnd; + if (fits) { + // On first "after" grapheme, prepend inherited styling from before overlay + if (!afterStarted) { + after += pooledStyleTracker.getActiveCodes(); + afterStarted = true; + } + after += segment; + afterWidth += w; + } + } + + currentCol += w; + // Early exit: done with "before" only, or done with both segments + if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break; + } + i = textEnd; + if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break; + } + + return { before, beforeWidth, after, afterWidth }; +} diff --git a/packages/pi-tui/src/word-navigation.ts b/packages/pi-tui/src/word-navigation.ts new file mode 100644 index 000000000..7c7eced2b --- /dev/null +++ b/packages/pi-tui/src/word-navigation.ts @@ -0,0 +1,117 @@ +import { getWordSegmenter, isWhitespaceChar, PUNCTUATION_REGEX } from "./utils.ts"; + +const wordSegmenter = getWordSegmenter(); + +/** + * Options for word navigation functions. + * When omitted, uses the default Intl.Segmenter word segmentation. + */ +export interface WordNavigationOptions { + /** Custom segmenter returning word segments for the given text. */ + segment?: (text: string) => Iterable; + /** Predicate identifying atomic segments that should be treated as single units (e.g. paste markers). */ + isAtomicSegment?: (segment: string) => boolean; +} + +/** + * Find the cursor position after moving one word backward from `cursor` in `text`. + * Skips trailing whitespace, then stops at the next word/punctuation boundary. + * + * Pure function - does not mutate any state. + */ +export function findWordBackward(text: string, cursor: number, options?: WordNavigationOptions): number { + if (cursor <= 0) return 0; + + const textBeforeCursor = text.slice(0, cursor); + const segmentFn = options?.segment; + const isAtomic = options?.isAtomicSegment; + const segments = segmentFn ? [...segmentFn(textBeforeCursor)] : [...wordSegmenter.segment(textBeforeCursor)]; + let newCursor = cursor; + + // Skip trailing whitespace + while ( + segments.length > 0 && + !isAtomic?.(segments[segments.length - 1]?.segment || "") && + isWhitespaceChar(segments[segments.length - 1]?.segment || "") + ) { + newCursor -= segments.pop()?.segment.length || 0; + } + + if (segments.length === 0) return newCursor; + + const last = segments[segments.length - 1]!; + + if (isAtomic?.(last.segment)) { + // Skip one atomic segment. + newCursor -= last.segment.length; + } else if (last.isWordLike) { + // Skip inside one word-like segment, preserving ASCII punctuation boundaries. + const segment = last.segment; + const matches = [...segment.matchAll(new RegExp(PUNCTUATION_REGEX, "g"))]; + if (matches.length <= 0) { + newCursor -= segment.length; + } else { + const lastMatch = matches[matches.length - 1]!; + newCursor -= segment.length - (lastMatch.index + lastMatch[0].length); + } + } else { + // Skip non-word non-whitespace run (punctuation) + while ( + segments.length > 0 && + !isAtomic?.(segments[segments.length - 1]?.segment || "") && + !segments[segments.length - 1]?.isWordLike && + !isWhitespaceChar(segments[segments.length - 1]?.segment || "") + ) { + newCursor -= segments.pop()?.segment.length || 0; + } + } + + return newCursor; +} + +/** + * Find the cursor position after moving one word forward from `cursor` in `text`. + * Skips leading whitespace, then stops at the next word/punctuation boundary. + * + * Pure function - does not mutate any state. + */ +export function findWordForward(text: string, cursor: number, options?: WordNavigationOptions): number { + if (cursor >= text.length) return text.length; + + const textAfterCursor = text.slice(cursor); + const segmentFn = options?.segment; + const isAtomic = options?.isAtomicSegment; + const segments = segmentFn ? segmentFn(textAfterCursor) : wordSegmenter.segment(textAfterCursor); + const iterator = segments[Symbol.iterator](); + let next = iterator.next(); + let newCursor = cursor; + + // Skip leading whitespace + while (!next.done && !isAtomic?.(next.value.segment) && isWhitespaceChar(next.value.segment)) { + newCursor += next.value.segment.length; + next = iterator.next(); + } + + if (next.done) return newCursor; + + if (isAtomic?.(next.value.segment)) { + // Skip one atomic segment. + newCursor += next.value.segment.length; + } else if (next.value.isWordLike) { + // Skip inside one word-like segment, preserving ASCII punctuation boundaries. + newCursor += PUNCTUATION_REGEX.exec(next.value.segment)?.index ?? next.value.segment.length; + } else { + // Skip non-word non-whitespace run (punctuation) + while ( + !next.done && + !isAtomic?.(next.value.segment) && + !next.value.isWordLike && + !isWhitespaceChar(next.value.segment) + ) { + newCursor += next.value.segment.length; + next = iterator.next(); + } + } + + return newCursor; +} diff --git a/packages/pi-tui/test/autocomplete.test.ts b/packages/pi-tui/test/autocomplete.test.ts new file mode 100644 index 000000000..be27d152b --- /dev/null +++ b/packages/pi-tui/test/autocomplete.test.ts @@ -0,0 +1,542 @@ +import assert from "node:assert"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, it, test } from "node:test"; +import { CombinedAutocompleteProvider } from "../src/autocomplete.ts"; + +const resolveFdPath = (): string | null => { + const command = process.platform === "win32" ? "where" : "which"; + const result = spawnSync(command, ["fd"], { encoding: "utf-8" }); + if (result.status !== 0 || !result.stdout) { + return null; + } + + const firstLine = result.stdout.split(/\r?\n/).find(Boolean); + return firstLine ? firstLine.trim() : null; +}; + +type FolderStructure = { + dirs?: string[]; + files?: Record; +}; + +const setupFolder = (baseDir: string, structure: FolderStructure = {}): void => { + const dirs = structure.dirs ?? []; + const files = structure.files ?? {}; + + dirs.forEach((dir) => { + mkdirSync(join(baseDir, dir), { recursive: true }); + }); + Object.entries(files).forEach(([filePath, contents]) => { + const fullPath = join(baseDir, filePath); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, contents); + }); +}; + +const fdPath = resolveFdPath(); +const isFdInstalled = Boolean(fdPath); + +const requireFdPath = (): string => { + if (!fdPath) { + throw new Error("fd is not available"); + } + return fdPath; +}; + +const getSuggestions = ( + provider: CombinedAutocompleteProvider, + lines: string[], + cursorLine: number, + cursorCol: number, + force: boolean = false, +) => provider.getSuggestions(lines, cursorLine, cursorCol, { signal: new AbortController().signal, force }); + +describe("CombinedAutocompleteProvider", () => { + describe("extractPathPrefix", () => { + it("extracts / from 'hey /' when forced", async () => { + const provider = new CombinedAutocompleteProvider([], "/tmp"); + const lines = ["hey /"]; + const cursorLine = 0; + const cursorCol = 5; // After the "/" + + const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true); + + assert.notEqual(result, null, "Should return suggestions for root directory"); + if (result) { + assert.strictEqual(result.prefix, "/", "Prefix should be '/'"); + } + }); + + it("extracts /A from '/A' when forced", async () => { + const provider = new CombinedAutocompleteProvider([], "/tmp"); + const lines = ["/A"]; + const cursorLine = 0; + const cursorCol = 2; // After the "A" + + const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true); + + console.log("Result:", result); + // This might return null if /A doesn't match anything, which is fine + // We're mainly testing that the prefix extraction works + if (result) { + assert.strictEqual(result.prefix, "/A", "Prefix should be '/A'"); + } + }); + + it("does not trigger for slash commands", async () => { + const provider = new CombinedAutocompleteProvider([], "/tmp"); + const lines = ["/model"]; + const cursorLine = 0; + const cursorCol = 6; // After "model" + + const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true); + + console.log("Result:", result); + assert.strictEqual(result, null, "Should not trigger for slash commands"); + }); + + it("triggers for absolute paths after slash command argument", async () => { + const provider = new CombinedAutocompleteProvider([], "/tmp"); + const lines = ["/command /"]; + const cursorLine = 0; + const cursorCol = 10; // After the second "/" + + const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true); + + console.log("Result:", result); + assert.notEqual(result, null, "Should trigger for absolute paths in command arguments"); + if (result) { + assert.strictEqual(result.prefix, "/", "Prefix should be '/'"); + } + }); + }); + + describe("fd @ file suggestions", { skip: !isFdInstalled }, () => { + let rootDir = ""; + let baseDir = ""; + let outsideDir = ""; + + beforeEach(() => { + rootDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-root-")); + baseDir = join(rootDir, "cwd"); + outsideDir = join(rootDir, "outside"); + mkdirSync(baseDir, { recursive: true }); + mkdirSync(outsideDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(rootDir, { recursive: true, force: true }); + }); + + test("returns all files and folders for empty @ query", async () => { + setupFolder(baseDir, { + dirs: ["src"], + files: { + "README.md": "readme", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = "@"; + const result = await getSuggestions(provider, [line], 0, line.length); + + const values = result?.items.map((item) => item.value).sort(); + assert.deepStrictEqual(values, ["@README.md", "@src/"].sort()); + }); + + test("matches file with extension in query", async () => { + setupFolder(baseDir, { + files: { + "file.txt": "content", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = "@file.txt"; + const result = await getSuggestions(provider, [line], 0, line.length); + + const values = result?.items.map((item) => item.value); + assert.ok(values?.includes("@file.txt")); + }); + + test("filters are case insensitive", async () => { + setupFolder(baseDir, { + dirs: ["src"], + files: { + "README.md": "readme", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = "@re"; + const result = await getSuggestions(provider, [line], 0, line.length); + + const values = result?.items.map((item) => item.value).sort(); + assert.deepStrictEqual(values, ["@README.md"]); + }); + + test("ranks directories before files", async () => { + setupFolder(baseDir, { + dirs: ["src"], + files: { + "src.txt": "text", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = "@src"; + const result = await getSuggestions(provider, [line], 0, line.length); + + const firstValue = result?.items[0]?.value; + const hasSrcFile = result?.items?.some((item) => item.value === "@src.txt"); + assert.strictEqual(firstValue, "@src/"); + assert.ok(hasSrcFile); + }); + + test("returns nested file paths", async () => { + setupFolder(baseDir, { + files: { + "src/index.ts": "export {};\n", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = "@index"; + const result = await getSuggestions(provider, [line], 0, line.length); + + const values = result?.items.map((item) => item.value); + assert.ok(values?.includes("@src/index.ts")); + }); + + test("matches deeply nested paths", async () => { + setupFolder(baseDir, { + files: { + "packages/tui/src/autocomplete.ts": "export {};", + "packages/ai/src/autocomplete.ts": "export {};", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = "@tui/src/auto"; + const result = await getSuggestions(provider, [line], 0, line.length); + + const values = result?.items.map((item) => item.value); + assert.ok(values?.includes("@packages/tui/src/autocomplete.ts")); + assert.ok(!values?.includes("@packages/ai/src/autocomplete.ts")); + }); + + test("matches directory in middle of path with --full-path", async () => { + setupFolder(baseDir, { + files: { + "src/components/Button.tsx": "export {};", + "src/utils/helpers.ts": "export {};", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = "@components/"; + const result = await getSuggestions(provider, [line], 0, line.length); + + const values = result?.items.map((item) => item.value); + assert.ok(values?.includes("@src/components/Button.tsx")); + assert.ok(!values?.includes("@src/utils/helpers.ts")); + }); + + test("scopes fuzzy search to relative directories and searches recursively", async () => { + setupFolder(outsideDir, { + files: { + "nested/alpha.ts": "export {};", + "nested/deeper/also-alpha.ts": "export {};", + "nested/deeper/zzz.ts": "export {};", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = "@../outside/a"; + const result = await getSuggestions(provider, [line], 0, line.length); + + const values = result?.items.map((item) => item.value); + assert.ok(values?.includes("@../outside/nested/alpha.ts")); + assert.ok(values?.includes("@../outside/nested/deeper/also-alpha.ts")); + assert.ok(!values?.includes("@../outside/nested/deeper/zzz.ts")); + }); + + test("quotes paths with spaces for @ suggestions", async () => { + setupFolder(baseDir, { + dirs: ["my folder"], + files: { + "my folder/test.txt": "content", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = "@my"; + const result = await getSuggestions(provider, [line], 0, line.length); + + const values = result?.items.map((item) => item.value); + assert.ok(values?.includes('@"my folder/"')); + }); + + test("includes hidden paths but excludes .git", async () => { + setupFolder(baseDir, { + dirs: [".pi", ".github", ".git"], + files: { + ".pi/config.json": "{}", + ".github/workflows/ci.yml": "name: ci", + ".git/config": "[core]", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = "@"; + const result = await getSuggestions(provider, [line], 0, line.length); + + const values = result?.items.map((item) => item.value) ?? []; + assert.ok(values.includes("@.pi/")); + assert.ok(values.includes("@.github/")); + assert.ok(!values.some((value) => value === "@.git" || value.startsWith("@.git/"))); + }); + + test("follows symlinked directories for fuzzy @ search", async () => { + setupFolder(baseDir, { + files: { + "dir/some_file.txt": "real", + }, + }); + setupFolder(outsideDir, { + files: { + "some_file.txt": "symlinked", + }, + }); + symlinkSync("../outside", join(baseDir, "symlinked_dir")); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = "@some"; + const result = await getSuggestions(provider, [line], 0, line.length); + + const values = result?.items.map((item) => item.value) ?? []; + assert.ok(values.includes("@dir/some_file.txt")); + assert.ok(values.includes("@symlinked_dir/some_file.txt")); + }); + + test("returns symlinked directories when matching their name", async () => { + setupFolder(outsideDir, { + files: { + "nested/file.txt": "symlinked", + }, + }); + symlinkSync("../outside", join(baseDir, "symlinked_dir")); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = "@symlinked"; + const result = await getSuggestions(provider, [line], 0, line.length); + + const values = result?.items.map((item) => item.value) ?? []; + assert.ok(values.includes("@symlinked_dir/")); + }); + + test("returns symlinked files without requiring type l", async () => { + setupFolder(baseDir, { + files: { + "original.txt": "content", + }, + }); + const linkPath = join(baseDir, "link.txt"); + symlinkSync("original.txt", linkPath); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = "@link"; + const result = await getSuggestions(provider, [line], 0, line.length); + + const values = result?.items.map((item) => item.value) ?? []; + assert.ok(values.includes("@link.txt")); + }); + + test("returns the same @ suggestions when the cwd path contains the query", async () => { + const normalBaseDir = join(rootDir, "cwd-normal"); + const queryInPathBaseDir = join(rootDir, "cwd-plan-repro"); + mkdirSync(normalBaseDir, { recursive: true }); + mkdirSync(queryInPathBaseDir, { recursive: true }); + + const structure = { + dirs: ["packages/coding-agent/examples/extensions/plan-mode"], + files: { + "packages/coding-agent/examples/extensions/plan-mode/README.md": "readme", + "packages/tui/docs/plan.md": "plan", + }, + }; + setupFolder(normalBaseDir, structure); + setupFolder(queryInPathBaseDir, structure); + + const query = "@plan"; + const normalProvider = new CombinedAutocompleteProvider([], normalBaseDir, requireFdPath()); + const queryInPathProvider = new CombinedAutocompleteProvider([], queryInPathBaseDir, requireFdPath()); + + const normalResult = await getSuggestions(normalProvider, [query], 0, query.length); + const queryInPathResult = await getSuggestions(queryInPathProvider, [query], 0, query.length); + + const normalize = (result: Awaited>) => + (result?.items ?? []).map((item) => `${item.label} :: ${item.description ?? ""}`).sort(); + + assert.deepStrictEqual(normalize(queryInPathResult), normalize(normalResult)); + assert.ok( + normalize(normalResult).includes("plan-mode/ :: packages/coding-agent/examples/extensions/plan-mode"), + ); + assert.ok(normalize(normalResult).includes("plan.md :: packages/tui/docs/plan.md")); + }); + + test("continues autocomplete inside quoted @ paths", async () => { + setupFolder(baseDir, { + files: { + "my folder/test.txt": "content", + "my folder/other.txt": "content", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = '@"my folder/"'; + const result = await getSuggestions(provider, [line], 0, line.length - 1); + + assert.notEqual(result, null, "Should return suggestions for quoted folder path"); + const values = result?.items.map((item) => item.value); + assert.ok(values?.includes('@"my folder/test.txt"')); + assert.ok(values?.includes('@"my folder/other.txt"')); + }); + + test("applies quoted @ completion without duplicating closing quote", async () => { + setupFolder(baseDir, { + files: { + "my folder/test.txt": "content", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = '@"my folder/te"'; + const cursorCol = line.length - 1; + const result = await getSuggestions(provider, [line], 0, cursorCol); + + assert.notEqual(result, null, "Should return suggestions for quoted @ path"); + const item = result?.items.find((entry) => entry.value === '@"my folder/test.txt"'); + assert.ok(item, "Should find test.txt suggestion"); + + const applied = provider.applyCompletion([line], 0, cursorCol, item!, result!.prefix); + assert.strictEqual(applied.lines[0], '@"my folder/test.txt" '); + }); + }); + + describe("dot-slash path completion", () => { + let baseDir = ""; + + beforeEach(() => { + baseDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-")); + }); + + afterEach(() => { + rmSync(baseDir, { recursive: true, force: true }); + }); + + test("preserves ./ prefix when completing paths", async () => { + setupFolder(baseDir, { + files: { + "update.sh": "#!/bin/bash", + "utils.ts": "export {};", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir); + const line = "./up"; + const result = await getSuggestions(provider, [line], 0, line.length, true); + + assert.notEqual(result, null, "Should return suggestions for ./ path"); + const values = result?.items.map((item) => item.value); + assert.ok(values?.includes("./update.sh"), `Expected ./update.sh in ${JSON.stringify(values)}`); + }); + + test("preserves ./ prefix for directory completions", async () => { + setupFolder(baseDir, { + dirs: ["src"], + files: { + "src/index.ts": "export {};", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir); + const line = "./sr"; + const result = await getSuggestions(provider, [line], 0, line.length, true); + + assert.notEqual(result, null, "Should return suggestions for ./ directory path"); + const values = result?.items.map((item) => item.value); + assert.ok(values?.includes("./src/"), `Expected ./src/ in ${JSON.stringify(values)}`); + }); + }); + + describe("quoted path completion", () => { + let baseDir = ""; + + beforeEach(() => { + baseDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-")); + }); + + afterEach(() => { + rmSync(baseDir, { recursive: true, force: true }); + }); + + test("quotes paths with spaces for direct completion", async () => { + setupFolder(baseDir, { + dirs: ["my folder"], + files: { + "my folder/test.txt": "content", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir); + const line = "my"; + const result = await getSuggestions(provider, [line], 0, line.length, true); + + assert.notEqual(result, null, "Should return suggestions for path completion"); + const values = result?.items.map((item) => item.value); + assert.ok(values?.includes('"my folder/"')); + }); + + test("continues completion inside quoted paths", async () => { + setupFolder(baseDir, { + files: { + "my folder/test.txt": "content", + "my folder/other.txt": "content", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir); + const line = '"my folder/"'; + const result = await getSuggestions(provider, [line], 0, line.length - 1, true); + + assert.notEqual(result, null, "Should return suggestions for quoted folder path"); + const values = result?.items.map((item) => item.value); + assert.ok(values?.includes('"my folder/test.txt"')); + assert.ok(values?.includes('"my folder/other.txt"')); + }); + + test("applies quoted completion without duplicating closing quote", async () => { + setupFolder(baseDir, { + files: { + "my folder/test.txt": "content", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir); + const line = '"my folder/te"'; + const cursorCol = line.length - 1; + const result = await getSuggestions(provider, [line], 0, cursorCol, true); + + assert.notEqual(result, null, "Should return suggestions for quoted path"); + const item = result?.items.find((entry) => entry.value === '"my folder/test.txt"'); + assert.ok(item, "Should find test.txt suggestion"); + + const applied = provider.applyCompletion([line], 0, cursorCol, item!, result!.prefix); + assert.strictEqual(applied.lines[0], '"my folder/test.txt"'); + }); + }); +}); diff --git a/packages/pi-tui/test/bug-regression-isimageline-startswith-bug.test.ts b/packages/pi-tui/test/bug-regression-isimageline-startswith-bug.test.ts new file mode 100644 index 000000000..b3d25e835 --- /dev/null +++ b/packages/pi-tui/test/bug-regression-isimageline-startswith-bug.test.ts @@ -0,0 +1,237 @@ +/** + * Bug regression test for isImageLine() crash scenario + * + * Bug: When isImageLine() used startsWith() and terminal doesn't support images, + * it would return false for lines containing image escape sequences, causing TUI to + * crash with "Rendered line exceeds terminal width" error. + * + * Fix: Changed to use includes() to detect escape sequences anywhere in the line. + * + * This test demonstrates: + * 1. The bug scenario with the old implementation + * 2. That the fix works correctly + */ + +import assert from "node:assert"; +import { describe, it } from "node:test"; + +describe("Bug regression: isImageLine() crash with image escape sequences", () => { + describe("Bug scenario: Terminal without image support", () => { + it("old implementation would return false, causing crash", () => { + /** + * OLD IMPLEMENTATION (buggy): + * ```typescript + * export function isImageLine(line: string): boolean { + * const prefix = getImageEscapePrefix(); + * return prefix !== null && line.startsWith(prefix); + * } + * ``` + * + * When terminal doesn't support images: + * - getImageEscapePrefix() returns null + * - isImageLine() returns false even for lines containing image sequences + * - TUI performs width check on line containing 300KB+ of base64 data + * - Crash: "Rendered line exceeds terminal width (304401 > 115)" + */ + + // Simulate old implementation behavior + const oldIsImageLine = (line: string, imageEscapePrefix: string | null): boolean => { + return imageEscapePrefix !== null && line.startsWith(imageEscapePrefix); + }; + + // When terminal doesn't support images, prefix is null + const terminalWithoutImageSupport = null; + + // Line containing image escape sequence with text before it (common bug scenario) + const lineWithImageSequence = + "Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64data...\x07"; + + // Old implementation would return false (BUG!) + const oldResult = oldIsImageLine(lineWithImageSequence, terminalWithoutImageSupport); + assert.strictEqual( + oldResult, + false, + "Bug: old implementation returns false for line containing image sequence when terminal has no image support", + ); + }); + + it("new implementation returns true correctly", async () => { + const { isImageLine } = await import("../src/terminal-image.ts"); + + // Line containing image escape sequence with text before it + const lineWithImageSequence = + "Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64data...\x07"; + + // New implementation should return true (FIX!) + const newResult = isImageLine(lineWithImageSequence); + assert.strictEqual(newResult, true, "Fix: new implementation returns true for line containing image sequence"); + }); + + it("new implementation detects Kitty sequences in any position", async () => { + const { isImageLine } = await import("../src/terminal-image.ts"); + + const scenarios = [ + "At start: \x1b_Ga=T,f=100,data...\x1b\\", + "Prefix \x1b_Ga=T,data...\x1b\\", + "Suffix text \x1b_Ga=T,data...\x1b\\ suffix", + "Middle \x1b_Ga=T,data...\x1b\\ more text", + // Very long line (simulating 300KB+ crash scenario) + `Text before \x1b_Ga=T,f=100${"A".repeat(300000)} text after`, + ]; + + for (const line of scenarios) { + assert.strictEqual(isImageLine(line), true, `Should detect Kitty sequence in: ${line.slice(0, 50)}...`); + } + }); + + it("new implementation detects iTerm2 sequences in any position", async () => { + const { isImageLine } = await import("../src/terminal-image.ts"); + + const scenarios = [ + "At start: \x1b]1337;File=size=100,100:base64...\x07", + "Prefix \x1b]1337;File=inline=1:data==\x07", + "Suffix text \x1b]1337;File=inline=1:data==\x07 suffix", + "Middle \x1b]1337;File=inline=1:data==\x07 more text", + // Very long line (simulating 304KB crash scenario) + `Text before \x1b]1337;File=size=800,600;inline=1:${"B".repeat(300000)} text after`, + ]; + + for (const line of scenarios) { + assert.strictEqual(isImageLine(line), true, `Should detect iTerm2 sequence in: ${line.slice(0, 50)}...`); + } + }); + }); + + describe("Integration: Tool execution scenario", () => { + /** + * This simulates what happens when the `read` tool reads an image file. + * The tool result contains both text and image content: + * + * ```typescript + * { + * content: [ + * { type: "text", text: "Read image file [image/jpeg]\n800x600" }, + * { type: "image", data: "base64...", mimeType: "image/jpeg" } + * ] + * } + * ``` + * + * When this is rendered, the image component creates escape sequences. + * If isImageLine() doesn't detect them, TUI crashes. + */ + + it("detects image sequences in read tool output", async () => { + const { isImageLine } = await import("../src/terminal-image.ts"); + + // Simulate output when read tool processes an image + // The line might have text from the read result plus the image escape sequence + const toolOutputLine = "Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64image...\x07"; + + assert.strictEqual(isImageLine(toolOutputLine), true, "Should detect image sequence in tool output line"); + }); + + it("detects Kitty sequences from Image component", async () => { + const { isImageLine } = await import("../src/terminal-image.ts"); + + // Kitty image component creates multi-line output with escape sequences + const kittyLine = "\x1b_Ga=T,f=100,t=f,d=base64data...\x1b\\\x1b_Gm=i=1;\x1b\\"; + + assert.strictEqual(isImageLine(kittyLine), true, "Should detect Kitty image component output"); + }); + + it("handles ANSI codes before image sequences", async () => { + const { isImageLine } = await import("../src/terminal-image.ts"); + + // Line might have styling (error, warning, etc.) before image data + const lines = [ + "\x1b[31mError\x1b[0m: \x1b]1337;File=inline=1:base64==\x07", + "\x1b[33mWarning\x1b[0m: \x1b_Ga=T,data...\x1b\\", + "\x1b[1mBold\x1b[0m \x1b]1337;File=:base64==\x07\x1b[0m", + ]; + + for (const line of lines) { + assert.strictEqual( + isImageLine(line), + true, + `Should detect image sequence after ANSI codes: ${line.slice(0, 30)}...`, + ); + } + }); + }); + + describe("Crash scenario simulation", () => { + it("does NOT crash on very long lines with image sequences", async () => { + const { isImageLine } = await import("../src/terminal-image.ts"); + + /** + * Simulate the exact crash scenario: + * - Line is 304,401 characters (the crash log showed 58649 > 115) + * - Contains image escape sequence somewhere in the middle + * - Old implementation would return false, causing TUI to do width check + * - New implementation returns true, skipping width check (preventing crash) + */ + + const base64Char = "A".repeat(100); + const iterm2Sequence = "\x1b]1337;File=size=800,600;inline=1:"; + + // Build a line that would cause the crash + const crashLine = + "Output: " + + iterm2Sequence + + base64Char.repeat(3040) + // ~304,000 chars + " end of output"; + + // Verify line is very long + assert(crashLine.length > 300000, "Test line should be > 300KB"); + + // New implementation should detect it (prevents crash) + const detected = isImageLine(crashLine); + assert.strictEqual(detected, true, "Should detect image sequence in very long line, preventing TUI crash"); + }); + + it("handles lines exactly matching crash log dimensions", async () => { + const { isImageLine } = await import("../src/terminal-image.ts"); + + /** + * Crash log showed: line 58649 chars wide, terminal width 115 + * Let's create a line with similar characteristics + */ + + const targetWidth = 58649; + const prefix = "Text"; + const sequence = "\x1b_Ga=T,f=100"; + const suffix = "End"; + const padding = "A".repeat(targetWidth - prefix.length - sequence.length - suffix.length); + const line = `${prefix}${sequence}${padding}${suffix}`; + + assert.strictEqual(line.length, 58649); + assert.strictEqual(isImageLine(line), true, "Should detect image sequence in 58649-char line"); + }); + }); + + describe("Negative cases: Don't false positive", () => { + it("does not detect images in regular long text", async () => { + const { isImageLine } = await import("../src/terminal-image.ts"); + + // Very long line WITHOUT image sequences + const longText = "A".repeat(100000); + + assert.strictEqual(isImageLine(longText), false, "Should not detect images in plain long text"); + }); + + it("does not detect images in lines with file paths", async () => { + const { isImageLine } = await import("../src/terminal-image.ts"); + + const filePaths = [ + "/path/to/1337/image.jpg", + "/usr/local/bin/File_converter", + "~/Documents/1337File_backup.png", + "./_G_test_file.txt", + ]; + + for (const path of filePaths) { + assert.strictEqual(isImageLine(path), false, `Should not falsely detect image sequence in path: ${path}`); + } + }); + }); +}); diff --git a/packages/pi-tui/test/chat-simple.ts b/packages/pi-tui/test/chat-simple.ts new file mode 100644 index 000000000..b6ccd1a85 --- /dev/null +++ b/packages/pi-tui/test/chat-simple.ts @@ -0,0 +1,129 @@ +/** + * Simple chat interface demo using tui.ts + */ + +import chalk from "chalk"; +import { CombinedAutocompleteProvider } from "../src/autocomplete.ts"; +import { Editor } from "../src/components/editor.ts"; +import { Loader } from "../src/components/loader.ts"; +import { Markdown } from "../src/components/markdown.ts"; +import { Text } from "../src/components/text.ts"; +import { ProcessTerminal } from "../src/terminal.ts"; +import { TUI } from "../src/tui.ts"; +import { defaultEditorTheme, defaultMarkdownTheme } from "./test-themes.ts"; + +// Create terminal +const terminal = new ProcessTerminal(); + +// Create TUI +const tui = new TUI(terminal); + +// Create chat container with some initial messages +tui.addChild( + new Text("Welcome to Simple Chat!\n\nType your messages below. Type '/' for commands. Press Ctrl+C to exit."), +); + +// Create editor with autocomplete +const editor = new Editor(tui, defaultEditorTheme); + +// Set up autocomplete provider with slash commands and file completion +const autocompleteProvider = new CombinedAutocompleteProvider( + [ + { name: "delete", description: "Delete the last message" }, + { name: "clear", description: "Clear all messages" }, + ], + process.cwd(), +); +editor.setAutocompleteProvider(autocompleteProvider); + +tui.addChild(editor); + +// Focus the editor +tui.setFocus(editor); + +// Track if we're waiting for bot response +let isResponding = false; + +// Handle message submission +editor.onSubmit = (value: string) => { + // Prevent submission if already responding + if (isResponding) { + return; + } + + const trimmed = value.trim(); + + // Handle slash commands + if (trimmed === "/delete") { + const children = tui.children; + // Remove component before editor (if there are any besides the initial text) + if (children.length > 3) { + // children[0] = "Welcome to Simple Chat!" + // children[1] = "Type your messages below..." + // children[2...n-1] = messages + // children[n] = editor + children.splice(children.length - 2, 1); + } + tui.requestRender(); + return; + } + + if (trimmed === "/clear") { + const children = tui.children; + // Remove all messages but keep the welcome text and editor + children.splice(2, children.length - 3); + tui.requestRender(); + return; + } + + if (trimmed) { + isResponding = true; + editor.disableSubmit = true; + + const userMessage = new Markdown(value, 1, 1, defaultMarkdownTheme); + + const children = tui.children; + children.splice(children.length - 1, 0, userMessage); + + const loader = new Loader( + tui, + (s) => chalk.cyan(s), + (s) => chalk.dim(s), + "Thinking...", + ); + children.splice(children.length - 1, 0, loader); + + tui.requestRender(); + + setTimeout(() => { + tui.removeChild(loader); + + // Simulate a response + const responses = [ + "That's interesting! Tell me more.", + "I see what you mean.", + "Fascinating perspective!", + "Could you elaborate on that?", + "That makes sense to me.", + "I hadn't thought of it that way.", + "Great point!", + "Thanks for sharing that.", + ]; + const randomResponse = responses[Math.floor(Math.random() * responses.length)]; + + // Add assistant message with no background (transparent) + const botMessage = new Markdown(randomResponse, 1, 1, defaultMarkdownTheme); + children.splice(children.length - 1, 0, botMessage); + + // Re-enable submit + isResponding = false; + editor.disableSubmit = false; + + // Request render + tui.requestRender(); + }, 1000); + } +}; + +// Start the TUI +tui.start(); diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts new file mode 100644 index 000000000..0f33370e1 --- /dev/null +++ b/packages/pi-tui/test/editor.test.ts @@ -0,0 +1,4051 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { stripVTControlCharacters } from "node:util"; +import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.ts"; +import { Editor, wordWrapLine } from "../src/components/editor.ts"; +import { TUI } from "../src/tui.ts"; +import { visibleWidth } from "../src/utils.ts"; +import { defaultEditorTheme } from "./test-themes.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +/** Create a TUI with a virtual terminal for testing */ +function createTestTUI(cols = 80, rows = 24): TUI { + return new TUI(new VirtualTerminal(cols, rows)); +} + +/** Standard applyCompletion that replaces prefix with item.value */ +function applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: { value: string }, + prefix: string, +): { lines: string[]; cursorLine: number; cursorCol: number } { + const line = lines[cursorLine] || ""; + const before = line.slice(0, cursorCol - prefix.length); + const after = line.slice(cursorCol); + const newLines = [...lines]; + newLines[cursorLine] = before + item.value + after; + return { + lines: newLines, + cursorLine, + cursorCol: cursorCol - prefix.length + item.value.length, + }; +} + +async function flushAutocomplete(): Promise { + await Promise.resolve(); + await new Promise((resolve) => setImmediate(resolve)); +} + +describe("Editor component", () => { + describe("Prompt history navigation", () => { + it("does nothing on Up arrow when history is empty", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("\x1b[A"); // Up arrow + + assert.strictEqual(editor.getText(), ""); + }); + + it("shows most recent history entry on Up arrow when editor is empty", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("first prompt"); + editor.addToHistory("second prompt"); + + editor.handleInput("\x1b[A"); // Up arrow + + assert.strictEqual(editor.getText(), "second prompt"); + }); + + it("cycles through history entries on repeated Up arrow", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("first"); + editor.addToHistory("second"); + editor.addToHistory("third"); + + editor.handleInput("\x1b[A"); // Up - shows "third" + assert.strictEqual(editor.getText(), "third"); + + editor.handleInput("\x1b[A"); // Up - shows "second" + assert.strictEqual(editor.getText(), "second"); + + editor.handleInput("\x1b[A"); // Up - shows "first" + assert.strictEqual(editor.getText(), "first"); + + editor.handleInput("\x1b[A"); // Up - stays at "first" (oldest) + assert.strictEqual(editor.getText(), "first"); + }); + + it("jumps to start before entering history from a non-empty draft", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("prompt"); + editor.setText("draft"); + editor.handleInput("\x1b[D"); + editor.handleInput("\x1b[D"); + + editor.handleInput("\x1b[A"); // Up - jumps to start before history browsing + assert.strictEqual(editor.getText(), "draft"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1b[A"); // Up at start - shows "prompt" + assert.strictEqual(editor.getText(), "prompt"); + + editor.handleInput("\x1b[B"); // Down - restores draft + assert.strictEqual(editor.getText(), "draft"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + }); + + it("navigates forward through history with Down arrow", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("first"); + editor.addToHistory("second"); + editor.addToHistory("third"); + editor.setText("draft"); + + // Go to oldest + editor.handleInput("\x1b[A"); // start of draft + editor.handleInput("\x1b[A"); // third + editor.handleInput("\x1b[A"); // second + editor.handleInput("\x1b[A"); // first + + // Navigate back + editor.handleInput("\x1b[B"); // second + assert.strictEqual(editor.getText(), "second"); + + editor.handleInput("\x1b[B"); // third + assert.strictEqual(editor.getText(), "third"); + + editor.handleInput("\x1b[B"); // draft + assert.strictEqual(editor.getText(), "draft"); + }); + + it("exits history mode when typing a character", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("old prompt"); + + editor.handleInput("\x1b[A"); // Up - shows "old prompt" + editor.handleInput("x"); // Type a character - exits history mode + + assert.strictEqual(editor.getText(), "xold prompt"); + }); + + it("exits history mode on setText", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("first"); + editor.addToHistory("second"); + + editor.handleInput("\x1b[A"); // Up - shows "second" + editor.setText(""); // External clear + + // Up should start fresh from most recent + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "second"); + }); + + it("does not add empty strings to history", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory(""); + editor.addToHistory(" "); + editor.addToHistory("valid"); + + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "valid"); + + // Should not have more entries + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "valid"); + }); + + it("does not add consecutive duplicates to history", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("same"); + editor.addToHistory("same"); + editor.addToHistory("same"); + + editor.handleInput("\x1b[A"); // "same" + assert.strictEqual(editor.getText(), "same"); + + editor.handleInput("\x1b[A"); // stays at "same" (only one entry) + assert.strictEqual(editor.getText(), "same"); + }); + + it("allows non-consecutive duplicates in history", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("first"); + editor.addToHistory("second"); + editor.addToHistory("first"); // Not consecutive, should be added + + editor.handleInput("\x1b[A"); // "first" + assert.strictEqual(editor.getText(), "first"); + + editor.handleInput("\x1b[A"); // "second" + assert.strictEqual(editor.getText(), "second"); + + editor.handleInput("\x1b[A"); // "first" (older one) + assert.strictEqual(editor.getText(), "first"); + }); + + it("uses cursor movement instead of history when editor has content", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("history item"); + editor.setText("line1\nline2"); + + // Cursor is at end of line2, Up should move to line1 + editor.handleInput("\x1b[A"); // Up - cursor movement + + // Insert character to verify cursor position + editor.handleInput("X"); + + // X should be inserted in line1, not replace with history + assert.strictEqual(editor.getText(), "line1X\nline2"); + }); + + it("limits history to 100 entries", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Add 105 entries + for (let i = 0; i < 105; i++) { + editor.addToHistory(`prompt ${i}`); + } + + // Navigate to oldest + for (let i = 0; i < 100; i++) { + editor.handleInput("\x1b[A"); + } + + // Should be at entry 5 (oldest kept), not entry 0 + assert.strictEqual(editor.getText(), "prompt 5"); + + // One more Up should not change anything + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getText(), "prompt 5"); + }); + + it("places cursor at start after browsing history upward", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("older entry"); + editor.addToHistory("line1\nline2\nline3"); + + editor.handleInput("\x1b[A"); // Up - shows multi-line entry at start + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1b[A"); // Up again - immediately navigates to older entry + assert.strictEqual(editor.getText(), "older entry"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + }); + + it("places cursor at end after browsing history downward", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("older entry"); + editor.addToHistory("line1\nline2\nline3"); + editor.addToHistory("newer entry"); + + editor.handleInput("\x1b[A"); // newer entry + editor.handleInput("\x1b[A"); // multi-line entry + editor.handleInput("\x1b[A"); // older entry + + editor.handleInput("\x1b[B"); // Down - shows multi-line entry at end + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 5 }); + + editor.handleInput("\x1b[B"); // Down again - immediately navigates to newer entry + assert.strictEqual(editor.getText(), "newer entry"); + }); + + it("allows opposite-direction cursor movement within multi-line history entry", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.addToHistory("line1\nline2\nline3"); + + editor.handleInput("\x1b[A"); // Up - shows entry at start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1b[B"); // Down - cursor moves to line2 + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 0 }); + + editor.handleInput("\x1b[A"); // Up - cursor moves back to line1 + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + }); + }); + + describe("public state accessors", () => { + it("returns cursor position", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("a"); + editor.handleInput("b"); + editor.handleInput("c"); + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + + editor.handleInput("\x1b[D"); // Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 }); + }); + + it("returns lines as a defensive copy", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.setText("a\nb"); + + const lines = editor.getLines(); + assert.deepStrictEqual(lines, ["a", "b"]); + + lines[0] = "mutated"; + assert.deepStrictEqual(editor.getLines(), ["a", "b"]); + }); + }); + + describe("Backslash+Enter newline workaround", () => { + it("inserts backslash immediately (no buffering)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("\\"); + + // Backslash should be visible immediately, not buffered + assert.strictEqual(editor.getText(), "\\"); + }); + + it("converts standalone backslash to newline on Enter", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("\\"); + editor.handleInput("\r"); + + assert.strictEqual(editor.getText(), "\n"); + }); + + it("inserts backslash normally when followed by other characters", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("\\"); + editor.handleInput("x"); + + assert.strictEqual(editor.getText(), "\\x"); + }); + + it("does not trigger newline when backslash is not immediately before cursor", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let submitted = false; + + editor.onSubmit = () => { + submitted = true; + }; + + editor.handleInput("\\"); + editor.handleInput("x"); + editor.handleInput("\r"); + + // Should submit, not insert newline (backslash not at cursor) + assert.strictEqual(submitted, true); + }); + + it("only removes one backslash when multiple are present", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("\\"); + editor.handleInput("\\"); + editor.handleInput("\\"); + assert.strictEqual(editor.getText(), "\\\\\\"); + + editor.handleInput("\r"); + // Only the last backslash is removed, newline inserted + assert.strictEqual(editor.getText(), "\\\\\n"); + }); + }); + + describe("Kitty CSI-u handling", () => { + it("ignores printable CSI-u sequences with unsupported modifiers", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("\x1b[99;9u"); + + assert.strictEqual(editor.getText(), ""); + }); + + it("inserts shifted CSI-u letters as text", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("\x1b[69;2u"); + + assert.strictEqual(editor.getText(), "E"); + }); + + it("inserts shifted xterm modifyOtherKeys letters as text", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("\x1b[27;2;69~"); + + assert.strictEqual(editor.getText(), "E"); + }); + }); + + describe("Unicode text editing behavior", () => { + it("inserts mixed ASCII, umlauts, and emojis as literal text", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("H"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput("l"); + editor.handleInput("o"); + editor.handleInput(" "); + editor.handleInput("ä"); + editor.handleInput("ö"); + editor.handleInput("ü"); + editor.handleInput(" "); + editor.handleInput("😀"); + + const text = editor.getText(); + assert.strictEqual(text, "Hello äöü 😀"); + }); + + it("deletes single-code-unit unicode characters (umlauts) with Backspace", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("ä"); + editor.handleInput("ö"); + editor.handleInput("ü"); + + // Delete the last character (ü) + editor.handleInput("\x7f"); // Backspace + + const text = editor.getText(); + assert.strictEqual(text, "äö"); + }); + + it("deletes multi-code-unit emojis with single Backspace", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("😀"); + editor.handleInput("👍"); + + // Delete the last emoji (👍) - single backspace deletes whole grapheme cluster + editor.handleInput("\x7f"); // Backspace + + const text = editor.getText(); + assert.strictEqual(text, "😀"); + }); + + it("inserts characters at the correct position after cursor movement over umlauts", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("ä"); + editor.handleInput("ö"); + editor.handleInput("ü"); + + // Move cursor left twice + editor.handleInput("\x1b[D"); // Left arrow + editor.handleInput("\x1b[D"); // Left arrow + + // Insert 'x' in the middle + editor.handleInput("x"); + + const text = editor.getText(); + assert.strictEqual(text, "äxöü"); + }); + + it("moves cursor across multi-code-unit emojis with single arrow key", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("😀"); + editor.handleInput("👍"); + editor.handleInput("🎉"); + + // Move cursor left over last emoji (🎉) - single arrow moves over whole grapheme + editor.handleInput("\x1b[D"); // Left arrow + + // Move cursor left over second emoji (👍) + editor.handleInput("\x1b[D"); + + // Insert 'x' between first and second emoji + editor.handleInput("x"); + + const text = editor.getText(); + assert.strictEqual(text, "😀x👍🎉"); + }); + + it("preserves umlauts across line breaks", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("ä"); + editor.handleInput("ö"); + editor.handleInput("ü"); + editor.handleInput("\n"); // new line + editor.handleInput("Ä"); + editor.handleInput("Ö"); + editor.handleInput("Ü"); + + const text = editor.getText(); + assert.strictEqual(text, "äöü\nÄÖÜ"); + }); + + it("replaces the entire document with unicode text via setText (paste simulation)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Simulate bracketed paste / programmatic replacement + editor.setText("Hällö Wörld! 😀 äöüÄÖÜß"); + + const text = editor.getText(); + assert.strictEqual(text, "Hällö Wörld! 😀 äöüÄÖÜß"); + }); + + it("moves cursor to document start on Ctrl+A and inserts at the beginning", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("a"); + editor.handleInput("b"); + editor.handleInput("\x01"); // Ctrl+A (move to start) + editor.handleInput("x"); // Insert at start + + const text = editor.getText(); + assert.strictEqual(text, "xab"); + }); + + it("deletes words correctly with Ctrl+W and Alt+Backspace", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Basic word deletion + editor.setText("foo bar baz"); + editor.handleInput("\x17"); // Ctrl+W + assert.strictEqual(editor.getText(), "foo bar "); + + // Trailing whitespace + editor.setText("foo bar "); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "foo "); + + // Punctuation run + editor.setText("foo bar..."); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "foo bar"); + + // ASCII punctuation inside Intl word-like segments preserves old boundaries + editor.setText("foo.bar"); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "foo."); + + editor.setText("foo:bar"); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "foo:"); + + // Delete across multiple lines + editor.setText("line one\nline two"); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "line one\nline "); + + // Delete empty line (merge) + editor.setText("line one\n"); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "line one"); + + // Grapheme safety (emoji as a word) + editor.setText("foo 😀😀 bar"); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "foo 😀😀 "); + editor.handleInput("\x17"); + assert.strictEqual(editor.getText(), "foo "); + + // Alt+Backspace + editor.setText("foo bar"); + editor.handleInput("\x1b\x7f"); // Alt+Backspace (legacy) + assert.strictEqual(editor.getText(), "foo "); + }); + + it("navigates words correctly with Ctrl+Left/Right", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("foo bar... baz"); + // Cursor at end + + // Move left over baz + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 11 }); // after '...' + + // Move left over punctuation + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); // after 'bar' + + // Move left over bar + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 4 }); // after 'foo ' + + // Move right over bar + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); // at end of 'bar' + + // Move right over punctuation run + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 10 }); // after '...' + + // Move right skips space and lands after baz + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 14 }); // end of line + + // Test forward from start with leading whitespace + editor.setText(" foo bar"); + editor.handleInput("\x01"); // Ctrl+A to go to start + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 }); // after 'foo' + + // ASCII punctuation inside Intl word-like segments preserves old boundaries + editor.setText("foo.bar baz"); + editor.handleInput("\x1b[1;5D"); // Ctrl+Left over baz + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); + editor.handleInput("\x1b[1;5D"); // Ctrl+Left over bar + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 4 }); + editor.handleInput("\x1b[1;5D"); // Ctrl+Left over . + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + + editor.handleInput("\x01"); // Ctrl+A + editor.handleInput("\x1b[1;5C"); // Ctrl+Right over foo + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + editor.handleInput("\x1b[1;5C"); // Ctrl+Right over . + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 4 }); + editor.handleInput("\x1b[1;5C"); // Ctrl+Right over bar + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); + }); + + it("stops at fullwidth Chinese punctuation (issue #4972)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // 你好,世界 = 你好(0-2) ,(2-3) 世界(3-5) + editor.setText("你好,世界"); + // Cursor at end (col 5) + + // Move left over 世界 + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); // after , + + // Move left over , + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 }); // after 你好 + + // Move left over 你好 + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); // start + + // Move right over 你好 + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 }); // after 你好 + + // Move right over , + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); // after , + + // Move right over 世界 + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); // end + }); + + it("handles mixed CJK and ASCII word movement", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // "hello你好,world世界" = hello(0-5) 你好(5-7) ,(7-8) world(8-13) 世界(13-15) + editor.setText("hello你好,world世界"); + // Cursor at end (col 15) + + // Move left over 世界 + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 13 }); // after 'world' + + // Move left over world + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); // after , + + // Move left over , + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); // after 你好 + + // Move left over 你好 + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); // after 'hello' + + // Move left over hello + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); // start + + // Forward from start + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); // after 'hello' + + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); // after 你好 + + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); // after , + + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 13 }); // after 'world' + + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 15 }); // end + }); + }); + + describe("Grapheme-aware text wrapping", () => { + it("wraps lines correctly when text contains wide emojis", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const width = 20; + + // ✅ is 2 columns wide, so "Hello ✅ World" is 14 columns + editor.setText("Hello ✅ World"); + const lines = editor.render(width); + + // All content lines (between borders) should fit within width + for (let i = 1; i < lines.length - 1; i++) { + const lineWidth = visibleWidth(lines[i]!); + assert.strictEqual(lineWidth, width, `Line ${i} has width ${lineWidth}, expected ${width}`); + } + }); + + it("wraps long text with emojis at correct positions", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const width = 10; + + // Each ✅ is 2 columns. "✅✅✅✅✅" = 10 columns, fits exactly + // "✅✅✅✅✅✅" = 12 columns, needs wrap + editor.setText("✅✅✅✅✅✅"); + const lines = editor.render(width); + + // Should have 2 content lines (plus 2 border lines) + // First line: 5 emojis (10 cols), second line: 1 emoji (2 cols) + padding + for (let i = 1; i < lines.length - 1; i++) { + const lineWidth = visibleWidth(lines[i]!); + assert.strictEqual(lineWidth, width, `Line ${i} has width ${lineWidth}, expected ${width}`); + } + }); + + it("renders isolated Thai and Lao AM clusters without width drift", () => { + for (const text of ["ำabc", "ຳabc"]) { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const width = 8; + editor.setText(text); + + for (const line of editor.render(width)) { + assert.strictEqual(visibleWidth(line), width, `line width drift for ${JSON.stringify(text)}: ${line}`); + } + } + }); + + it("wraps CJK characters correctly (each is 2 columns wide)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const width = 10 + 1; // +1 col reserved for cursor + + // Each CJK char is 2 columns. "日本語テスト" = 6 chars = 12 columns + editor.setText("日本語テスト"); + const lines = editor.render(width); + + for (let i = 1; i < lines.length - 1; i++) { + const lineWidth = visibleWidth(lines[i]!); + assert.strictEqual(lineWidth, width, `Line ${i} has width ${lineWidth}, expected ${width}`); + } + + // Verify content split correctly + const contentLines = lines.slice(1, -1).map((l) => stripVTControlCharacters(l).trim()); + assert.strictEqual(contentLines.length, 2); + assert.strictEqual(contentLines[0], "日本語テス"); // 5 chars = 10 columns + assert.strictEqual(contentLines[1], "ト"); // 1 char = 2 columns (+ padding) + }); + + it("handles mixed ASCII and wide characters in wrapping", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const width = 15 + 1; // +1 col reserved for cursor + + // "Test ✅ OK 日本" = 4 + 1 + 2 + 1 + 2 + 1 + 4 = 15 columns (fits in width-1=15) + editor.setText("Test ✅ OK 日本"); + const lines = editor.render(width); + + // Should fit in one content line + const contentLines = lines.slice(1, -1); + assert.strictEqual(contentLines.length, 1); + + const lineWidth = visibleWidth(contentLines[0]!); + assert.strictEqual(lineWidth, width); + }); + + it("renders cursor correctly on wide characters", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const width = 20; + + editor.setText("A✅B"); + // Cursor should be at end (after B) + const lines = editor.render(width); + + // The cursor (reverse video space) should be visible + const contentLine = lines[1]!; + assert.ok(contentLine.includes("\x1b[7m"), "Should have reverse video cursor"); + + // Line should still be correct width + assert.strictEqual(visibleWidth(contentLine), width); + }); + + it("does not exceed terminal width with emoji at wrap boundary", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const width = 11; + + // "0123456789✅" = 10 ASCII + 2-wide emoji = 12 columns + // Should wrap before the emoji since it would exceed width + editor.setText("0123456789✅"); + const lines = editor.render(width); + + for (let i = 1; i < lines.length - 1; i++) { + const lineWidth = visibleWidth(lines[i]!); + assert.ok(lineWidth <= width, `Line ${i} has width ${lineWidth}, exceeds max ${width}`); + } + }); + + it("shows cursor at end of line before wrap, wraps on next char", () => { + const width = 10; + for (const paddingX of [0, 1]) { + const editor = new Editor(createTestTUI(width + paddingX), defaultEditorTheme, { paddingX }); + + // Type 9 chars → fills layoutWidth exactly, cursor at end on same line + for (const ch of "aaaaaaaaa") editor.handleInput(ch); + let lines = editor.render(width + paddingX); + let contentLines = lines.slice(1, -1); + assert.strictEqual(contentLines.length, 1, "Should be 1 content line before wrap"); + assert.ok(contentLines[0]!.endsWith("\x1b[7m \x1b[0m"), "Cursor should be at end of line"); + + // Type 1 more → text wraps to second line + editor.handleInput("a"); + lines = editor.render(width + paddingX); + contentLines = lines.slice(1, -1); + assert.strictEqual(contentLines.length, 2, "Should wrap to 2 content lines"); + } + }); + }); + + describe("Word wrapping", () => { + it("wraps at word boundaries instead of mid-word", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const width = 40; + + editor.setText("Hello world this is a test of word wrapping functionality"); + const lines = editor.render(width); + + // Get content lines (between borders) + const contentLines = lines.slice(1, -1).map((l) => stripVTControlCharacters(l).trim()); + + // Should NOT break mid-word + // Line 1 should end with a complete word + assert.ok(!contentLines[0]!.endsWith("-"), "Line should not end with hyphen (mid-word break)"); + + // Each content line should be complete words + for (const line of contentLines) { + // Words at end of line should be complete (no partial words) + const lastChar = line.trimEnd().slice(-1); + assert.ok(lastChar === "" || /[\w.,!?;:]/.test(lastChar), `Line ends unexpectedly with: "${lastChar}"`); + } + }); + + it("does not start lines with leading whitespace after word wrap", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const width = 20; + + editor.setText("Word1 Word2 Word3 Word4 Word5 Word6"); + const lines = editor.render(width); + + // Get content lines (between borders) + const contentLines = lines.slice(1, -1); + + // No line should start with whitespace (except for padding at the end) + for (let i = 0; i < contentLines.length; i++) { + const line = stripVTControlCharacters(contentLines[i]!); + const trimmedStart = line.trimStart(); + // The line should either be all padding or start with a word character + if (trimmedStart.length > 0) { + assert.ok(!/^\s+\S/.test(line.trimEnd()), `Line ${i} starts with unexpected whitespace before content`); + } + } + }); + + it("breaks long words (URLs) at character level", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const width = 30; + + editor.setText("Check https://example.com/very/long/path/that/exceeds/width here"); + const lines = editor.render(width); + + // All lines should fit within width + for (let i = 1; i < lines.length - 1; i++) { + const lineWidth = visibleWidth(lines[i]!); + assert.strictEqual(lineWidth, width, `Line ${i} has width ${lineWidth}, expected ${width}`); + } + }); + + it("preserves multiple spaces within words on same line", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const width = 50; + + editor.setText("Word1 Word2 Word3"); + const lines = editor.render(width); + + const contentLine = stripVTControlCharacters(lines[1]!).trim(); + // Multiple spaces should be preserved + assert.ok(contentLine.includes("Word1 Word2"), "Multiple spaces should be preserved"); + }); + + it("handles empty string", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const width = 40; + + editor.setText(""); + const lines = editor.render(width); + + // Should have border + empty content + border + assert.strictEqual(lines.length, 3); + }); + + it("handles single word that fits exactly", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const width = 10 + 1; // +1 col reserved for cursor + + editor.setText("1234567890"); + const lines = editor.render(width); + + // Should have exactly 3 lines (top border, content, bottom border) + assert.strictEqual(lines.length, 3); + const contentLine = stripVTControlCharacters(lines[1]!); + assert.ok(contentLine.includes("1234567890"), "Content should contain the word"); + }); + + it("wraps word to next line when it ends exactly at terminal width", () => { + // "hello " (6) + "world" (5) = 11, but "world" is non-whitespace ending at width. + // Thus, wrap it to next line. The trailing space stays with "hello" on line 1 + const chunks = wordWrapLine("hello world test", 11); + + assert.strictEqual(chunks.length, 2); + assert.strictEqual(chunks[0]!.text, "hello "); + assert.strictEqual(chunks[1]!.text, "world test"); + }); + + it("keeps whitespace at terminal width boundary on same line", () => { + // "hello world " is exactly 12 chars (including trailing space) + // The space at position 12 should stay on the first line + const chunks = wordWrapLine("hello world test", 12); + + assert.strictEqual(chunks.length, 2); + assert.strictEqual(chunks[0]!.text, "hello world "); + assert.strictEqual(chunks[1]!.text, "test"); + }); + + it("handles unbreakable word filling width exactly followed by space", () => { + const chunks = wordWrapLine("aaaaaaaaaaaa aaaa", 12); + + assert.strictEqual(chunks.length, 2); + assert.strictEqual(chunks[0]!.text, "aaaaaaaaaaaa"); + assert.strictEqual(chunks[1]!.text, " aaaa"); + }); + + it("wraps word to next line when it fits width but not remaining space", () => { + const chunks = wordWrapLine(" aaaaaaaaaaaa", 12); + + assert.strictEqual(chunks.length, 2); + assert.strictEqual(chunks[0]!.text, " "); + assert.strictEqual(chunks[1]!.text, "aaaaaaaaaaaa"); + }); + + it("keeps word with multi-space and following word together when they fit", () => { + const chunks = wordWrapLine("Lorem ipsum dolor sit amet, consectetur", 30); + + assert.strictEqual(chunks.length, 2); + assert.strictEqual(chunks[0]!.text, "Lorem ipsum dolor sit "); + assert.strictEqual(chunks[1]!.text, "amet, consectetur"); + }); + + it("keeps word with multi-space and following word when they fill width exactly", () => { + const chunks = wordWrapLine("Lorem ipsum dolor sit amet, consectetur", 30); + + assert.strictEqual(chunks.length, 2); + assert.strictEqual(chunks[0]!.text, "Lorem ipsum dolor sit "); + assert.strictEqual(chunks[1]!.text, "amet, consectetur"); + }); + + it("splits when word plus multi-space plus word exceeds width", () => { + const chunks = wordWrapLine("Lorem ipsum dolor sit amet, consectetur", 30); + + assert.strictEqual(chunks.length, 3); + assert.strictEqual(chunks[0]!.text, "Lorem ipsum dolor sit "); + assert.strictEqual(chunks[1]!.text, "amet, "); + assert.strictEqual(chunks[2]!.text, "consectetur"); + }); + + it("breaks long whitespace at line boundary", () => { + const chunks = wordWrapLine("Lorem ipsum dolor sit amet, consectetur", 30); + + assert.strictEqual(chunks.length, 3); + assert.strictEqual(chunks[0]!.text, "Lorem ipsum dolor sit "); + assert.strictEqual(chunks[1]!.text, "amet, "); + assert.strictEqual(chunks[2]!.text, "consectetur"); + }); + + it("breaks long whitespace at line boundary 2", () => { + const chunks = wordWrapLine("Lorem ipsum dolor sit amet, consectetur", 30); + + assert.strictEqual(chunks.length, 3); + assert.strictEqual(chunks[0]!.text, "Lorem ipsum dolor sit "); + assert.strictEqual(chunks[1]!.text, "amet, "); + assert.strictEqual(chunks[2]!.text, " consectetur"); + }); + + it("breaks whitespace spanning full lines", () => { + const chunks = wordWrapLine("Lorem ipsum dolor sit amet, consectetur", 30); + + assert.strictEqual(chunks.length, 3); + assert.strictEqual(chunks[0]!.text, "Lorem ipsum dolor sit "); + assert.strictEqual(chunks[1]!.text, "amet, "); + assert.strictEqual(chunks[2]!.text, " consectetur"); + }); + + it("force-breaks when wide char after word boundary wrap still overflows", () => { + // " " (1) + "a"*186 (186) + "你" (2) = 189 visible width + // maxWidth = 187: backtracking to the space would leave 186 + 2 = 188 > 187, + // so the algorithm must force-break before the wide char instead. + const line = ` ${"a".repeat(186)}你`; + const chunks = wordWrapLine(line, 187); + + for (const chunk of chunks) { + assert.ok( + visibleWidth(chunk.text) <= 187, + `chunk "${chunk.text.slice(0, 20)}..." has visible width ${visibleWidth(chunk.text)}, expected <= 187`, + ); + } + // Verify no content is lost + const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join(""); + assert.strictEqual(reconstructed, line); + }); + + it("splits oversized atomic segment across multiple chunks", () => { + // Simulate a paste marker wider than maxWidth by passing pre-segmented data + const marker = "[paste #1 +20 lines]"; // 21 chars + const line = `A${marker}B`; + const segments: Intl.SegmentData[] = [ + { segment: "A", index: 0, input: line }, + { segment: marker, index: 1, input: line }, + { segment: "B", index: 1 + marker.length, input: line }, + ]; + + const chunks = wordWrapLine(line, 10, segments); + + // Every chunk must fit within maxWidth + for (const chunk of chunks) { + assert.ok( + visibleWidth(chunk.text) <= 10, + `chunk "${chunk.text}" has visible width ${visibleWidth(chunk.text)}, expected <= 10`, + ); + } + + // Verify no content is lost + const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join(""); + assert.strictEqual(reconstructed, line); + }); + + it("splits oversized atomic segment at start of line", () => { + const marker = "[paste #1 +20 lines]"; // 21 chars + const line = `${marker}B`; + const segments: Intl.SegmentData[] = [ + { segment: marker, index: 0, input: line }, + { segment: "B", index: marker.length, input: line }, + ]; + + const chunks = wordWrapLine(line, 10, segments); + + for (const chunk of chunks) { + assert.ok(visibleWidth(chunk.text) <= 10); + } + // "B" ends up on the last line (either alone or with the marker tail) + assert.strictEqual(chunks[chunks.length - 1]!.text.includes("B"), true); + + const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join(""); + assert.strictEqual(reconstructed, line); + }); + + it("splits oversized atomic segment at end of line", () => { + const marker = "[paste #1 +20 lines]"; // 21 chars + const line = `A${marker}`; + const segments: Intl.SegmentData[] = [ + { segment: "A", index: 0, input: line }, + { segment: marker, index: 1, input: line }, + ]; + + const chunks = wordWrapLine(line, 10, segments); + + for (const chunk of chunks) { + assert.ok(visibleWidth(chunk.text) <= 10); + } + assert.strictEqual(chunks[0]!.text, "A"); + + const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join(""); + assert.strictEqual(reconstructed, line); + }); + + it("splits consecutive oversized atomic segments", () => { + const m1 = "[paste #1 +20 lines]"; // 21 chars + const m2 = "[paste #2 +30 lines]"; // 21 chars + const line = `${m1}${m2}`; + const segments: Intl.SegmentData[] = [ + { segment: m1, index: 0, input: line }, + { segment: m2, index: m1.length, input: line }, + ]; + + const chunks = wordWrapLine(line, 10, segments); + + for (const chunk of chunks) { + assert.ok( + visibleWidth(chunk.text) <= 10, + `chunk "${chunk.text}" has visible width ${visibleWidth(chunk.text)}, expected <= 10`, + ); + } + + const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join(""); + assert.strictEqual(reconstructed, line); + }); + + it("wraps normally after oversized atomic segment", () => { + const marker = "[paste #1 +20 lines]"; // 21 chars + const line = `${marker} hello world`; + const segments: Intl.SegmentData[] = [ + { segment: marker, index: 0, input: line }, + { segment: " ", index: marker.length, input: line }, + { segment: "h", index: marker.length + 1, input: line }, + { segment: "e", index: marker.length + 2, input: line }, + { segment: "l", index: marker.length + 3, input: line }, + { segment: "l", index: marker.length + 4, input: line }, + { segment: "o", index: marker.length + 5, input: line }, + { segment: " ", index: marker.length + 6, input: line }, + { segment: "w", index: marker.length + 7, input: line }, + { segment: "o", index: marker.length + 8, input: line }, + { segment: "r", index: marker.length + 9, input: line }, + { segment: "l", index: marker.length + 10, input: line }, + { segment: "d", index: marker.length + 11, input: line }, + ]; + + const chunks = wordWrapLine(line, 10, segments); + + // All chunks must fit + for (const chunk of chunks) { + assert.ok( + visibleWidth(chunk.text) <= 10, + `chunk "${chunk.text}" has visible width ${visibleWidth(chunk.text)}, expected <= 10`, + ); + } + + // Last chunk should contain "world" (normal wrapping resumes) + assert.strictEqual(chunks[chunks.length - 1]!.text, "world"); + + const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join(""); + assert.strictEqual(reconstructed, line); + }); + }); + + describe("Kill ring", () => { + it("Ctrl+W saves deleted text to kill ring and Ctrl+Y yanks it", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("foo bar baz"); + editor.handleInput("\x17"); // Ctrl+W - deletes "baz" + assert.strictEqual(editor.getText(), "foo bar "); + + // Move to beginning and yank + editor.handleInput("\x01"); // Ctrl+A + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "bazfoo bar "); + }); + + it("Ctrl+U saves deleted text to kill ring", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + // Move cursor to middle + editor.handleInput("\x01"); // Ctrl+A (start) + editor.handleInput("\x1b[C"); // Right 5 times + editor.handleInput("\x1b[C"); + editor.handleInput("\x1b[C"); + editor.handleInput("\x1b[C"); + editor.handleInput("\x1b[C"); + editor.handleInput("\x1b[C"); // After "hello " + + editor.handleInput("\x15"); // Ctrl+U - deletes "hello " + assert.strictEqual(editor.getText(), "world"); + + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "hello world"); + }); + + it("Ctrl+K saves deleted text to kill ring", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A (start) + editor.handleInput("\x0b"); // Ctrl+K - deletes "hello world" + + assert.strictEqual(editor.getText(), ""); + + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "hello world"); + }); + + it("Ctrl+Y does nothing when kill ring is empty", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("test"); + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "test"); + }); + + it("Alt+Y cycles through kill ring after Ctrl+Y", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Create kill ring with multiple entries + editor.setText("first"); + editor.handleInput("\x17"); // Ctrl+W - deletes "first" + editor.setText("second"); + editor.handleInput("\x17"); // Ctrl+W - deletes "second" + editor.setText("third"); + editor.handleInput("\x17"); // Ctrl+W - deletes "third" + + // Kill ring now has: [first, second, third] + assert.strictEqual(editor.getText(), ""); + + editor.handleInput("\x19"); // Ctrl+Y - yanks "third" (most recent) + assert.strictEqual(editor.getText(), "third"); + + editor.handleInput("\x1by"); // Alt+Y - cycles to "second" + assert.strictEqual(editor.getText(), "second"); + + editor.handleInput("\x1by"); // Alt+Y - cycles to "first" + assert.strictEqual(editor.getText(), "first"); + + editor.handleInput("\x1by"); // Alt+Y - cycles back to "third" + assert.strictEqual(editor.getText(), "third"); + }); + + it("Alt+Y does nothing if not preceded by yank", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("test"); + editor.handleInput("\x17"); // Ctrl+W - deletes "test" + editor.setText("other"); + + // Type something to break the yank chain + editor.handleInput("x"); + assert.strictEqual(editor.getText(), "otherx"); + + // Alt+Y should do nothing + editor.handleInput("\x1by"); // Alt+Y + assert.strictEqual(editor.getText(), "otherx"); + }); + + it("Alt+Y does nothing if kill ring has ≤1 entry", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("only"); + editor.handleInput("\x17"); // Ctrl+W - deletes "only" + + editor.handleInput("\x19"); // Ctrl+Y - yanks "only" + assert.strictEqual(editor.getText(), "only"); + + editor.handleInput("\x1by"); // Alt+Y - should do nothing (only 1 entry) + assert.strictEqual(editor.getText(), "only"); + }); + + it("consecutive Ctrl+W accumulates into one kill ring entry", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("one two three"); + editor.handleInput("\x17"); // Ctrl+W - deletes "three" + editor.handleInput("\x17"); // Ctrl+W - deletes "two " (prepended) + editor.handleInput("\x17"); // Ctrl+W - deletes "one " (prepended) + + assert.strictEqual(editor.getText(), ""); + + // Should be one combined entry + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "one two three"); + }); + + it("Ctrl+U accumulates multiline deletes including newlines", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Start with multiline text, cursor at end + editor.setText("line1\nline2\nline3"); + // Cursor is at end of line3 (line 2, col 5) + + // Delete "line3" + editor.handleInput("\x15"); // Ctrl+U + assert.strictEqual(editor.getText(), "line1\nline2\n"); + + // Delete newline (at start of empty line 2, merges with line1) + editor.handleInput("\x15"); // Ctrl+U + assert.strictEqual(editor.getText(), "line1\nline2"); + + // Delete "line2" + editor.handleInput("\x15"); // Ctrl+U + assert.strictEqual(editor.getText(), "line1\n"); + + // Delete newline + editor.handleInput("\x15"); // Ctrl+U + assert.strictEqual(editor.getText(), "line1"); + + // Delete "line1" + editor.handleInput("\x15"); // Ctrl+U + assert.strictEqual(editor.getText(), ""); + + // All deletions accumulated into one entry: "line1\nline2\nline3" + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + }); + + it("backward deletions prepend, forward deletions append during accumulation", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("prefix|suffix"); + // Position cursor at | + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C"); // Move right 6 times + + editor.handleInput("\x0b"); // Ctrl+K - deletes "suffix" (forward) + editor.handleInput("\x0b"); // Ctrl+K - deletes "|" (forward, appended) + assert.strictEqual(editor.getText(), "prefix"); + + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "prefix|suffix"); + }); + + it("non-delete actions break kill accumulation", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Delete "baz", then type "x" to break accumulation, then delete "x" + editor.setText("foo bar baz"); + editor.handleInput("\x17"); // Ctrl+W - deletes "baz" + assert.strictEqual(editor.getText(), "foo bar "); + + editor.handleInput("x"); // Typing breaks accumulation + assert.strictEqual(editor.getText(), "foo bar x"); + + editor.handleInput("\x17"); // Ctrl+W - deletes "x" (separate entry, not accumulated) + assert.strictEqual(editor.getText(), "foo bar "); + + // Yank most recent - should be "x", not "xbaz" + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "foo bar x"); + + // Cycle to previous - should be "baz" (separate entry) + editor.handleInput("\x1by"); // Alt+Y + assert.strictEqual(editor.getText(), "foo bar baz"); + }); + + it("non-yank actions break Alt+Y chain", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("first"); + editor.handleInput("\x17"); // Ctrl+W + editor.setText("second"); + editor.handleInput("\x17"); // Ctrl+W + editor.setText(""); + + editor.handleInput("\x19"); // Ctrl+Y - yanks "second" + assert.strictEqual(editor.getText(), "second"); + + editor.handleInput("x"); // Type breaks yank chain + assert.strictEqual(editor.getText(), "secondx"); + + editor.handleInput("\x1by"); // Alt+Y - should do nothing + assert.strictEqual(editor.getText(), "secondx"); + }); + + it("kill ring rotation persists after cycling", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("first"); + editor.handleInput("\x17"); // deletes "first" + editor.setText("second"); + editor.handleInput("\x17"); // deletes "second" + editor.setText("third"); + editor.handleInput("\x17"); // deletes "third" + editor.setText(""); + + // Ring: [first, second, third] + + editor.handleInput("\x19"); // Ctrl+Y - yanks "third" + editor.handleInput("\x1by"); // Alt+Y - cycles to "second", ring rotates + + // Now ring is: [third, first, second] + assert.strictEqual(editor.getText(), "second"); + + // Do something else + editor.handleInput("x"); + editor.setText(""); + + // New yank should get "second" (now at end after rotation) + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "second"); + }); + + it("consecutive deletions across lines coalesce into one entry", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // "1\n2\n3" with cursor at end, delete everything with Ctrl+W + editor.setText("1\n2\n3"); + editor.handleInput("\x17"); // Ctrl+W - deletes "3" + assert.strictEqual(editor.getText(), "1\n2\n"); + + editor.handleInput("\x17"); // Ctrl+W - deletes newline (merge with prev line) + assert.strictEqual(editor.getText(), "1\n2"); + + editor.handleInput("\x17"); // Ctrl+W - deletes "2" + assert.strictEqual(editor.getText(), "1\n"); + + editor.handleInput("\x17"); // Ctrl+W - deletes newline + assert.strictEqual(editor.getText(), "1"); + + editor.handleInput("\x17"); // Ctrl+W - deletes "1" + assert.strictEqual(editor.getText(), ""); + + // All deletions should have accumulated into one entry + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "1\n2\n3"); + }); + + it("Ctrl+K at line end deletes newline and coalesces", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // "ab" on line 1, "cd" on line 2, cursor at end of line 1 + editor.setText(""); + editor.handleInput("a"); + editor.handleInput("b"); + editor.handleInput("\n"); + editor.handleInput("c"); + editor.handleInput("d"); + // Move to end of first line + editor.handleInput("\x1b[A"); // Up arrow + editor.handleInput("\x05"); // Ctrl+E - end of line + + // Now at end of "ab", Ctrl+K should delete newline (merge with "cd") + editor.handleInput("\x0b"); // Ctrl+K - deletes newline + assert.strictEqual(editor.getText(), "abcd"); + + // Continue deleting + editor.handleInput("\x0b"); // Ctrl+K - deletes "cd" + assert.strictEqual(editor.getText(), "ab"); + + // Both deletions should accumulate + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "ab\ncd"); + }); + + it("handles yank in middle of text", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("word"); + editor.handleInput("\x17"); // Ctrl+W - deletes "word" + editor.setText("hello world"); + + // Move to middle (after "hello ") + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C"); + + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "hello wordworld"); + }); + + it("handles yank-pop in middle of text", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Create two kill ring entries + editor.setText("FIRST"); + editor.handleInput("\x17"); // Ctrl+W - deletes "FIRST" + editor.setText("SECOND"); + editor.handleInput("\x17"); // Ctrl+W - deletes "SECOND" + + // Ring: ["FIRST", "SECOND"] + + // Set up "hello world" and position cursor after "hello " + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start of line + for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C"); // Move right 6 + + // Yank "SECOND" in the middle + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "hello SECONDworld"); + + // Yank-pop replaces "SECOND" with "FIRST" + editor.handleInput("\x1by"); // Alt+Y + assert.strictEqual(editor.getText(), "hello FIRSTworld"); + }); + + it("multiline yank and yank-pop in middle of text", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Create single-line entry + editor.setText("SINGLE"); + editor.handleInput("\x17"); // Ctrl+W - deletes "SINGLE" + + // Create multiline entry via consecutive Ctrl+U + editor.setText("A\nB"); + editor.handleInput("\x15"); // Ctrl+U - deletes "B" + editor.handleInput("\x15"); // Ctrl+U - deletes newline + editor.handleInput("\x15"); // Ctrl+U - deletes "A" + // Ring: ["SINGLE", "A\nB"] + + // Insert in middle of "hello world" + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C"); + + // Yank multiline "A\nB" + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "hello A\nBworld"); + + // Yank-pop replaces with "SINGLE" + editor.handleInput("\x1by"); // Alt+Y + assert.strictEqual(editor.getText(), "hello SINGLEworld"); + }); + + it("Alt+D deletes word forward and saves to kill ring", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world test"); + editor.handleInput("\x01"); // Ctrl+A - go to start + + editor.handleInput("\x1bd"); // Alt+D - deletes "hello" + assert.strictEqual(editor.getText(), " world test"); + + editor.handleInput("\x1bd"); // Alt+D - deletes " world" (skips whitespace, then word) + assert.strictEqual(editor.getText(), " test"); + + // Yank should get accumulated text + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "hello world test"); + }); + + it("Alt+D at end of line deletes newline", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("line1\nline2"); + // Move to start of document, then to end of first line + editor.handleInput("\x1b[A"); // Up arrow - go to first line + editor.handleInput("\x05"); // Ctrl+E - end of line + + editor.handleInput("\x1bd"); // Alt+D - deletes newline (merges lines) + assert.strictEqual(editor.getText(), "line1line2"); + + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "line1\nline2"); + }); + }); + + describe("Undo", () => { + it("does nothing when undo stack is empty", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), ""); + }); + + it("coalesces consecutive word characters into one undo unit", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("h"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput("l"); + editor.handleInput("o"); + editor.handleInput(" "); + editor.handleInput("w"); + editor.handleInput("o"); + editor.handleInput("r"); + editor.handleInput("l"); + editor.handleInput("d"); + assert.strictEqual(editor.getText(), "hello world"); + + // Undo removes " world" (space captured state before it, so we restore to "hello") + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "hello"); + + // Undo removes "hello" + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), ""); + }); + + it("undoes spaces one at a time", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("h"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput("l"); + editor.handleInput("o"); + editor.handleInput(" "); + editor.handleInput(" "); + assert.strictEqual(editor.getText(), "hello "); + + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes second " " + assert.strictEqual(editor.getText(), "hello "); + + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes first " " + assert.strictEqual(editor.getText(), "hello"); + + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes "hello" + assert.strictEqual(editor.getText(), ""); + }); + + it("undoes newlines and signals next word to capture state", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("h"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput("l"); + editor.handleInput("o"); + editor.handleInput("\n"); + editor.handleInput("w"); + editor.handleInput("o"); + editor.handleInput("r"); + editor.handleInput("l"); + editor.handleInput("d"); + assert.strictEqual(editor.getText(), "hello\nworld"); + + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "hello\n"); + + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "hello"); + + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), ""); + }); + + it("undoes backspace", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("h"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput("l"); + editor.handleInput("o"); + editor.handleInput("\x7f"); // Backspace + assert.strictEqual(editor.getText(), "hell"); + + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "hello"); + }); + + it("undoes forward delete", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("h"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput("l"); + editor.handleInput("o"); + editor.handleInput("\x01"); // Ctrl+A - go to start + editor.handleInput("\x1b[C"); // Right arrow + editor.handleInput("\x1b[3~"); // Delete key + assert.strictEqual(editor.getText(), "hllo"); + + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "hello"); + }); + + it("undoes Ctrl+W (delete word backward)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("h"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput("l"); + editor.handleInput("o"); + editor.handleInput(" "); + editor.handleInput("w"); + editor.handleInput("o"); + editor.handleInput("r"); + editor.handleInput("l"); + editor.handleInput("d"); + assert.strictEqual(editor.getText(), "hello world"); + + editor.handleInput("\x17"); // Ctrl+W + assert.strictEqual(editor.getText(), "hello "); + + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "hello world"); + }); + + it("undoes Ctrl+K (delete to line end)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("h"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput("l"); + editor.handleInput("o"); + editor.handleInput(" "); + editor.handleInput("w"); + editor.handleInput("o"); + editor.handleInput("r"); + editor.handleInput("l"); + editor.handleInput("d"); + editor.handleInput("\x01"); // Ctrl+A - go to start + for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C"); // Move right 6 times + + editor.handleInput("\x0b"); // Ctrl+K + assert.strictEqual(editor.getText(), "hello "); + + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "hello world"); + + editor.handleInput("|"); + assert.strictEqual(editor.getText(), "hello |world"); + }); + + it("undoes Ctrl+U (delete to line start)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("h"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput("l"); + editor.handleInput("o"); + editor.handleInput(" "); + editor.handleInput("w"); + editor.handleInput("o"); + editor.handleInput("r"); + editor.handleInput("l"); + editor.handleInput("d"); + editor.handleInput("\x01"); // Ctrl+A - go to start + for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C"); // Move right 6 times + + editor.handleInput("\x15"); // Ctrl+U + assert.strictEqual(editor.getText(), "world"); + + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "hello world"); + }); + + it("undoes yank", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("h"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput("l"); + editor.handleInput("o"); + editor.handleInput(" "); + editor.handleInput("\x17"); // Ctrl+W - delete "hello " + editor.handleInput("\x19"); // Ctrl+Y - yank + assert.strictEqual(editor.getText(), "hello "); + + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), ""); + }); + + it("undoes single-line paste atomically", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); // Move right 5 (after "hello", before space) + + // Simulate bracketed paste of "beep boop" + editor.handleInput("\x1b[200~beep boop\x1b[201~"); + assert.strictEqual(editor.getText(), "hellobeep boop world"); + + // Single undo should restore entire pre-paste state + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "hello world"); + + editor.handleInput("|"); + assert.strictEqual(editor.getText(), "hello| world"); + }); + + it("does not trigger autocomplete during single-line paste", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let suggestionCalls = 0; + + const mockProvider: AutocompleteProvider = { + getSuggestions: async () => { + suggestionCalls += 1; + return null; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + editor.handleInput("\x1b[200~look at @node_modules/react/index.js please\x1b[201~"); + + assert.strictEqual(editor.getText(), "look at @node_modules/react/index.js please"); + assert.strictEqual(suggestionCalls, 0); + assert.strictEqual(editor.isShowingAutocomplete(), false); + }); + + it("decodes CSI-u Ctrl+letter sequences inside bracketed paste (tmux popup)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // tmux popups with extended-keys-format=csi-u re-encode \n in pastes as + // \x1b[106;5u (Ctrl+J). Without decoding, the per-char filter strips ESC + // and leaks "[106;5u" between lines. See issue #3599. + editor.handleInput("\x1b[200~line1\x1b[106;5uline2\x1b[106;5uline3\x1b[201~"); + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + }); + + it("undoes multi-line paste atomically", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); // Move right 5 (after "hello", before space) + + // Simulate bracketed paste of multi-line text + editor.handleInput("\x1b[200~line1\nline2\nline3\x1b[201~"); + assert.strictEqual(editor.getText(), "helloline1\nline2\nline3 world"); + + // Single undo should restore entire pre-paste state + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "hello world"); + + editor.handleInput("|"); + assert.strictEqual(editor.getText(), "hello| world"); + }); + + it("undoes insertTextAtCursor atomically", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); // Move right 5 (after "hello", before space) + + // Programmatic insertion (e.g., clipboard image path) + editor.insertTextAtCursor("/tmp/image.png"); + assert.strictEqual(editor.getText(), "hello/tmp/image.png world"); + + // Single undo should restore entire pre-insert state + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "hello world"); + + editor.handleInput("|"); + assert.strictEqual(editor.getText(), "hello| world"); + }); + + it("insertTextAtCursor handles multiline text", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); // Move right 5 (after "hello", before space) + + // Insert multiline text + editor.insertTextAtCursor("line1\nline2\nline3"); + assert.strictEqual(editor.getText(), "helloline1\nline2\nline3 world"); + + // Cursor should be at end of inserted text (after "line3", before " world") + const cursor = editor.getCursor(); + assert.strictEqual(cursor.line, 2); + assert.strictEqual(cursor.col, 5); // "line3".length + + // Single undo should restore entire pre-insert state + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "hello world"); + }); + + it("insertTextAtCursor normalizes CRLF and CR line endings", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText(""); + + // Insert text with CRLF + editor.insertTextAtCursor("a\r\nb\r\nc"); + assert.strictEqual(editor.getText(), "a\nb\nc"); + + editor.handleInput("\x1b[45;5u"); // Undo + assert.strictEqual(editor.getText(), ""); + + // Insert text with CR only + editor.insertTextAtCursor("x\ry\rz"); + assert.strictEqual(editor.getText(), "x\ny\nz"); + }); + + it("undoes setText to empty string", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("h"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput("l"); + editor.handleInput("o"); + editor.handleInput(" "); + editor.handleInput("w"); + editor.handleInput("o"); + editor.handleInput("r"); + editor.handleInput("l"); + editor.handleInput("d"); + assert.strictEqual(editor.getText(), "hello world"); + + editor.setText(""); + assert.strictEqual(editor.getText(), ""); + + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "hello world"); + }); + + it("clears undo stack on submit", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let submitted = ""; + editor.onSubmit = (text) => { + submitted = text; + }; + + editor.handleInput("h"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput("l"); + editor.handleInput("o"); + editor.handleInput("\r"); // Enter - submit + + assert.strictEqual(submitted, "hello"); + assert.strictEqual(editor.getText(), ""); + + // Undo should do nothing - stack was cleared + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), ""); + }); + + it("exits history browsing mode on undo", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Add "hello" to history + editor.addToHistory("hello"); + assert.strictEqual(editor.getText(), ""); + + // Type "world" + editor.handleInput("w"); + editor.handleInput("o"); + editor.handleInput("r"); + editor.handleInput("l"); + editor.handleInput("d"); + assert.strictEqual(editor.getText(), "world"); + + // Ctrl+W - delete word + editor.handleInput("\x17"); // Ctrl+W + assert.strictEqual(editor.getText(), ""); + + // Press Up - enter history browsing, shows "hello" + editor.handleInput("\x1b[A"); // Up arrow + assert.strictEqual(editor.getText(), "hello"); + + // Undo should restore to "" (state before entering history browsing) + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), ""); + + // Undo again should restore to "world" (state before Ctrl+W) + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "world"); + }); + + it("undo restores to pre-history state even after multiple history navigations", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Add history entries + editor.addToHistory("first"); + editor.addToHistory("second"); + editor.addToHistory("third"); + + // Type something + editor.handleInput("c"); + editor.handleInput("u"); + editor.handleInput("r"); + editor.handleInput("r"); + editor.handleInput("e"); + editor.handleInput("n"); + editor.handleInput("t"); + assert.strictEqual(editor.getText(), "current"); + + // Clear editor + editor.handleInput("\x17"); // Ctrl+W + assert.strictEqual(editor.getText(), ""); + + // Navigate through history multiple times + editor.handleInput("\x1b[A"); // Up - "third" + assert.strictEqual(editor.getText(), "third"); + editor.handleInput("\x1b[A"); // Up - "second" + assert.strictEqual(editor.getText(), "second"); + editor.handleInput("\x1b[A"); // Up - "first" + assert.strictEqual(editor.getText(), "first"); + + // Undo should go back to "" (state before we started browsing), not intermediate states + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), ""); + + // Another undo goes back to "current" + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "current"); + }); + + it("cursor movement starts new undo unit", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("h"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput("l"); + editor.handleInput("o"); + editor.handleInput(" "); + editor.handleInput("w"); + editor.handleInput("o"); + editor.handleInput("r"); + editor.handleInput("l"); + editor.handleInput("d"); + assert.strictEqual(editor.getText(), "hello world"); + + // Move cursor left 5 (to after "hello ") + for (let i = 0; i < 5; i++) editor.handleInput("\x1b[D"); + + // Type "lol" in the middle + editor.handleInput("l"); + editor.handleInput("o"); + editor.handleInput("l"); + assert.strictEqual(editor.getText(), "hello lolworld"); + + // Undo should restore to "hello world" (before inserting "lol") + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "hello world"); + + editor.handleInput("|"); + assert.strictEqual(editor.getText(), "hello |world"); + }); + + it("no-op delete operations do not push undo snapshots", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.handleInput("h"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput("l"); + editor.handleInput("o"); + assert.strictEqual(editor.getText(), "hello"); + + // Delete word on empty - multiple times (should be no-ops) + editor.handleInput("\x17"); // Ctrl+W - deletes "hello" + assert.strictEqual(editor.getText(), ""); + editor.handleInput("\x17"); // Ctrl+W - no-op (nothing to delete) + editor.handleInput("\x17"); // Ctrl+W - no-op + + // Single undo should restore "hello" + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "hello"); + }); + + it("undoes autocomplete", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Create a mock autocomplete provider + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + const text = lines[0] || ""; + const prefix = text.slice(0, cursorCol); + if (prefix === "di") { + return { + items: [{ value: "dist/", label: "dist/" }], + prefix: "di", + }; + } + return null; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type "di" + editor.handleInput("d"); + editor.handleInput("i"); + assert.strictEqual(editor.getText(), "di"); + + // Press Tab to trigger autocomplete + editor.handleInput("\t"); + await flushAutocomplete(); + assert.strictEqual(editor.getText(), "dist/"); + assert.strictEqual(editor.isShowingAutocomplete(), false); + + // Undo should restore to "di" + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "di"); + }); + }); + + describe("Autocomplete", () => { + it("auto-applies single force-file suggestion without showing menu", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol, options) => { + if (!options.force) { + return null; + } + const text = lines[0] || ""; + const prefix = text.slice(0, cursorCol); + if (prefix === "Work") { + return { + items: [{ value: "Workspace/", label: "Workspace/" }], + prefix: "Work", + }; + } + return null; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type "Work" + editor.handleInput("W"); + editor.handleInput("o"); + editor.handleInput("r"); + editor.handleInput("k"); + assert.strictEqual(editor.getText(), "Work"); + + // Press Tab - should auto-apply without showing menu + editor.handleInput("\t"); + await flushAutocomplete(); + assert.strictEqual(editor.getText(), "Workspace/"); + assert.strictEqual(editor.isShowingAutocomplete(), false); + + // Undo should restore to "Work" + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "Work"); + }); + + it("shows menu when force-file has multiple suggestions", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol, options) => { + if (!options.force) { + return null; + } + const text = lines[0] || ""; + const prefix = text.slice(0, cursorCol); + if (prefix === "src") { + return { + items: [ + { value: "src/", label: "src/" }, + { value: "src.txt", label: "src.txt" }, + ], + prefix: "src", + }; + } + return null; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type "src" + editor.handleInput("s"); + editor.handleInput("r"); + editor.handleInput("c"); + assert.strictEqual(editor.getText(), "src"); + + // Press Tab - should show menu because there are multiple suggestions + editor.handleInput("\t"); + await flushAutocomplete(); + assert.strictEqual(editor.getText(), "src"); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + // Press Tab again to accept first suggestion + editor.handleInput("\t"); + assert.strictEqual(editor.getText(), "src/"); + assert.strictEqual(editor.isShowingAutocomplete(), false); + }); + + it("keeps suggestions open when typing in force mode (Tab-triggered)", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + const allFiles = [ + { value: "readme.md", label: "readme.md" }, + { value: "package.json", label: "package.json" }, + { value: "src/", label: "src/" }, + { value: "dist/", label: "dist/" }, + ]; + + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol, options) => { + const text = lines[0] || ""; + const prefix = text.slice(0, cursorCol); + const shouldMatch = options.force || prefix.includes("/") || prefix.startsWith("."); + if (!shouldMatch) { + return null; + } + const filtered = allFiles.filter((f) => f.value.toLowerCase().startsWith(prefix.toLowerCase())); + if (filtered.length > 0) { + return { items: filtered, prefix }; + } + return null; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Press Tab on empty prompt - should show all files (force mode) + editor.handleInput("\t"); + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + // Type "r" - should narrow to "readme.md" (force mode keeps suggestions open) + editor.handleInput("r"); + await flushAutocomplete(); + assert.strictEqual(editor.getText(), "r"); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + // Type "e" - should still show "readme.md" + editor.handleInput("e"); + await flushAutocomplete(); + assert.strictEqual(editor.getText(), "re"); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + // Accept with Tab + editor.handleInput("\t"); + assert.strictEqual(editor.getText(), "readme.md"); + assert.strictEqual(editor.isShowingAutocomplete(), false); + }); + + it("debounces @ autocomplete while typing", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let suggestionCalls = 0; + + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + suggestionCalls += 1; + const text = (lines[0] || "").slice(0, cursorCol); + return { + items: [{ value: "@main.ts", label: "main.ts" }], + prefix: text, + }; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + editor.handleInput("@"); + editor.handleInput("m"); + editor.handleInput("a"); + editor.handleInput("i"); + + assert.strictEqual(suggestionCalls, 0); + assert.strictEqual(editor.isShowingAutocomplete(), false); + + await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAutocomplete(); + + assert.strictEqual(suggestionCalls, 1); + assert.strictEqual(editor.isShowingAutocomplete(), true); + }); + + it("re-queries the autocomplete picker when the cursor moves back into the command name", async () => { + // Regression for earendil-works/pi#5496: arrowing left out of a slash + // command's argument region must re-query the picker, not leave the + // stale argument list showing. Before the fix, moveCursor() never + // called updateAutocomplete(), so `/cmd ` (argument menu) + Left kept + // displaying the arguments against a `/cmd` prefix — and a Tab there + // would concatenate the stale suggestion onto the partial command name. + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + const before = (lines[0] || "").slice(0, cursorCol); + if (!before.startsWith("/")) return null; + // Past the command name (a space before the cursor): offer arguments. + if (before.includes(" ")) { + return { + items: [ + { value: "repo", label: "repo" }, + { value: "message", label: "message" }, + { value: "help", label: "help" }, + ], + prefix: before.slice(before.indexOf(" ") + 1), + }; + } + // Inside the command name: offer the command name only. + return { items: [{ value: "cmd", label: "cmd" }], prefix: before }; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type `/cmd ` so the picker ends up showing the argument list. + for (const ch of "/cmd ") { + editor.handleInput(ch); + await flushAutocomplete(); + } + assert.strictEqual(editor.getText(), "/cmd "); + assert.strictEqual(editor.isShowingAutocomplete(), true); + const atArg = editor + .render(80) + .map((l) => stripVTControlCharacters(l)) + .join("\n"); + assert.ok(atArg.includes("repo"), "argument menu should be visible at `/cmd `"); + + // Arrow Left back into the command name (`/cmd`). + editor.handleInput("\x1b[D"); + await flushAutocomplete(); + + // The picker must have re-queried: the stale argument items are gone + // (replaced by the command-name suggestion, or the picker closed). + const afterMove = editor + .render(80) + .map((l) => stripVTControlCharacters(l)) + .join("\n"); + assert.ok(!afterMove.includes("repo"), "stale argument menu must not survive the cursor move"); + assert.ok(!afterMove.includes("message"), "stale argument menu must not survive the cursor move"); + }); + + it("debounces # autocomplete while typing", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let suggestionCalls = 0; + + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + suggestionCalls += 1; + const text = (lines[0] || "").slice(0, cursorCol); + return { + items: [{ value: "#2983", label: "#2983" }], + prefix: text, + }; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + editor.handleInput("#"); + editor.handleInput("2"); + editor.handleInput("9"); + editor.handleInput("8"); + + assert.strictEqual(suggestionCalls, 0); + assert.strictEqual(editor.isShowingAutocomplete(), false); + + await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAutocomplete(); + + assert.strictEqual(suggestionCalls, 1); + assert.strictEqual(editor.isShowingAutocomplete(), true); + }); + + it("debounces custom triggerCharacters autocomplete while typing", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let suggestionCalls = 0; + + editor.setAutocompleteProvider({ + triggerCharacters: ["$"], + getSuggestions: async (lines, _cursorLine, cursorCol) => { + suggestionCalls += 1; + const prefix = (lines[0] || "").slice(0, cursorCol); + return { items: [{ value: "$skill-name", label: "skill-name" }], prefix }; + }, + applyCompletion, + }); + + editor.handleInput("$"); + editor.handleInput("s"); + editor.handleInput("k"); + + assert.strictEqual(suggestionCalls, 0); + await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAutocomplete(); + + assert.strictEqual(suggestionCalls, 1); + assert.strictEqual(editor.isShowingAutocomplete(), true); + }); + + it("resets custom triggerCharacters when provider changes", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let suggestionCalls = 0; + + editor.setAutocompleteProvider({ + triggerCharacters: ["$"], + getSuggestions: async () => ({ items: [{ value: "$skill-name", label: "skill-name" }], prefix: "$" }), + applyCompletion, + }); + editor.setAutocompleteProvider({ + getSuggestions: async () => { + suggestionCalls += 1; + return { items: [{ value: "$skill-name", label: "skill-name" }], prefix: "$" }; + }, + applyCompletion, + }); + + editor.handleInput("$"); + editor.handleInput("s"); + await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAutocomplete(); + + assert.strictEqual(suggestionCalls, 0); + assert.strictEqual(editor.isShowingAutocomplete(), false); + }); + + it("aborts active @ autocomplete when typing continues", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let aborts = 0; + + const mockProvider: AutocompleteProvider = { + getSuggestions: async (_lines, _cursorLine, _cursorCol, options) => { + return await new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve({ items: [{ value: "@main.ts", label: "main.ts" }], prefix: "@main" }); + }, 500); + options.signal.addEventListener( + "abort", + () => { + aborts += 1; + clearTimeout(timeout); + resolve(null); + }, + { once: true }, + ); + }); + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + editor.handleInput("@"); + editor.handleInput("m"); + editor.handleInput("a"); + editor.handleInput("i"); + await new Promise((resolve) => setTimeout(resolve, 250)); + editor.handleInput("n"); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.strictEqual(aborts, 1); + }); + + it("hides autocomplete when backspacing slash command to empty", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Mock provider with slash commands + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + const text = lines[0] || ""; + const prefix = text.slice(0, cursorCol); + // Only return slash command suggestions when line starts with / + if (prefix.startsWith("/")) { + const commands = [ + { value: "/model", label: "model", description: "Change model" }, + { value: "/help", label: "help", description: "Show help" }, + ]; + const query = prefix.slice(1); // Remove leading / + const filtered = commands.filter((c) => c.value.startsWith(query)); + if (filtered.length > 0) { + return { items: filtered, prefix }; + } + } + return null; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type "/" - should show slash command suggestions + editor.handleInput("/"); + await flushAutocomplete(); + assert.strictEqual(editor.getText(), "/"); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + // Backspace to delete "/" - should hide autocomplete completely + editor.handleInput("\x7f"); // Backspace + await flushAutocomplete(); + assert.strictEqual(editor.getText(), ""); + assert.strictEqual(editor.isShowingAutocomplete(), false); + }); + + it("applies exact typed slash-argument value on Enter even when first item is highlighted", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Mock provider for /argtest command with argument completions + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + const text = lines[0] || ""; + const beforeCursor = text.slice(0, cursorCol); + + // Check if we're in argument completion context: "/argtest " + const argtestMatch = beforeCursor.match(/^\/argtest\s+(\S+)$/); + if (argtestMatch) { + const argumentText = argtestMatch[1]!; + const allArguments = [ + { value: "one", label: "one" }, + { value: "two", label: "two" }, + { value: "three", label: "three" }, + ]; + // Return all arguments that start with the typed prefix + const filtered = allArguments.filter((arg) => arg.value.startsWith(argumentText)); + if (filtered.length > 0) { + return { items: filtered, prefix: argumentText }; + } + } + return null; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type "/argtest two" + editor.handleInput("/"); + editor.handleInput("a"); + editor.handleInput("r"); + editor.handleInput("g"); + editor.handleInput("t"); + editor.handleInput("e"); + editor.handleInput("s"); + editor.handleInput("t"); + editor.handleInput(" "); + editor.handleInput("t"); + editor.handleInput("w"); + editor.handleInput("o"); + + assert.strictEqual(editor.getText(), "/argtest two"); + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + // Press Enter - should apply the exact typed value "two", not the first item + editor.handleInput("\r"); + + // The exact typed value "two" should be retained + assert.strictEqual(editor.getText(), "/argtest two"); + }); + + it("selects first prefix match on Enter when typed arg is not exact match", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Mock provider for /argtest command with argument completions + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + const text = lines[0] || ""; + const beforeCursor = text.slice(0, cursorCol); + + // Check if we're in argument completion context + const argtestMatch = beforeCursor.match(/^\/argtest\s+(\S+)$/); + if (argtestMatch) { + const argumentText = argtestMatch[1]!; + const allArguments = [ + { value: "two", label: "two" }, + { value: "three", label: "three" }, + { value: "twelve", label: "twelve" }, + ]; + // Return all items that start with the typed prefix + const filtered = allArguments.filter((arg) => arg.value.startsWith(argumentText)); + if (filtered.length > 0) { + return { items: filtered, prefix: argumentText }; + } + } + return null; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type "/argtest t" - filtered to [two, three, twelve], prefix "t" matches "two" first + editor.handleInput("/"); + editor.handleInput("a"); + editor.handleInput("r"); + editor.handleInput("g"); + editor.handleInput("t"); + editor.handleInput("e"); + editor.handleInput("s"); + editor.handleInput("t"); + editor.handleInput(" "); + editor.handleInput("t"); + + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + // Press Enter - "t" prefix matches "two" (first in list), so "two" is applied + editor.handleInput("\r"); + assert.strictEqual(editor.getText(), "/argtest two"); + }); + + it("highlights unique prefix match as user types (before full exact match)", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Mock provider that returns all items unfiltered (like real extensions do) + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + const text = lines[0] || ""; + const beforeCursor = text.slice(0, cursorCol); + + const argtestMatch = beforeCursor.match(/^\/argtest\s+(\S+)$/); + if (argtestMatch) { + const argumentText = argtestMatch[1]!; + // Return all items - provider does not filter + const allArguments = [ + { value: "one", label: "one" }, + { value: "two", label: "two" }, + { value: "three", label: "three" }, + ]; + return { items: allArguments, prefix: argumentText }; + } + return null; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type "/argtest tw" - "tw" is a prefix of only "two" + editor.handleInput("/"); + editor.handleInput("a"); + editor.handleInput("r"); + editor.handleInput("g"); + editor.handleInput("t"); + editor.handleInput("e"); + editor.handleInput("s"); + editor.handleInput("t"); + editor.handleInput(" "); + editor.handleInput("t"); + editor.handleInput("w"); + + assert.strictEqual(editor.getText(), "/argtest tw"); + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + // Press Enter - "tw" uniquely matches "two", so "two" should be applied + editor.handleInput("\r"); + assert.strictEqual(editor.getText(), "/argtest two"); + }); + + it("selects first prefix match when multiple items match", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Mock provider that returns all items unfiltered + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + const text = lines[0] || ""; + const beforeCursor = text.slice(0, cursorCol); + + const argtestMatch = beforeCursor.match(/^\/argtest\s+(\S+)$/); + if (argtestMatch) { + const argumentText = argtestMatch[1]!; + const allArguments = [ + { value: "one", label: "one" }, + { value: "two", label: "two" }, + { value: "three", label: "three" }, + ]; + return { items: allArguments, prefix: argumentText }; + } + return null; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type "/argtest t" - "t" is a prefix of both "two" and "three" + editor.handleInput("/"); + editor.handleInput("a"); + editor.handleInput("r"); + editor.handleInput("g"); + editor.handleInput("t"); + editor.handleInput("e"); + editor.handleInput("s"); + editor.handleInput("t"); + editor.handleInput(" "); + editor.handleInput("t"); + + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + // Press Enter - "t" matches "two" first, so "two" is selected + editor.handleInput("\r"); + assert.strictEqual(editor.getText(), "/argtest two"); + }); + + it("works for built-in-style command argument completion path (model-like)", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Mock provider for /model command with model completions + const mockProvider: AutocompleteProvider = { + getSuggestions: async (lines, _cursorLine, cursorCol) => { + const text = lines[0] || ""; + const beforeCursor = text.slice(0, cursorCol); + + // Check if we're in /model argument completion context + // Use [^ ]+ to match any non-space characters (including hyphens) + const modelMatch = beforeCursor.match(/^\/model\s+(\S+)$/); + if (modelMatch) { + const modelText = modelMatch[1]!; + const allModels = [ + { value: "gpt-4o", label: "gpt-4o" }, + { value: "gpt-4o-mini", label: "gpt-4o-mini" }, + { value: "claude-sonnet", label: "claude-sonnet" }, + ]; + // Return all models that start with the typed prefix + const filtered = allModels.filter((m) => m.value.startsWith(modelText)); + if (filtered.length > 0) { + return { items: filtered, prefix: modelText }; + } + } + return null; + }, + applyCompletion, + }; + + editor.setAutocompleteProvider(mockProvider); + + // Type "/model gpt-4o-mini" - exact match for second item in list + editor.handleInput("/"); + editor.handleInput("m"); + editor.handleInput("o"); + editor.handleInput("d"); + editor.handleInput("e"); + editor.handleInput("l"); + editor.handleInput(" "); + editor.handleInput("g"); + editor.handleInput("p"); + editor.handleInput("t"); + editor.handleInput("-"); + editor.handleInput("4"); + editor.handleInput("o"); + editor.handleInput("-"); + editor.handleInput("m"); + editor.handleInput("i"); + editor.handleInput("n"); + editor.handleInput("i"); + + assert.strictEqual(editor.getText(), "/model gpt-4o-mini"); + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + // Press Enter - should retain exact typed value, not apply first highlighted item + editor.handleInput("\r"); + + // The exact typed value should be retained + assert.strictEqual(editor.getText(), "/model gpt-4o-mini"); + }); + + it("awaits async slash command argument completions", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const provider = new CombinedAutocompleteProvider( + [ + { + name: "load-skills", + description: "Load skills", + getArgumentCompletions: async (prefix) => + prefix.startsWith("s") ? [{ value: "skill-a", label: "skill-a" }] : null, + }, + ], + process.cwd(), + ); + editor.setAutocompleteProvider(provider); + editor.setText("/load-skills "); + + editor.handleInput("s"); + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + editor.handleInput("\t"); + assert.strictEqual(editor.getText(), "/load-skills skill-a"); + assert.strictEqual(editor.isShowingAutocomplete(), false); + }); + + it("ignores invalid slash command argument completion results", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const provider = new CombinedAutocompleteProvider( + [ + { + name: "load-skills", + description: "Load skills", + getArgumentCompletions: (() => "not-an-array") as unknown as ( + argumentPrefix: string, + ) => Promise<{ value: string; label: string }[] | null>, + }, + ], + process.cwd(), + ); + editor.setAutocompleteProvider(provider); + editor.setText("/load-skills "); + + editor.handleInput("s"); + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), false); + assert.strictEqual(editor.getText(), "/load-skills s"); + }); + + it("does not show argument completions when command has no argument completer", async () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const provider = new CombinedAutocompleteProvider( + [ + { name: "help", description: "Show help" }, + { + name: "model", + description: "Switch model", + getArgumentCompletions: () => [{ value: "claude-opus", label: "claude-opus" }], + }, + ], + process.cwd(), + ); + editor.setAutocompleteProvider(provider); + + editor.handleInput("/"); + editor.handleInput("h"); + editor.handleInput("e"); + await flushAutocomplete(); + assert.strictEqual(editor.isShowingAutocomplete(), true); + + editor.handleInput("\t"); + assert.strictEqual(editor.getText(), "/help "); + assert.strictEqual(editor.isShowingAutocomplete(), false); + }); + }); + + describe("Character jump (Ctrl+])", () => { + it("jumps forward to first occurrence of character on same line", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1d"); // Ctrl+] (legacy sequence for ctrl+]) + editor.handleInput("o"); // Jump to first 'o' + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 4 }); // 'o' in "hello" + }); + + it("jumps forward to next occurrence after cursor", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + // Move cursor to the 'o' in "hello" (col 4) + for (let i = 0; i < 4; i++) editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 4 }); + + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("o"); // Jump to next 'o' (in "world") + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); // 'o' in "world" + }); + + it("jumps forward across multiple lines", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("abc\ndef\nghi"); + // Cursor is at end (line 2, col 3). Move to line 0 via up arrows, then Ctrl+A + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x1b[A"); // Up - now on line 0 + editor.handleInput("\x01"); // Ctrl+A - go to start of line + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("g"); // Jump to 'g' on line 3 + + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 0 }); + }); + + it("jumps backward to first occurrence before cursor on same line", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + // Cursor at end (col 11) + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 11 }); + + editor.handleInput("\x1b\x1d"); // Ctrl+Alt+] (ESC followed by Ctrl+]) + editor.handleInput("o"); // Jump to last 'o' before cursor + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); // 'o' in "world" + }); + + it("jumps backward across multiple lines", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("abc\ndef\nghi"); + // Cursor at end of line 3 + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 3 }); + + editor.handleInput("\x1b\x1d"); // Ctrl+Alt+] + editor.handleInput("a"); // Jump to 'a' on line 1 + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + }); + + it("does nothing when character is not found (forward)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("z"); // 'z' doesn't exist + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); // Cursor unchanged + }); + + it("does nothing when character is not found (backward)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + // Cursor at end + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 11 }); + + editor.handleInput("\x1b\x1d"); // Ctrl+Alt+] + editor.handleInput("z"); // 'z' doesn't exist + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 11 }); // Cursor unchanged + }); + + it("is case-sensitive", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("Hello World"); + editor.handleInput("\x01"); // Ctrl+A - go to start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + // Search for lowercase 'h' - should not find it (only 'H' exists) + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("h"); + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); // Cursor unchanged + + // Search for uppercase 'W' - should find it + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("W"); + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 }); // 'W' in "World" + }); + + it("cancels jump mode when Ctrl+] is pressed again", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1d"); // Ctrl+] - enter jump mode + editor.handleInput("\x1d"); // Ctrl+] again - cancel + + // Type 'o' normally - should insert, not jump + editor.handleInput("o"); + assert.strictEqual(editor.getText(), "ohello world"); + }); + + it("cancels jump mode on Escape and processes the Escape", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1d"); // Ctrl+] - enter jump mode + editor.handleInput("\x1b"); // Escape - cancel jump mode + + // Cursor should be unchanged (Escape itself doesn't move cursor in editor) + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + // Type 'o' normally - should insert, not jump + editor.handleInput("o"); + assert.strictEqual(editor.getText(), "ohello world"); + }); + + it("cancels backward jump mode when Ctrl+Alt+] is pressed again", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + // Cursor at end + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 11 }); + + editor.handleInput("\x1b\x1d"); // Ctrl+Alt+] - enter backward jump mode + editor.handleInput("\x1b\x1d"); // Ctrl+Alt+] again - cancel + + // Type 'o' normally - should insert, not jump + editor.handleInput("o"); + assert.strictEqual(editor.getText(), "hello worldo"); + }); + + it("searches for special characters", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("foo(bar) = baz;"); + editor.handleInput("\x01"); // Ctrl+A - go to start + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + // Jump to '(' + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("("); + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + + // Jump to '=' + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("="); + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 9 }); + }); + + it("handles empty text gracefully", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText(""); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("x"); + + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); // Cursor unchanged + }); + + it("resets lastAction when jumping", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start + + // Type to set lastAction to "type-word" + editor.handleInput("x"); + assert.strictEqual(editor.getText(), "xhello world"); + + // Jump forward + editor.handleInput("\x1d"); // Ctrl+] + editor.handleInput("o"); + + // Type more - should start a new undo unit (lastAction was reset) + editor.handleInput("Y"); + assert.strictEqual(editor.getText(), "xhellYo world"); + + // Undo should only undo "Y", not "x" as well + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "xhello world"); + }); + }); + + describe("Sticky column", () => { + // Helper: position cursor at a specific line and column + function positionCursor(editor: Editor, line: number, col: number): void { + // Go to line 0 first + for (let i = 0; i < 20; i++) editor.handleInput("\x1b[A"); + // Go to target line + for (let i = 0; i < line; i++) editor.handleInput("\x1b[B"); + // Go to target col + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < col; i++) editor.handleInput("\x1b[C"); + } + + it("preserves target column when moving up through a shorter line", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Line 0: "2222222222x222" (x at col 10) + // Line 1: "" (empty) + // Line 2: "1111111111_111111111111" (_ at col 10) + editor.setText("2222222222x222\n\n1111111111_111111111111"); + + // Position cursor on _ (line 2, col 10) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 23 }); // At end + editor.handleInput("\x01"); // Ctrl+A - go to start of line + for (let i = 0; i < 10; i++) editor.handleInput("\x1b[C"); // Move right to col 10 + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 10 }); + + // Press Up - should move to empty line (col clamped to 0) + editor.handleInput("\x1b[A"); // Up arrow + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 0 }); + + // Press Up again - should move to line 0 at col 10 (on 'x') + editor.handleInput("\x1b[A"); // Up arrow + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 10 }); + }); + + it("preserves target column when moving down through a shorter line", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1111111111_111\n\n2222222222x222222222222"); + + // Position cursor on _ (line 0, col 10) + editor.handleInput("\x1b[A"); // Up to line 1 + editor.handleInput("\x1b[A"); // Up to line 0 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 10; i++) editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 10 }); + + // Press Down - should move to empty line (col clamped to 0) + editor.handleInput("\x1b[B"); // Down arrow + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 0 }); + + // Press Down again - should move to line 2 at col 10 (on 'x') + editor.handleInput("\x1b[B"); // Down arrow + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 10 }); + }); + + it("resets sticky column on horizontal movement (left arrow)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Start at line 2, col 5 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 5 }); + + // Move up through empty line + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 5 (sticky) + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); + + // Move left - resets sticky column + editor.handleInput("\x1b[D"); // Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 4 }); + + // Move down twice + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 4 (new sticky from col 4) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 4 }); + }); + + it("resets sticky column on horizontal movement (right arrow)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Start at line 0, col 5 + editor.handleInput("\x1b[A"); // Up to line 1 + editor.handleInput("\x1b[A"); // Up to line 0 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); + + // Move down through empty line + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 5 (sticky) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 5 }); + + // Move right - resets sticky column + editor.handleInput("\x1b[C"); // Right + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 6 }); + + // Move up twice + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 6 (new sticky from col 6) + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 }); + }); + + it("resets sticky column on typing", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Start at line 2, col 8 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); + + // Move up through empty line + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x1b[A"); // Up - line 0, col 8 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); + + // Type a character - resets sticky column + editor.handleInput("X"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 9 }); + + // Move down twice + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 9 (new sticky from col 9) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 9 }); + }); + + it("resets sticky column on backspace", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Start at line 2, col 8 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); + + // Move up through empty line + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x1b[A"); // Up - line 0, col 8 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); + + // Backspace - resets sticky column + editor.handleInput("\x7f"); // Backspace + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); + + // Move down twice + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 7 (new sticky from col 7) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 7 }); + }); + + it("resets sticky column on Ctrl+A (move to line start)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Start at line 2, col 8 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); + + // Move up - establishes sticky col 8 + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + + // Ctrl+A - resets sticky column to 0 + editor.handleInput("\x01"); // Ctrl+A + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 0 }); + + // Move up + editor.handleInput("\x1b[A"); // Up - line 0, col 0 (new sticky from col 0) + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + }); + + it("resets sticky column on Ctrl+E (move to line end)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("12345\n\n1234567890"); + + // Start at line 2, col 3 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 3; i++) editor.handleInput("\x1b[C"); + + // Move up through empty line - establishes sticky col 3 + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 3 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + + // Ctrl+E - resets sticky column to end + editor.handleInput("\x05"); // Ctrl+E + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); + + // Move down twice + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 5 (new sticky from col 5) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 5 }); + }); + + it("resets sticky column on word movement (Ctrl+Left)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world\n\nhello world"); + + // Start at end of line 2 (col 11) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 11 }); + + // Move up through empty line - establishes sticky col 11 + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 11 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 11 }); + + // Ctrl+Left - word movement resets sticky column + editor.handleInput("\x1b[1;5D"); // Ctrl+Left + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 }); // Before "world" + + // Move down twice + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 6 (new sticky from col 6) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 6 }); + }); + + it("resets sticky column on word movement (Ctrl+Right)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world\n\nhello world"); + + // Start at line 0, col 0 + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x01"); // Ctrl+A + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + // Move down through empty line - establishes sticky col 0 + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 0 + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 0 }); + + // Ctrl+Right - word movement resets sticky column + editor.handleInput("\x1b[1;5C"); // Ctrl+Right + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 5 }); // After "hello" + + // Move up twice + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 5 (new sticky from col 5) + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); + }); + + it("resets sticky column on undo", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Go to line 0, col 8 + editor.handleInput("\x1b[A"); // Up to line 1 + editor.handleInput("\x1b[A"); // Up to line 0 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); + + // Move down through empty line - establishes sticky col 8 + editor.handleInput("\x1b[B"); // Down - line 1, col 0 + editor.handleInput("\x1b[B"); // Down - line 2, col 8 (sticky) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 8 }); + + // Type something to create undo state - this clears sticky and sets col to 9 + editor.handleInput("X"); + assert.strictEqual(editor.getText(), "1234567890\n\n12345678X90"); + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 9 }); + + // Move up - establishes new sticky col 9 + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 9 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 9 }); + + // Undo - resets sticky column and restores cursor to line 2, col 8 + editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(editor.getText(), "1234567890\n\n1234567890"); + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 8 }); + + // Move up - should capture new sticky from restored col 8, not old col 9 + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 8 (new sticky from restored position) + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); + }); + + it("handles multiple consecutive up/down movements", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1234567890\nab\ncd\nef\n1234567890"); + + // Start at line 4, col 7 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 7; i++) editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 4, col: 7 }); + + // Move up multiple times through short lines + editor.handleInput("\x1b[A"); // Up - line 3, col 2 (clamped) + editor.handleInput("\x1b[A"); // Up - line 2, col 2 (clamped) + editor.handleInput("\x1b[A"); // Up - line 1, col 2 (clamped) + editor.handleInput("\x1b[A"); // Up - line 0, col 7 (restored) + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 7 }); + + // Move down multiple times - sticky should still be 7 + editor.handleInput("\x1b[B"); // Down - line 1, col 2 + editor.handleInput("\x1b[B"); // Down - line 2, col 2 + editor.handleInput("\x1b[B"); // Down - line 3, col 2 + editor.handleInput("\x1b[B"); // Down - line 4, col 7 (restored) + assert.deepStrictEqual(editor.getCursor(), { line: 4, col: 7 }); + }); + + it("moves correctly through wrapped visual lines without getting stuck", () => { + const tui = createTestTUI(15, 24); // Narrow terminal + const editor = new Editor(tui, defaultEditorTheme); + + // Line 0: short + // Line 1: 30 chars = wraps to 3 visual lines at width 10 (after padding) + editor.setText("short\n123456789012345678901234567890"); + editor.render(15); // This gives 14 layout width + + // Position at end of line 1 (col 30) + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 30 }); + + // Move up repeatedly - should traverse all visual lines of the wrapped text + // and eventually reach line 0 + editor.handleInput("\x1b[A"); // Up - to previous visual line within line 1 + assert.strictEqual(editor.getCursor().line, 1); + + editor.handleInput("\x1b[A"); // Up - another visual line + assert.strictEqual(editor.getCursor().line, 1); + + editor.handleInput("\x1b[A"); // Up - should reach line 0 + assert.strictEqual(editor.getCursor().line, 0); + }); + + it("handles setText resetting sticky column", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("1234567890\n\n1234567890"); + + // Establish sticky column + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C"); + editor.handleInput("\x1b[A"); // Up + + // setText should reset sticky column + editor.setText("abcdefghij\n\nabcdefghij"); + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 10 }); // At end + + // Move up - should capture new sticky from current position (10) + editor.handleInput("\x1b[A"); // Up - line 1, col 0 + editor.handleInput("\x1b[A"); // Up - line 0, col 10 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 10 }); + }); + + it("sets preferredVisualCol when pressing right at end of prompt (last line)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Line 0: 20 chars with 'x' at col 10 + // Line 1: empty + // Line 2: 10 chars ending with '_' + editor.setText("111111111x1111111111\n\n333333333_"); + + // Go to line 0, press Ctrl+E (end of line) - col 20 + editor.handleInput("\x1b[A"); // Up to line 1 + editor.handleInput("\x1b[A"); // Up to line 0 + editor.handleInput("\x05"); // Ctrl+E - move to end of line + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 20 }); + + // Move down to line 2 - cursor clamped to col 10 (end of line) + editor.handleInput("\x1b[B"); // Down to line 1, col 0 + editor.handleInput("\x1b[B"); // Down to line 2, col 10 (clamped) + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 10 }); + + // Press Right at end of prompt - nothing visible happens, but sets preferredVisualCol to 10 + editor.handleInput("\x1b[C"); // Right - can't move, but sets preferredVisualCol + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 10 }); // Still at same position + + // Move up twice to line 0 - should use preferredVisualCol (10) to land on 'x' + editor.handleInput("\x1b[A"); // Up to line 1, col 0 + editor.handleInput("\x1b[A"); // Up to line 0, col 10 (on 'x') + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 10 }); + }); + + it("handles editor resizes when preferredVisualCol is on the same line", () => { + // Create editor with wider terminal + const tui = createTestTUI(80, 24); + const editor = new Editor(tui, defaultEditorTheme); + + editor.setText("12345678901234567890\n\n12345678901234567890"); + + // Start at line 2, col 15 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 15; i++) editor.handleInput("\x1b[C"); + + // Move up through empty line - establishes sticky col 15 + editor.handleInput("\x1b[A"); // Up + editor.handleInput("\x1b[A"); // Up - line 0, col 15 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 15 }); + + // Render with narrower width to simulate resize + editor.render(12); // Width 12 + + // Move down - sticky should be clamped to new width + editor.handleInput("\x1b[B"); // Down - line 1 + editor.handleInput("\x1b[B"); // Down - line 2, col should be clamped + assert.equal(editor.getCursor().col, 4); + }); + + it("handles editor resizes when preferredVisualCol is on a different line", () => { + const tui = createTestTUI(80, 24); + const editor = new Editor(tui, defaultEditorTheme); + + // Create a line that wraps into multiple visual lines at width 10 + // "12345678901234567890" = 20 chars, wraps to 2 visual lines at width 10 + editor.setText("short\n12345678901234567890"); + + // Go to line 1, col 15 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 15; i++) editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 15 }); + + // Move up to establish sticky col 15 + editor.handleInput("\x1b[A"); // Up to line 0 + // Line 0 has only 5 chars, so cursor at col 5 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); + + // Narrow the editor + editor.render(10); + + // Move down - preferredVisualCol was 15, but width is 10 + // Should land on line 1, clamped to width (visual col 9, which is logical col 9) + editor.handleInput("\x1b[B"); // Down to line 1 + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 8 }); + + // Move up + editor.handleInput("\x1b[A"); // Up - should go to line 0 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 5 }); // Line 0 only has 5 chars + + // Restore the original width + editor.render(80); + + // Move down - preferredVisualCol was kept at 15 + editor.handleInput("\x1b[B"); // Down to line 1 + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 15 }); + }); + + it("rewrapped lines: target fits current visual column", () => { + const tui = createTestTUI(80, 24); + const editor = new Editor(tui, defaultEditorTheme); + editor.setText("abcdefghijklmnopqr\n123456789012345678"); + + positionCursor(editor, 0, 18); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 18 }); + + // Narrow to width 10 (layoutWidth = 9). + // Line 0 last segment has visual col max 9, line 1 first segment max 8 + editor.render(10); + + // Move down: cursor clamps to 8 + editor.handleInput("\x1b[B"); + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 8 }); + + // Widen back. Move up, the current visual col wins + editor.render(80); + editor.handleInput("\x1b[A"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 8 }); + + // Preferred was cleared by the rewrapped branch + editor.handleInput("\x1b[B"); + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 8 }); + }); + + it("rewrapped lines: target shorter than current visual column", () => { + const tui = createTestTUI(80, 24); + const editor = new Editor(tui, defaultEditorTheme); + editor.setText("abcdefghijklmnopqr\n123456789012345678\nab"); + + positionCursor(editor, 0, 18); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 18 }); + + // Narrow to width 10 (layoutWidth = 9). Moving down clamps to col 8 + editor.render(10); + editor.handleInput("\x1b[B"); + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 8 }); + + // Widen the editor + editor.render(80); + + // Move down to short line "ab". + // preferredVisualCol is replaced with current visual col (8), cursor clamps to 2 + editor.handleInput("\x1b[B"); + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 2 }); + + // Moving up restores to preferred col 8 + editor.handleInput("\x1b[A"); + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 8 }); + }); + }); + + describe("Paste marker atomic behavior", () => { + /** Helper: simulate a large paste that creates a marker */ + function pasteWithMarker(editor: Editor): string { + const bigContent = "line\n".repeat(20).trimEnd(); // 20 lines + editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`); + // The editor replaces large pastes with a marker like "[paste #1 +20 lines]" + return editor.getText(); + } + + it("creates a paste marker for large pastes", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const text = pasteWithMarker(editor); + assert.match(text, /\[paste #\d+ \+\d+ lines\]/); + }); + + it("treats paste marker as single unit for right arrow", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.handleInput("A"); + pasteWithMarker(editor); + editor.handleInput("B"); + // Text: "A[paste #1 +20 lines]B", cursor at end + + // Go to start + editor.handleInput("\x01"); // Ctrl+A + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + // Right arrow: should move past "A" + editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 }); + + // Right arrow: should skip the entire marker + editor.handleInput("\x1b[C"); + const marker = editor.getText().match(/\[paste #\d+ \+\d+ lines\]/)![0]; + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 + marker.length }); + + // Right arrow: should move past "B" + editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 + marker.length + 1 }); + }); + + it("treats paste marker as single unit for left arrow", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.handleInput("A"); + pasteWithMarker(editor); + editor.handleInput("B"); + // Cursor at end + + // Left arrow: past "B" + editor.handleInput("\x1b[D"); + const text = editor.getText(); + const marker = text.match(/\[paste #\d+ \+\d+ lines\]/)![0]; + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 + marker.length }); + + // Left arrow: skip the entire marker + editor.handleInput("\x1b[D"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 }); + + // Left arrow: past "A" + editor.handleInput("\x1b[D"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + }); + + it("treats paste marker as single unit for backspace", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.handleInput("A"); + pasteWithMarker(editor); + editor.handleInput("B"); + + const text = editor.getText(); + const marker = text.match(/\[paste #\d+ \+\d+ lines\]/)![0]; + + // Position cursor right after the marker (before "B") + editor.handleInput("\x01"); // Ctrl+A + // Move past "A" and the marker + editor.handleInput("\x1b[C"); // past "A" + editor.handleInput("\x1b[C"); // past marker + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 + marker.length }); + + // Backspace: should delete the entire marker at once + editor.handleInput("\x7f"); + assert.strictEqual(editor.getText(), "AB"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 }); + }); + + it("treats paste marker as single unit for forward delete", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.handleInput("A"); + pasteWithMarker(editor); + editor.handleInput("B"); + + // Position cursor on "A" (col 0) then move right once to be just before marker + editor.handleInput("\x01"); // Ctrl+A + editor.handleInput("\x1b[C"); // past "A", now at col 1 (start of marker) + + // Forward delete: should delete the entire marker at once + editor.handleInput("\x1b[3~"); // Delete key + assert.strictEqual(editor.getText(), "AB"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 }); + }); + + it("treats paste marker as single unit for word movement", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.handleInput("X"); + editor.handleInput(" "); + pasteWithMarker(editor); + editor.handleInput(" "); + editor.handleInput("Y"); + // Text: "X [paste #1 +20 lines] Y" + + const text = editor.getText(); + const marker = text.match(/\[paste #\d+ \+\d+ lines\]/)![0]; + + // Go to start + editor.handleInput("\x01"); // Ctrl+A + + // Ctrl+Right: skip "X" + editor.handleInput("\x1b[1;5C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 }); + + // Ctrl+Right: skip whitespace + marker (marker treated as single non-ws, non-punct unit) + editor.handleInput("\x1b[1;5C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 + marker.length }); + }); + + it("undo restores marker after backspace deletion", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + editor.handleInput("A"); + pasteWithMarker(editor); + editor.handleInput("B"); + + const textBefore = editor.getText(); + + // Position after marker + editor.handleInput("\x01"); + editor.handleInput("\x1b[C"); // past A + editor.handleInput("\x1b[C"); // past marker + + // Delete marker + editor.handleInput("\x7f"); + assert.strictEqual(editor.getText(), "AB"); + + // Undo + editor.handleInput("\x1b[45;5u"); + assert.strictEqual(editor.getText(), textBefore); + }); + + it("handles multiple paste markers in same line", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + pasteWithMarker(editor); + editor.handleInput(" "); + pasteWithMarker(editor); + + const text = editor.getText(); + const markers = [...text.matchAll(/\[paste #\d+ \+\d+ lines\]/g)]; + assert.strictEqual(markers.length, 2); + + // Go to start + editor.handleInput("\x01"); + + // Right arrow: should skip first marker atomically + editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: markers[0]![0].length }); + + // Right arrow: past space + editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: markers[0]![0].length + 1 }); + + // Right arrow: should skip second marker atomically + editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { + line: 0, + col: markers[0]![0].length + 1 + markers[1]![0].length, + }); + }); + + it("does not treat manually typed marker-like text as atomic (no valid paste ID)", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + // Type text that matches the pattern but was typed manually (no paste entry) + const fakeMarker = "[paste #99 +5 lines]"; + for (const ch of fakeMarker) editor.handleInput(ch); + + assert.strictEqual(editor.getText(), fakeMarker); + + // No paste with ID 99 exists, so the marker is NOT treated atomically. + // Right arrow should move one grapheme at a time. + editor.handleInput("\x01"); // Ctrl+A + editor.handleInput("\x1b[C"); // Right + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 }); // Just past "[" + }); + + it("does not crash when paste marker is wider than terminal width", () => { + // Reproduce: terminal width 8, paste marker "[paste #1 +47 lines]" (21 chars) + const tui = createTestTUI(); + const editor = new Editor(tui, defaultEditorTheme); + const bigContent = "line\n".repeat(47).trimEnd(); + editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`); + + const text = editor.getText(); + const marker = text.match(/\[paste #\d+ \+\d+ lines\]/); + assert.ok(marker, "paste marker should be created"); + assert.ok(visibleWidth(marker[0]) > 8, "marker should be wider than render width"); + + // Render at very narrow width - should not throw + const lines = editor.render(8); + // Every rendered line must fit within the width (marker is split) + for (const line of lines) { + assert.ok( + visibleWidth(line) <= 8, + `line exceeds width 8: visible=${visibleWidth(line)} text=${JSON.stringify(line)}`, + ); + } + }); + + it("does not crash when text + paste marker exceeds terminal width with cursor on marker", () => { + // Reproduce: terminal width 54, text "b".repeat(35) + "[paste #1 +27 lines]" + "bbbb" + // Cursor lands on the paste marker after word-wrap, causing the rendered line + // to be 55 visible chars (1 over the width). + const tui = createTestTUI(); + const editor = new Editor(tui, defaultEditorTheme); + + // Type 35 'b' characters + for (let i = 0; i < 35; i++) editor.handleInput("b"); + + // Paste 27 lines + const bigContent = "line\n".repeat(27).trimEnd(); + editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`); + + // Type a few more characters + for (let i = 0; i < 4; i++) editor.handleInput("b"); + + // Move cursor left to land on the paste marker + editor.handleInput("\x1b[D"); // past last 'b' + editor.handleInput("\x1b[D"); // past last 'b' + editor.handleInput("\x1b[D"); // past last 'b' + editor.handleInput("\x1b[D"); // past last 'b' + editor.handleInput("\x1b[D"); // now on the paste marker + + // Render at width 54 - should not throw + const renderWidth = 54; + const lines = editor.render(renderWidth); + for (const line of lines) { + assert.ok( + visibleWidth(line) <= renderWidth, + `line exceeds width ${renderWidth}: visible=${visibleWidth(line)} text=${JSON.stringify(line)}`, + ); + } + }); + + it("wordWrapLine re-checks overflow after backtracking to wrap opportunity", () => { + // Reproduce crash #2: " " + "b".repeat(35) + atomic_marker(20 chars) + "bbbb" + // layoutWidth=53. After wrapping at the space, the remaining 35 b's + marker = 55 + // must trigger a second force-break instead of silently overflowing. + const tui = createTestTUI(); + const editor = new Editor(tui, defaultEditorTheme); + + // Type a space, then 35 b's + editor.handleInput(" "); + for (let i = 0; i < 35; i++) editor.handleInput("b"); + + // Paste 27 lines to create marker + const bigContent = "line\n".repeat(27).trimEnd(); + editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`); + + // Type trailing chars + for (let i = 0; i < 4; i++) editor.handleInput("b"); + + // Render at width 54 (contentWidth=54, layoutWidth=53 with paddingX=0) + const renderWidth = 54; + const lines = editor.render(renderWidth); + for (const line of lines) { + assert.ok( + visibleWidth(line) <= renderWidth, + `line exceeds width ${renderWidth}: visible=${visibleWidth(line)} text=${JSON.stringify(line)}`, + ); + } + }); + + it("expands large pasted content literally in getExpandedText", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const pastedText = [ + "line 1", + "line 2", + "line 3", + "line 4", + "line 5", + "line 6", + "line 7", + "line 8", + "line 9", + "line 10", + "tokens $1 $2 $& $$ $` $' end", + ].join("\n"); + + editor.handleInput(`\x1b[200~${pastedText}\x1b[201~`); + + assert.match(editor.getText(), /\[paste #\d+ \+\d+ lines\]/); + assert.strictEqual(editor.getExpandedText(), pastedText); + }); + + it("snaps to the paste marker start when navigating down into it", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Line 0: long enough text to establish a sticky column + editor.setText("12345678901234567890\n\nhello "); + + // Create a large paste to get a marker + const bigContent = "x".repeat(2000); + editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`); + editor.render(80); + + const text = editor.getText(); + const _marker = text.match(/\[paste #\d+ \d+ chars\]/)![0]; + // Line 0: "12345678901234567890" + // Line 1: "" (empty) + // Line 2: "hello [paste #1 2000 chars]" + // marker starts at col 6 + + // Navigate to line 0, col 10 + editor.handleInput("\x1b[A"); // Up to line 1 + editor.handleInput("\x1b[A"); // Up to line 0 + editor.handleInput("\x01"); // Ctrl+A (start of line) + for (let i = 0; i < 10; i++) editor.handleInput("\x1b[C"); // Right 10 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 10 }); + + // Down to empty line + editor.handleInput("\x1b[B"); + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 0 }); + + // Down to paste marker line - sticky col 10 falls inside marker (starts at col 6). + // Cursor should snap to start of marker (col 6), not end (col 6 + marker.length). + editor.handleInput("\x1b[B"); + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 6 }); + }); + + it("preserves sticky column when navigating through paste marker line", () => { + const tui = createTestTUI(30, 24); + const editor = new Editor(tui, defaultEditorTheme); + + // Build: + // Line 0: "1234567890123456" (16 chars) + // Line 1: "" (empty) + // Line 2: "[paste #1 2000 chars]" (22 chars, paste marker) + // Line 3: "" (empty) + // Line 4: "abcdefghijklmnop" (16 chars) + for (const ch of "1234567890123456") editor.handleInput(ch); + editor.handleInput("\n"); + editor.handleInput("\n"); + editor.handleInput(`\x1b[200~${"x".repeat(2000)}\x1b[201~`); + editor.handleInput("\n"); + editor.handleInput("\n"); + for (const ch of "abcdefghijklmnop") editor.handleInput(ch); + editor.render(30); + + // Navigate to line 0, col 10 + for (let i = 0; i < 4; i++) editor.handleInput("\x1b[A"); // Up to line 0 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 10; i++) editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 10 }); + + // Down to empty line - sticky col 10 established + editor.handleInput("\x1b[B"); + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 0 }); + + // Down to paste marker - cursor snapped to col 0 (start of marker) + editor.handleInput("\x1b[B"); + assert.deepStrictEqual(editor.getCursor(), { line: 2, col: 0 }); + + // Down to empty line + editor.handleInput("\x1b[B"); + assert.deepStrictEqual(editor.getCursor(), { line: 3, col: 0 }); + + // Down to last line - should restore sticky col 10 + editor.handleInput("\x1b[B"); + assert.deepStrictEqual(editor.getCursor(), { line: 4, col: 10 }); + }); + + it("does not get stuck moving down from a multi-visual-line paste marker", () => { + const tui = createTestTUI(20, 24); + const editor = new Editor(tui, defaultEditorTheme); + + // Build: + // Logical line 0: "abcdefgh" + marker(21 chars) + "ijklmnopqr" + // Logical line 1: "123456789012345678" + // + // Marker "[paste #1 +100 lines]" (21 chars) is wider than the + // terminal (20). Word-wrap splits at the space before "lines", + // producing: + // VL1: abcdefgh (startCol 0, len 8) + // VL2: [paste #1 +100 (startCol 8, len 15) <- marker head + // VL3: lines]ijklmnopqr (startCol 23, len 16) <- marker tail + content + // VL4: 123456789012345678 (line 1) + // + // On VL3 the marker tail "lines]" occupies visual cols 0-5. + // Content ("i") starts at visual col 6 = logical col 29. + for (const ch of "abcdefgh") editor.handleInput(ch); + const bigContent = "line\n".repeat(100).trimEnd(); + editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`); + for (const ch of "ijklmnopqr") editor.handleInput(ch); + editor.handleInput("\n"); + for (const ch of "123456789012345678") editor.handleInput(ch); + editor.render(20); + + const text = editor.getText(); + const markerMatch = text.match(/\[paste #\d+ \+\d+ lines]/); + assert.ok(markerMatch, "paste marker should be created"); + const markerLen = markerMatch[0].length; // 21 + assert.ok(markerLen > 20, "marker should be wider than terminal"); + const markerStart = 8; + const markerEnd = markerStart + markerLen; // 29 + + // Navigate to line 0, col 6 (on "g"). Preferred col 6 is past the + // marker tail on VL3, so the cursor should land on content ("i" at + // col 29) without snapping back. + editor.handleInput("\x1b[A"); // Up to line 0 + editor.handleInput("\x01"); // Ctrl+A (start of line) + for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C"); // Right to col 6 + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 }); + + // Down: cursor lands on paste marker start + editor.handleInput("\x1b[B"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: markerStart }); + + // Down again: preferred col 6 lands at VL3 col 29 ("i"), which is + // past the marker. Cursor stays on line 0. + editor.handleInput("\x1b[B"); + assert.strictEqual(editor.getCursor().line, 0); + assert.strictEqual(editor.getCursor().col, markerEnd); // col 29 = "i" + + // Up: back to paste marker + editor.handleInput("\x1b[A"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: markerStart }); + + // Up again: back to col 6 ("g") + editor.handleInput("\x1b[A"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 6 }); + }); + + it("skips marker continuation VLs when preferred col falls in marker tail", () => { + const tui = createTestTUI(20, 24); + const editor = new Editor(tui, defaultEditorTheme); + + // Same layout. Start at col 3 ("d"). Preferred col 3 maps to VL3 + // visual col 3 which is inside the "lines]" marker tail. + // moveToVisualLine detects the continuation VL and skips to VL4 + // (line 1). + // VL1: abcdefgh (startCol 0, len 8) + // VL2: [paste #1 +100 (startCol 8, len 15) <- marker head + // VL3: lines]ijklmnopqr (startCol 23, len 16) <- marker tail + content + // VL4: 123456789012345678 (line 1) + for (const ch of "abcdefgh") editor.handleInput(ch); + const bigContent = "line\n".repeat(100).trimEnd(); + editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`); + for (const ch of "ijklmnopqr") editor.handleInput(ch); + editor.handleInput("\n"); + for (const ch of "123456789012345678") editor.handleInput(ch); + editor.render(20); + + // Navigate to line 0, col 3 (on "d") + editor.handleInput("\x1b[A"); // Up to line 0 + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 3; i++) editor.handleInput("\x1b[C"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + + // Down: marker + editor.handleInput("\x1b[B"); + assert.strictEqual(editor.getCursor().col, 8); + + // Down: skips VL3 (col 3 in marker tail) and lands on line 1 + editor.handleInput("\x1b[B"); + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 3 }); + + // Round-trip back + editor.handleInput("\x1b[A"); + assert.strictEqual(editor.getCursor().col, 8); // marker + editor.handleInput("\x1b[A"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + }); + + it("submits large pasted content literally", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + const pastedText = [ + "line 1", + "line 2", + "line 3", + "line 4", + "line 5", + "line 6", + "line 7", + "line 8", + "line 9", + "line 10", + "tokens $1 $2 $& $$ $` $' end", + ].join("\n"); + let submitted = ""; + editor.onSubmit = (text) => { + submitted = text; + }; + + editor.handleInput(`\x1b[200~${pastedText}\x1b[201~`); + editor.handleInput("\r"); + + assert.strictEqual(submitted, pastedText); + }); + }); +}); diff --git a/packages/pi-tui/test/fuzzy.test.ts b/packages/pi-tui/test/fuzzy.test.ts new file mode 100644 index 000000000..af4662afa --- /dev/null +++ b/packages/pi-tui/test/fuzzy.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { fuzzyFilter, fuzzyMatch } from "../src/fuzzy.ts"; + +describe("fuzzyMatch", () => { + it("empty query matches everything with score 0", () => { + const result = fuzzyMatch("", "anything"); + assert.strictEqual(result.matches, true); + assert.strictEqual(result.score, 0); + }); + + it("query longer than text does not match", () => { + const result = fuzzyMatch("longquery", "short"); + assert.strictEqual(result.matches, false); + }); + + it("exact match has good score", () => { + const result = fuzzyMatch("test", "test"); + assert.strictEqual(result.matches, true); + assert.ok(result.score < 0); // Should be negative due to consecutive bonuses + }); + + it("characters must appear in order", () => { + const matchInOrder = fuzzyMatch("abc", "aXbXc"); + assert.strictEqual(matchInOrder.matches, true); + + const matchOutOfOrder = fuzzyMatch("abc", "cba"); + assert.strictEqual(matchOutOfOrder.matches, false); + }); + + it("case insensitive matching", () => { + const result = fuzzyMatch("ABC", "abc"); + assert.strictEqual(result.matches, true); + + const result2 = fuzzyMatch("abc", "ABC"); + assert.strictEqual(result2.matches, true); + }); + + it("consecutive matches score better than scattered matches", () => { + const consecutive = fuzzyMatch("foo", "foobar"); + const scattered = fuzzyMatch("foo", "f_o_o_bar"); + + assert.strictEqual(consecutive.matches, true); + assert.strictEqual(scattered.matches, true); + assert.ok(consecutive.score < scattered.score); + }); + + it("word boundary matches score better", () => { + const atBoundary = fuzzyMatch("fb", "foo-bar"); + const notAtBoundary = fuzzyMatch("fb", "afbx"); + + assert.strictEqual(atBoundary.matches, true); + assert.strictEqual(notAtBoundary.matches, true); + assert.ok(atBoundary.score < notAtBoundary.score); + }); + + it("matches swapped alpha numeric tokens", () => { + const result = fuzzyMatch("codex52", "gpt-5.2-codex"); + assert.strictEqual(result.matches, true); + }); +}); + +describe("fuzzyFilter", () => { + it("empty query returns all items unchanged", () => { + const items = ["apple", "banana", "cherry"]; + const result = fuzzyFilter(items, "", (x: string) => x); + assert.deepStrictEqual(result, items); + }); + + it("filters out non-matching items", () => { + const items = ["apple", "banana", "cherry"]; + const result = fuzzyFilter(items, "an", (x: string) => x); + assert.ok(result.includes("banana")); + assert.ok(!result.includes("apple")); + assert.ok(!result.includes("cherry")); + }); + + it("sorts results by match quality", () => { + const items = ["a_p_p", "app", "application"]; + const result = fuzzyFilter(items, "app", (x: string) => x); + + // "app" should be first (exact consecutive match at start) + assert.strictEqual(result[0], "app"); + }); + + it("prioritizes exact matches over longer prefix matches", () => { + const items = ["clone", "cl"]; + const result = fuzzyFilter(items, "cl", (x: string) => x); + + assert.deepStrictEqual(result, ["cl", "clone"]); + }); + + it("works with custom getText function", () => { + const items = [ + { name: "foo", id: 1 }, + { name: "bar", id: 2 }, + { name: "foobar", id: 3 }, + ]; + const result = fuzzyFilter(items, "foo", (item: { name: string; id: number }) => item.name); + + assert.strictEqual(result.length, 2); + assert.ok(result.map((r) => r.name).includes("foo")); + assert.ok(result.map((r) => r.name).includes("foobar")); + }); + + it("matches slash-separated provider/model queries against reordered text", () => { + const item = { id: "gpt-5.5", provider: "openai-codex" }; + const result = fuzzyFilter([item], "openai-codex/gpt-5.5", (model) => `${model.id} ${model.provider}`); + + assert.deepStrictEqual(result, [item]); + }); +}); diff --git a/packages/pi-tui/test/image-test.ts b/packages/pi-tui/test/image-test.ts new file mode 100644 index 000000000..116c97d02 --- /dev/null +++ b/packages/pi-tui/test/image-test.ts @@ -0,0 +1,56 @@ +import { readFileSync } from "fs"; +import { Image } from "../src/components/image.ts"; +import { Spacer } from "../src/components/spacer.ts"; +import { Text } from "../src/components/text.ts"; +import { ProcessTerminal } from "../src/terminal.ts"; +import { getCapabilities, getImageDimensions } from "../src/terminal-image.ts"; +import { TUI } from "../src/tui.ts"; + +const testImagePath = process.argv[2] || "/tmp/test-image.png"; + +console.log("Terminal capabilities:", getCapabilities()); +console.log("Loading image from:", testImagePath); + +let imageBuffer: Buffer; +try { + imageBuffer = readFileSync(testImagePath); +} catch (_e) { + console.error(`Failed to load image: ${testImagePath}`); + console.error("Usage: npx tsx test/image-test.ts [path-to-image.png]"); + process.exit(1); +} + +const base64Data = imageBuffer.toString("base64"); +const dims = getImageDimensions(base64Data, "image/png"); + +console.log("Image dimensions:", dims); +console.log(""); + +const terminal = new ProcessTerminal(); +const tui = new TUI(terminal); + +tui.addChild(new Text("Image Rendering Test", 1, 1)); +tui.addChild(new Spacer(1)); + +if (dims) { + tui.addChild( + new Image(base64Data, "image/png", { fallbackColor: (s) => `\x1b[33m${s}\x1b[0m` }, { maxWidthCells: 60 }, dims), + ); +} else { + tui.addChild(new Text("Could not parse image dimensions", 1, 0)); +} + +tui.addChild(new Spacer(1)); +tui.addChild(new Text("Press Ctrl+C to exit", 1, 0)); + +const editor = { + handleInput(data: string) { + if (data.charCodeAt(0) === 3) { + tui.stop(); + process.exit(0); + } + }, +}; + +tui.setFocus(editor as any); +tui.start(); diff --git a/packages/pi-tui/test/input.test.ts b/packages/pi-tui/test/input.test.ts new file mode 100644 index 000000000..5b83ec71d --- /dev/null +++ b/packages/pi-tui/test/input.test.ts @@ -0,0 +1,647 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { Input } from "../src/components/input.ts"; +import { visibleWidth } from "../src/utils.ts"; + +describe("Input component", () => { + it("submits value including backslash on Enter", () => { + const input = new Input(); + let submitted: string | undefined; + + input.onSubmit = (value) => { + submitted = value; + }; + + // Type hello, then backslash, then Enter + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput("\\"); + input.handleInput("\r"); + + // Input is single-line, no backslash+Enter workaround + assert.strictEqual(submitted, "hello\\"); + }); + + it("inserts backslash as regular character", () => { + const input = new Input(); + + input.handleInput("\\"); + input.handleInput("x"); + + assert.strictEqual(input.getValue(), "\\x"); + }); + + describe("render", () => { + it("does not overflow with wide CJK and fullwidth text", () => { + const width = 93; + const cases = [ + "가나다라마바사아자차카타파하 한글 텍스트가 터미널 너비를 초과하면 크래시가 발생합니다 이것은 재현용 테스트입니다", + "これはテスト文章です。日本語のテキストが正しく表示されるかどうかを確認するためのサンプルテキストです。あいうえお", + "这是一段测试文本,用于验证中文字符在终端中的显示宽度是否被正确计算,如果不正确就会导致用户界面崩溃的问题", + "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklm", + ]; + const cursorPositions = [ + { label: "start", move: (_input: Input) => {} }, + { + label: "middle", + move: (input: Input) => { + for (let i = 0; i < 10; i++) input.handleInput("\x1b[C"); + }, + }, + { label: "end", move: (input: Input) => input.handleInput("\x05") }, + ]; + + for (const text of cases) { + for (const { label, move } of cursorPositions) { + const input = new Input(); + input.setValue(text); + input.focused = true; + move(input); + + const [line] = input.render(width); + assert.ok(line); + assert.ok(visibleWidth(line) <= width, `rendered line overflowed for ${text} at ${label}`); + } + } + }); + + it("keeps the cursor visible when horizontally scrolling wide text", () => { + const input = new Input(); + const width = 20; + const text = "가나다라마바사아자차카타파하"; + input.setValue(text); + input.focused = true; + input.handleInput("\x01"); + for (let i = 0; i < 5; i++) input.handleInput("\x1b[C"); + + const [line] = input.render(width); + assert.ok(line); + assert.ok(visibleWidth(line) <= width); + }); + }); + + describe("Kill ring", () => { + it("Ctrl+W saves deleted text to kill ring and Ctrl+Y yanks it", () => { + const input = new Input(); + + input.setValue("foo bar baz"); + // Move cursor to end + input.handleInput("\x05"); // Ctrl+E + + input.handleInput("\x17"); // Ctrl+W - deletes "baz" + assert.strictEqual(input.getValue(), "foo bar "); + + // Move to beginning and yank + input.handleInput("\x01"); // Ctrl+A + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "bazfoo bar "); + }); + + it("Ctrl+W preserves ASCII punctuation boundaries", () => { + const input = new Input(); + + input.setValue("foo.bar"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "bar" + assert.strictEqual(input.getValue(), "foo."); + + input.setValue("foo:bar"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "bar" + assert.strictEqual(input.getValue(), "foo:"); + }); + + it("Ctrl+W handles Unicode word boundaries", () => { + const input = new Input(); + + // "你好世界。你好,世界" segments as: 你好|世界|。|你好|,|世界 + input.setValue("你好世界。你好,世界"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "世界" + assert.strictEqual(input.getValue(), "你好世界。你好,"); + input.handleInput("\x17"); // Ctrl+W - deletes "," + assert.strictEqual(input.getValue(), "你好世界。你好"); + input.handleInput("\x17"); // Ctrl+W - deletes "你好" + assert.strictEqual(input.getValue(), "你好世界。"); + input.handleInput("\x17"); // Ctrl+W - deletes "。" + assert.strictEqual(input.getValue(), "你好世界"); + input.handleInput("\x17"); // Ctrl+W - deletes "世界" + assert.strictEqual(input.getValue(), "你好"); + input.handleInput("\x17"); // Ctrl+W - deletes "你好" + assert.strictEqual(input.getValue(), ""); + }); + + it("Ctrl+U saves deleted text to kill ring", () => { + const input = new Input(); + + input.setValue("hello world"); + // Move cursor to after "hello " + input.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); + + input.handleInput("\x15"); // Ctrl+U - deletes "hello " + assert.strictEqual(input.getValue(), "world"); + + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "hello world"); + }); + + it("Ctrl+K saves deleted text to kill ring", () => { + const input = new Input(); + + input.setValue("hello world"); + input.handleInput("\x01"); // Ctrl+A + input.handleInput("\x0b"); // Ctrl+K - deletes "hello world" + + assert.strictEqual(input.getValue(), ""); + + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "hello world"); + }); + + it("Ctrl+Y does nothing when kill ring is empty", () => { + const input = new Input(); + + input.setValue("test"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "test"); + }); + + it("Alt+Y cycles through kill ring after Ctrl+Y", () => { + const input = new Input(); + + // Create kill ring with multiple entries + input.setValue("first"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "first" + input.setValue("second"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "second" + input.setValue("third"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "third" + + assert.strictEqual(input.getValue(), ""); + + input.handleInput("\x19"); // Ctrl+Y - yanks "third" + assert.strictEqual(input.getValue(), "third"); + + input.handleInput("\x1by"); // Alt+Y - cycles to "second" + assert.strictEqual(input.getValue(), "second"); + + input.handleInput("\x1by"); // Alt+Y - cycles to "first" + assert.strictEqual(input.getValue(), "first"); + + input.handleInput("\x1by"); // Alt+Y - cycles back to "third" + assert.strictEqual(input.getValue(), "third"); + }); + + it("Alt+Y does nothing if not preceded by yank", () => { + const input = new Input(); + + input.setValue("test"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "test" + input.setValue("other"); + input.handleInput("\x05"); // Ctrl+E + + // Type something to break the yank chain + input.handleInput("x"); + assert.strictEqual(input.getValue(), "otherx"); + + input.handleInput("\x1by"); // Alt+Y - should do nothing + assert.strictEqual(input.getValue(), "otherx"); + }); + + it("Alt+Y does nothing if kill ring has one entry", () => { + const input = new Input(); + + input.setValue("only"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "only" + + input.handleInput("\x19"); // Ctrl+Y - yanks "only" + assert.strictEqual(input.getValue(), "only"); + + input.handleInput("\x1by"); // Alt+Y - should do nothing + assert.strictEqual(input.getValue(), "only"); + }); + + it("consecutive Ctrl+W accumulates into one kill ring entry", () => { + const input = new Input(); + + input.setValue("one two three"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "three" + input.handleInput("\x17"); // Ctrl+W - deletes "two " + input.handleInput("\x17"); // Ctrl+W - deletes "one " + + assert.strictEqual(input.getValue(), ""); + + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "one two three"); + }); + + it("non-delete actions break kill accumulation", () => { + const input = new Input(); + + input.setValue("foo bar baz"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "baz" + assert.strictEqual(input.getValue(), "foo bar "); + + input.handleInput("x"); // Typing breaks accumulation + assert.strictEqual(input.getValue(), "foo bar x"); + + input.handleInput("\x17"); // Ctrl+W - deletes "x" (separate entry) + assert.strictEqual(input.getValue(), "foo bar "); + + input.handleInput("\x19"); // Ctrl+Y - most recent is "x" + assert.strictEqual(input.getValue(), "foo bar x"); + + input.handleInput("\x1by"); // Alt+Y - cycle to "baz" + assert.strictEqual(input.getValue(), "foo bar baz"); + }); + + it("non-yank actions break Alt+Y chain", () => { + const input = new Input(); + + input.setValue("first"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W + input.setValue("second"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W + input.setValue(""); + + input.handleInput("\x19"); // Ctrl+Y - yanks "second" + assert.strictEqual(input.getValue(), "second"); + + input.handleInput("x"); // Breaks yank chain + assert.strictEqual(input.getValue(), "secondx"); + + input.handleInput("\x1by"); // Alt+Y - should do nothing + assert.strictEqual(input.getValue(), "secondx"); + }); + + it("kill ring rotation persists after cycling", () => { + const input = new Input(); + + input.setValue("first"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // deletes "first" + input.setValue("second"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // deletes "second" + input.setValue("third"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // deletes "third" + input.setValue(""); + + input.handleInput("\x19"); // Ctrl+Y - yanks "third" + input.handleInput("\x1by"); // Alt+Y - cycles to "second" + assert.strictEqual(input.getValue(), "second"); + + // Break chain and start fresh + input.handleInput("x"); + input.setValue(""); + + // New yank should get "second" (now at end after rotation) + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "second"); + }); + + it("backward deletions prepend, forward deletions append during accumulation", () => { + const input = new Input(); + + input.setValue("prefix|suffix"); + // Position cursor at "|" + input.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); // Move right 6 + + input.handleInput("\x0b"); // Ctrl+K - deletes "|suffix" (forward) + assert.strictEqual(input.getValue(), "prefix"); + + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "prefix|suffix"); + }); + + it("Alt+D deletes word forward and saves to kill ring", () => { + const input = new Input(); + + input.setValue("hello world test"); + input.handleInput("\x01"); // Ctrl+A + + input.handleInput("\x1bd"); // Alt+D - deletes "hello" + assert.strictEqual(input.getValue(), " world test"); + + input.handleInput("\x1bd"); // Alt+D - deletes " world" + assert.strictEqual(input.getValue(), " test"); + + // Yank should get accumulated text + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "hello world test"); + }); + + it("Alt+D preserves ASCII punctuation boundaries", () => { + const input = new Input(); + + input.setValue("foo.bar baz"); + input.handleInput("\x01"); // Ctrl+A + input.handleInput("\x1bd"); // Alt+D - deletes "foo" + assert.strictEqual(input.getValue(), ".bar baz"); + input.handleInput("\x1bd"); // Alt+D - deletes "." + assert.strictEqual(input.getValue(), "bar baz"); + input.handleInput("\x1bd"); // Alt+D - deletes "bar" + assert.strictEqual(input.getValue(), " baz"); + }); + + it("Alt+D handles Unicode word boundaries", () => { + const input = new Input(); + + // "你好世界。你好,世界" segments as: 你好|世界|。|你好|,|世界 + input.setValue("你好世界。你好,世界"); + input.handleInput("\x01"); // Ctrl+A + input.handleInput("\x1bd"); // Alt+D - deletes "你好" + assert.strictEqual(input.getValue(), "世界。你好,世界"); + input.handleInput("\x1bd"); // Alt+D - deletes "世界" + assert.strictEqual(input.getValue(), "。你好,世界"); + input.handleInput("\x1bd"); // Alt+D - deletes "。" + assert.strictEqual(input.getValue(), "你好,世界"); + input.handleInput("\x1bd"); // Alt+D - deletes "你好" + assert.strictEqual(input.getValue(), ",世界"); + input.handleInput("\x1bd"); // Alt+D - deletes "," + assert.strictEqual(input.getValue(), "世界"); + input.handleInput("\x1bd"); // Alt+D - deletes "世界" + assert.strictEqual(input.getValue(), ""); + }); + + it("handles yank in middle of text", () => { + const input = new Input(); + + input.setValue("word"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "word" + input.setValue("hello world"); + // Move to middle (after "hello ") + input.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); + + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "hello wordworld"); + }); + + it("handles yank-pop in middle of text", () => { + const input = new Input(); + + // Create two kill ring entries + input.setValue("FIRST"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "FIRST" + input.setValue("SECOND"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "SECOND" + + // Set up "hello world" and position cursor after "hello " + input.setValue("hello world"); + input.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); + + input.handleInput("\x19"); // Ctrl+Y - yanks "SECOND" + assert.strictEqual(input.getValue(), "hello SECONDworld"); + + input.handleInput("\x1by"); // Alt+Y - replaces with "FIRST" + assert.strictEqual(input.getValue(), "hello FIRSTworld"); + }); + }); + + describe("Undo", () => { + it("does nothing when undo stack is empty", () => { + const input = new Input(); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), ""); + }); + + it("coalesces consecutive word characters into one undo unit", () => { + const input = new Input(); + + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput(" "); + input.handleInput("w"); + input.handleInput("o"); + input.handleInput("r"); + input.handleInput("l"); + input.handleInput("d"); + assert.strictEqual(input.getValue(), "hello world"); + + // Undo removes " world" + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "hello"); + + // Undo removes "hello" + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), ""); + }); + + it("undoes spaces one at a time", () => { + const input = new Input(); + + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput(" "); + input.handleInput(" "); + assert.strictEqual(input.getValue(), "hello "); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes second " " + assert.strictEqual(input.getValue(), "hello "); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes first " " + assert.strictEqual(input.getValue(), "hello"); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes "hello" + assert.strictEqual(input.getValue(), ""); + }); + + it("undoes backspace", () => { + const input = new Input(); + + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput("\x7f"); // Backspace + assert.strictEqual(input.getValue(), "hell"); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "hello"); + }); + + it("undoes forward delete", () => { + const input = new Input(); + + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput("\x01"); // Ctrl+A - go to start + input.handleInput("\x1b[C"); // Right arrow + input.handleInput("\x1b[3~"); // Delete key + assert.strictEqual(input.getValue(), "hllo"); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "hello"); + }); + + it("undoes Ctrl+W (delete word backward)", () => { + const input = new Input(); + + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput(" "); + input.handleInput("w"); + input.handleInput("o"); + input.handleInput("r"); + input.handleInput("l"); + input.handleInput("d"); + assert.strictEqual(input.getValue(), "hello world"); + + input.handleInput("\x17"); // Ctrl+W + assert.strictEqual(input.getValue(), "hello "); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "hello world"); + }); + + it("undoes Ctrl+K (delete to line end)", () => { + const input = new Input(); + + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput(" "); + input.handleInput("w"); + input.handleInput("o"); + input.handleInput("r"); + input.handleInput("l"); + input.handleInput("d"); + input.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); + + input.handleInput("\x0b"); // Ctrl+K + assert.strictEqual(input.getValue(), "hello "); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "hello world"); + }); + + it("undoes Ctrl+U (delete to line start)", () => { + const input = new Input(); + + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput(" "); + input.handleInput("w"); + input.handleInput("o"); + input.handleInput("r"); + input.handleInput("l"); + input.handleInput("d"); + input.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); + + input.handleInput("\x15"); // Ctrl+U + assert.strictEqual(input.getValue(), "world"); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "hello world"); + }); + + it("undoes yank", () => { + const input = new Input(); + + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput(" "); + input.handleInput("\x17"); // Ctrl+W - delete "hello " + input.handleInput("\x19"); // Ctrl+Y - yank + assert.strictEqual(input.getValue(), "hello "); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), ""); + }); + + it("undoes paste atomically", () => { + const input = new Input(); + + input.setValue("hello world"); + input.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 5; i++) input.handleInput("\x1b[C"); + + // Simulate bracketed paste + input.handleInput("\x1b[200~beep boop\x1b[201~"); + assert.strictEqual(input.getValue(), "hellobeep boop world"); + + // Single undo should restore entire pre-paste state + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "hello world"); + }); + + it("undoes Alt+D (delete word forward)", () => { + const input = new Input(); + + input.setValue("hello world"); + input.handleInput("\x01"); // Ctrl+A + + input.handleInput("\x1bd"); // Alt+D - deletes "hello" + assert.strictEqual(input.getValue(), " world"); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "hello world"); + }); + + it("cursor movement starts new undo unit", () => { + const input = new Input(); + + input.handleInput("a"); + input.handleInput("b"); + input.handleInput("c"); + input.handleInput("\x01"); // Ctrl+A - movement breaks coalescing + input.handleInput("\x05"); // Ctrl+E + input.handleInput("d"); + input.handleInput("e"); + assert.strictEqual(input.getValue(), "abcde"); + + // Undo removes "de" (typed after movement) + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "abc"); + + // Undo removes "abc" + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), ""); + }); + }); +}); diff --git a/packages/pi-tui/test/key-tester.ts b/packages/pi-tui/test/key-tester.ts new file mode 100755 index 000000000..dce060d4f --- /dev/null +++ b/packages/pi-tui/test/key-tester.ts @@ -0,0 +1,123 @@ +#!/usr/bin/env node +import { matchesKey } from "../src/keys.ts"; +import { ProcessTerminal } from "../src/terminal.ts"; +import { type Component, TUI } from "../src/tui.ts"; +import { truncateToWidth } from "../src/utils.ts"; + +/** + * Simple key code logger component + */ +class KeyLogger implements Component { + private log: string[] = []; + private maxLines = 20; + private tui: TUI; + private terminal: ProcessTerminal; + + constructor(tui: TUI, terminal: ProcessTerminal) { + this.tui = tui; + this.terminal = terminal; + } + + handleInput(data: string): void { + // Handle Ctrl+C (raw or Kitty protocol) for exit + if (matchesKey(data, "ctrl+c")) { + this.tui.stop(); + console.log("\nExiting..."); + process.exit(0); + } + + // Convert to various representations + const hex = Buffer.from(data).toString("hex"); + const charCodes = Array.from(data) + .map((c) => c.charCodeAt(0)) + .join(", "); + const repr = data + .replace(/\x1b/g, "\\x1b") + .replace(/\r/g, "\\r") + .replace(/\n/g, "\\n") + .replace(/\t/g, "\\t") + .replace(/\x7f/g, "\\x7f"); + + const logLine = `Hex: ${hex.padEnd(20)} | Chars: [${charCodes.padEnd(15)}] | Repr: "${repr}"`; + + this.log.push(logLine); + + // Keep only last N lines + if (this.log.length > this.maxLines) { + this.log.shift(); + } + + // Request re-render to show the new log entry + this.tui.requestRender(); + } + + invalidate(): void { + // No cached state to invalidate currently + } + + private protocolName(): string { + if (this.terminal.kittyProtocolActive) return "kitty"; + if (this.terminal.modifyOtherKeysActive) return "modifyOtherKeys"; + return "legacy"; + } + + private fit(line: string, width: number): string { + return truncateToWidth(line, width).padEnd(width); + } + + render(width: number): string[] { + const lines: string[] = []; + + // Title + lines.push("=".repeat(width)); + lines.push(this.fit("Key Code Tester - Press keys to see their codes (Ctrl+C to exit)", width)); + lines.push(this.fit(`Protocol: ${this.protocolName()}`, width)); + lines.push("=".repeat(width)); + lines.push(""); + + // Log entries + for (const entry of this.log) { + lines.push(this.fit(entry, width)); + } + + // Fill remaining space + const remaining = Math.max(0, 25 - lines.length); + for (let i = 0; i < remaining; i++) { + lines.push("".padEnd(width)); + } + + // Footer + lines.push("=".repeat(width)); + lines.push(this.fit("Test these:", width)); + lines.push(this.fit(" - Shift + Enter (should show: \\x1b[13;2u with Kitty protocol)", width)); + lines.push(this.fit(" - Alt/Option + Enter", width)); + lines.push(this.fit(" - Option/Alt + Backspace", width)); + lines.push(this.fit(" - Cmd/Ctrl + Backspace", width)); + lines.push(this.fit(" - Regular Backspace", width)); + lines.push("=".repeat(width)); + + return lines; + } +} + +// Set up TUI +const terminal = new ProcessTerminal(); +const tui = new TUI(terminal); +const logger = new KeyLogger(tui, terminal); + +tui.addChild(logger); +tui.setFocus(logger); + +// Handle Ctrl+C for clean exit (SIGINT still works for raw mode) +process.on("SIGINT", () => { + tui.stop(); + console.log("\nExiting..."); + process.exit(0); +}); + +// Start the TUI +tui.start(); + +// Protocol negotiation completes asynchronously after the first render. +// Refresh briefly/continuously so the displayed protocol state is not stale. +setInterval(() => tui.requestRender(), 100); diff --git a/packages/pi-tui/test/keybindings.test.ts b/packages/pi-tui/test/keybindings.test.ts new file mode 100644 index 000000000..8bd7f4387 --- /dev/null +++ b/packages/pi-tui/test/keybindings.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { KeybindingsManager, TUI_KEYBINDINGS } from "../src/keybindings.ts"; + +describe("KeybindingsManager", () => { + it("binds Ctrl+J as a default newline alias", () => { + const keybindings = new KeybindingsManager(TUI_KEYBINDINGS); + + assert.deepStrictEqual(keybindings.getKeys("tui.input.newLine"), ["shift+enter", "ctrl+j"]); + assert.strictEqual(keybindings.matches("\n", "tui.input.newLine"), true); + assert.strictEqual(keybindings.matches("\x1b[106;5u", "tui.input.newLine"), true); + }); + + it("does not evict selector confirm when input submit is rebound", () => { + const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.input.submit": ["enter", "ctrl+enter"], + }); + + assert.deepStrictEqual(keybindings.getKeys("tui.input.submit"), ["enter", "ctrl+enter"]); + assert.deepStrictEqual(keybindings.getKeys("tui.select.confirm"), ["enter"]); + }); + + it("does not evict cursor bindings when another action reuses the same key", () => { + const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.select.up": ["up", "ctrl+p"], + }); + + assert.deepStrictEqual(keybindings.getKeys("tui.select.up"), ["up", "ctrl+p"]); + assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorUp"), ["up"]); + }); + + it("still reports direct user binding conflicts without evicting defaults", () => { + const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.input.submit": "ctrl+x", + "tui.select.confirm": "ctrl+x", + }); + + assert.deepStrictEqual(keybindings.getConflicts(), [ + { + key: "ctrl+x", + keybindings: ["tui.input.submit", "tui.select.confirm"], + }, + ]); + assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLeft"), ["left", "ctrl+b"]); + }); +}); diff --git a/packages/pi-tui/test/keys.test.ts b/packages/pi-tui/test/keys.test.ts new file mode 100644 index 000000000..e06844e40 --- /dev/null +++ b/packages/pi-tui/test/keys.test.ts @@ -0,0 +1,614 @@ +/** + * Tests for keyboard input handling + */ + +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { + decodeKittyPrintable, + decodePrintableKey, + Key, + matchesKey, + parseKey, + setKittyProtocolActive, +} from "../src/keys.ts"; + +function withEnv(name: string, value: string | undefined, fn: () => void): void { + const previous = process.env[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + try { + fn(); + } finally { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + } +} + +function withEnvVars(vars: Record, fn: () => void): void { + const entries = Object.entries(vars); + const run = (index: number): void => { + if (index >= entries.length) { + fn(); + return; + } + const [name, value] = entries[index]!; + withEnv(name, value, () => run(index + 1)); + }; + run(0); +} + +describe("matchesKey", () => { + describe("Kitty protocol with alternate keys (non-Latin layouts)", () => { + // Kitty protocol flag 4 (Report alternate keys) sends: + // CSI codepoint:shifted:base ; modifier:event u + // Where base is the key in standard PC-101 layout + + it("should match Ctrl+c when pressing Ctrl+С (Cyrillic) with base layout key", () => { + setKittyProtocolActive(true); + // Cyrillic 'с' = codepoint 1089, Latin 'c' = codepoint 99 + // Format: CSI 1089::99;5u (codepoint::base;modifier with ctrl=4, +1=5) + const cyrillicCtrlC = "\x1b[1089::99;5u"; + assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+c"), true); + setKittyProtocolActive(false); + }); + + it("should match Ctrl+d when pressing Ctrl+В (Cyrillic) with base layout key", () => { + setKittyProtocolActive(true); + // Cyrillic 'в' = codepoint 1074, Latin 'd' = codepoint 100 + const cyrillicCtrlD = "\x1b[1074::100;5u"; + assert.strictEqual(matchesKey(cyrillicCtrlD, "ctrl+d"), true); + setKittyProtocolActive(false); + }); + + it("should match Ctrl+z when pressing Ctrl+Я (Cyrillic) with base layout key", () => { + setKittyProtocolActive(true); + // Cyrillic 'я' = codepoint 1103, Latin 'z' = codepoint 122 + const cyrillicCtrlZ = "\x1b[1103::122;5u"; + assert.strictEqual(matchesKey(cyrillicCtrlZ, "ctrl+z"), true); + setKittyProtocolActive(false); + }); + + it("should match Ctrl+Shift+p with base layout key", () => { + setKittyProtocolActive(true); + // Cyrillic 'з' = codepoint 1079, Latin 'p' = codepoint 112 + // ctrl=4, shift=1, +1 = 6 + const cyrillicCtrlShiftP = "\x1b[1079::112;6u"; + assert.strictEqual(matchesKey(cyrillicCtrlShiftP, "ctrl+shift+p"), true); + setKittyProtocolActive(false); + }); + + it("should still match direct codepoint when no base layout key", () => { + setKittyProtocolActive(true); + // Latin ctrl+c without base layout key (terminal doesn't support flag 4) + const latinCtrlC = "\x1b[99;5u"; + assert.strictEqual(matchesKey(latinCtrlC, "ctrl+c"), true); + setKittyProtocolActive(false); + }); + + it("should match super-modified Kitty bindings, including combined modifiers", () => { + setKittyProtocolActive(true); + assert.strictEqual(matchesKey("\x1b[107;9u", "super+k"), true); + assert.strictEqual(matchesKey("\x1b[13;9u", "super+enter"), true); + assert.strictEqual(matchesKey("\x1b[107;13u", Key.ctrlSuper("k")), true); + assert.strictEqual(matchesKey("\x1b[107;13u", "ctrl+super+k"), true); + assert.strictEqual(matchesKey("\x1b[107;14u", "ctrl+shift+super+k"), true); + assert.strictEqual(matchesKey("\x1b[107;13u", "super+k"), false); + assert.strictEqual(parseKey("\x1b[107;9u"), "super+k"); + assert.strictEqual(parseKey("\x1b[13;9u"), "super+enter"); + assert.strictEqual(parseKey("\x1b[107;13u"), "ctrl+super+k"); + assert.strictEqual(parseKey("\x1b[107;14u"), "shift+ctrl+super+k"); + setKittyProtocolActive(false); + }); + + it("should match digit bindings via Kitty CSI-u", () => { + setKittyProtocolActive(true); + assert.strictEqual(matchesKey("\x1b[49u", "1"), true); + assert.strictEqual(matchesKey("\x1b[49;5u", "ctrl+1"), true); + assert.strictEqual(matchesKey("\x1b[49;5u", "ctrl+2"), false); + assert.strictEqual(parseKey("\x1b[49u"), "1"); + assert.strictEqual(parseKey("\x1b[49;5u"), "ctrl+1"); + setKittyProtocolActive(false); + }); + + it("should normalize Kitty keypad functional keys to logical digits, symbols, and navigation", () => { + setKittyProtocolActive(true); + assert.strictEqual(matchesKey("\x1b[57400u", "1"), true); + assert.strictEqual(matchesKey("\x1b[57410u", "/"), true); + assert.strictEqual(matchesKey("\x1b[57417u", "left"), true); + assert.strictEqual(matchesKey("\x1b[57426u", "delete"), true); + assert.strictEqual(parseKey("\x1b[57399u"), "0"); + assert.strictEqual(parseKey("\x1b[57409u"), "."); + assert.strictEqual(parseKey("\x1b[57413u"), "+"); + assert.strictEqual(parseKey("\x1b[57416u"), ","); + assert.strictEqual(parseKey("\x1b[57417u"), "left"); + assert.strictEqual(parseKey("\x1b[57418u"), "right"); + assert.strictEqual(parseKey("\x1b[57419u"), "up"); + assert.strictEqual(parseKey("\x1b[57420u"), "down"); + assert.strictEqual(parseKey("\x1b[57421u"), "pageUp"); + assert.strictEqual(parseKey("\x1b[57422u"), "pageDown"); + assert.strictEqual(parseKey("\x1b[57423u"), "home"); + assert.strictEqual(parseKey("\x1b[57424u"), "end"); + assert.strictEqual(parseKey("\x1b[57425u"), "insert"); + assert.strictEqual(parseKey("\x1b[57426u"), "delete"); + setKittyProtocolActive(false); + }); + + it("should handle shifted key in format", () => { + setKittyProtocolActive(true); + // Format with shifted key: CSI codepoint:shifted:base;modifier u + // Latin 'c' with shifted 'C' (67) and base 'c' (99) + const shiftedKey = "\x1b[99:67:99;2u"; // shift modifier = 1, +1 = 2 + assert.strictEqual(matchesKey(shiftedKey, "shift+c"), true); + setKittyProtocolActive(false); + }); + + it("should handle event type in format", () => { + setKittyProtocolActive(true); + // Format with event type: CSI codepoint::base;modifier:event u + // Cyrillic ctrl+c release event (event type 3) + const releaseEvent = "\x1b[1089::99;5:3u"; + assert.strictEqual(matchesKey(releaseEvent, "ctrl+c"), true); + setKittyProtocolActive(false); + }); + + it("should handle full format with shifted key, base key, and event type", () => { + setKittyProtocolActive(true); + // Full format: CSI codepoint:shifted:base;modifier:event u + // Cyrillic 'С' (shifted) with base 'c', Ctrl+Shift pressed, repeat event + // Cyrillic 'с' = 1089, Cyrillic 'С' = 1057, Latin 'c' = 99 + // ctrl=4, shift=1, +1 = 6, repeat event = 2 + const fullFormat = "\x1b[1089:1057:99;6:2u"; + assert.strictEqual(matchesKey(fullFormat, "ctrl+shift+c"), true); + setKittyProtocolActive(false); + }); + + it("should prefer codepoint for Latin letters even when base layout differs", () => { + setKittyProtocolActive(true); + // Dvorak Ctrl+K reports codepoint 'k' (107) and base layout 'v' (118) + const dvorakCtrlK = "\x1b[107::118;5u"; + assert.strictEqual(matchesKey(dvorakCtrlK, "ctrl+k"), true); + assert.strictEqual(matchesKey(dvorakCtrlK, "ctrl+v"), false); + setKittyProtocolActive(false); + }); + + it("should prefer codepoint for symbol keys even when base layout differs", () => { + setKittyProtocolActive(true); + // Dvorak Ctrl+/ reports codepoint '/' (47) and base layout '[' (91) + const dvorakCtrlSlash = "\x1b[47::91;5u"; + assert.strictEqual(matchesKey(dvorakCtrlSlash, "ctrl+/"), true); + assert.strictEqual(matchesKey(dvorakCtrlSlash, "ctrl+["), false); + setKittyProtocolActive(false); + }); + + it("should not match wrong key even with base layout", () => { + setKittyProtocolActive(true); + // Cyrillic ctrl+с with base 'c' should NOT match ctrl+d + const cyrillicCtrlC = "\x1b[1089::99;5u"; + assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+d"), false); + setKittyProtocolActive(false); + }); + + it("should not match wrong modifiers even with base layout", () => { + setKittyProtocolActive(true); + // Cyrillic ctrl+с should NOT match ctrl+shift+c + const cyrillicCtrlC = "\x1b[1089::99;5u"; + assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+shift+c"), false); + setKittyProtocolActive(false); + }); + }); + + describe("modifyOtherKeys matching", () => { + it("should match xterm modifyOtherKeys Ctrl+c", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;5;99~", "ctrl+c"), true); + assert.strictEqual(parseKey("\x1b[27;5;99~"), "ctrl+c"); + }); + + it("should match xterm modifyOtherKeys Ctrl+d", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;5;100~", "ctrl+d"), true); + assert.strictEqual(parseKey("\x1b[27;5;100~"), "ctrl+d"); + }); + + it("should match xterm modifyOtherKeys Ctrl+z", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;5;122~", "ctrl+z"), true); + assert.strictEqual(parseKey("\x1b[27;5;122~"), "ctrl+z"); + }); + + it("should match xterm modifyOtherKeys Enter variants", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;5;13~", "ctrl+enter"), true); + assert.strictEqual(matchesKey("\x1b[27;2;13~", "shift+enter"), true); + assert.strictEqual(matchesKey("\x1b[27;3;13~", "alt+enter"), true); + assert.strictEqual(parseKey("\x1b[27;5;13~"), "ctrl+enter"); + assert.strictEqual(parseKey("\x1b[27;2;13~"), "shift+enter"); + assert.strictEqual(parseKey("\x1b[27;3;13~"), "alt+enter"); + }); + + it("should match xterm modifyOtherKeys Tab variants", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;2;9~", "shift+tab"), true); + assert.strictEqual(matchesKey("\x1b[27;5;9~", "ctrl+tab"), true); + assert.strictEqual(matchesKey("\x1b[27;3;9~", "alt+tab"), true); + assert.strictEqual(parseKey("\x1b[27;2;9~"), "shift+tab"); + assert.strictEqual(parseKey("\x1b[27;5;9~"), "ctrl+tab"); + assert.strictEqual(parseKey("\x1b[27;3;9~"), "alt+tab"); + }); + + it("should match xterm modifyOtherKeys Backspace variants", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;1;127~", "backspace"), true); + assert.strictEqual(matchesKey("\x1b[27;5;127~", "ctrl+backspace"), true); + assert.strictEqual(matchesKey("\x1b[27;3;127~", "alt+backspace"), true); + assert.strictEqual(parseKey("\x1b[27;1;127~"), "backspace"); + assert.strictEqual(parseKey("\x1b[27;5;127~"), "ctrl+backspace"); + assert.strictEqual(parseKey("\x1b[27;3;127~"), "alt+backspace"); + }); + + it("should match xterm modifyOtherKeys Escape", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;1;27~", "escape"), true); + assert.strictEqual(parseKey("\x1b[27;1;27~"), "escape"); + }); + + it("should match xterm modifyOtherKeys Space variants", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;1;32~", "space"), true); + assert.strictEqual(matchesKey("\x1b[27;5;32~", "ctrl+space"), true); + assert.strictEqual(parseKey("\x1b[27;1;32~"), "space"); + assert.strictEqual(parseKey("\x1b[27;5;32~"), "ctrl+space"); + }); + + it("should match xterm modifyOtherKeys symbol combos", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;5;47~", "ctrl+/"), true); + assert.strictEqual(parseKey("\x1b[27;5;47~"), "ctrl+/"); + }); + + it("should match xterm modifyOtherKeys digit combos", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;5;49~", "ctrl+1"), true); + assert.strictEqual(matchesKey("\x1b[27;2;49~", "shift+1"), true); + assert.strictEqual(parseKey("\x1b[27;5;49~"), "ctrl+1"); + assert.strictEqual(parseKey("\x1b[27;2;49~"), "shift+1"); + }); + + it("should match xterm modifyOtherKeys shifted uppercase letters", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;2;69~", "shift+e"), true); + assert.strictEqual(matchesKey("\x1b[27;6;69~", "ctrl+shift+e"), true); + assert.strictEqual(parseKey("\x1b[27;2;69~"), "shift+e"); + assert.strictEqual(parseKey("\x1b[27;6;69~"), "shift+ctrl+e"); + }); + + it("should match Ctrl+Alt+letter via CSI-u when kitty inactive", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[104;7u", "ctrl+alt+h"), true); + assert.strictEqual(parseKey("\x1b[104;7u"), "ctrl+alt+h"); + }); + + it("should match Ctrl+Alt+letter via xterm modifyOtherKeys", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;7;104~", "ctrl+alt+h"), true); + assert.strictEqual(parseKey("\x1b[27;7;104~"), "ctrl+alt+h"); + }); + }); + + describe("Legacy key matching", () => { + it("should match legacy Ctrl+c", () => { + setKittyProtocolActive(false); + // Ctrl+c sends ASCII 3 (ETX) + assert.strictEqual(matchesKey("\x03", "ctrl+c"), true); + }); + + it("should match legacy Ctrl+d", () => { + setKittyProtocolActive(false); + // Ctrl+d sends ASCII 4 (EOT) + assert.strictEqual(matchesKey("\x04", "ctrl+d"), true); + }); + + it("should match escape key", () => { + assert.strictEqual(matchesKey("\x1b", "escape"), true); + }); + + it("should match legacy linefeed as enter", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\n", "enter"), true); + assert.strictEqual(parseKey("\n"), "enter"); + }); + + it("should treat linefeed as shift+enter when kitty active", () => { + setKittyProtocolActive(true); + assert.strictEqual(matchesKey("\n", "shift+enter"), true); + assert.strictEqual(matchesKey("\n", "enter"), false); + assert.strictEqual(parseKey("\n"), "shift+enter"); + setKittyProtocolActive(false); + }); + + it("should parse ctrl+space", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x00", "ctrl+space"), true); + assert.strictEqual(parseKey("\x00"), "ctrl+space"); + }); + + it("should match legacy Ctrl+symbol", () => { + setKittyProtocolActive(false); + // Ctrl+\ sends ASCII 28 (File Separator) in legacy terminals + assert.strictEqual(matchesKey("\x1c", "ctrl+\\"), true); + assert.strictEqual(parseKey("\x1c"), "ctrl+\\"); + // Ctrl+] sends ASCII 29 (Group Separator) in legacy terminals + assert.strictEqual(matchesKey("\x1d", "ctrl+]"), true); + assert.strictEqual(parseKey("\x1d"), "ctrl+]"); + // Ctrl+_ sends ASCII 31 (Unit Separator) in legacy terminals + // Ctrl+- is on the same physical key on US keyboards + assert.strictEqual(matchesKey("\x1f", "ctrl+_"), true); + assert.strictEqual(matchesKey("\x1f", "ctrl+-"), true); + assert.strictEqual(parseKey("\x1f"), "ctrl+-"); + }); + + it("should match legacy Ctrl+Alt+symbol", () => { + setKittyProtocolActive(false); + // Ctrl+Alt+[ sends ESC followed by ESC (Ctrl+[ = ESC) + assert.strictEqual(matchesKey("\x1b\x1b", "ctrl+alt+["), true); + assert.strictEqual(parseKey("\x1b\x1b"), "ctrl+alt+["); + // Ctrl+Alt+\ sends ESC followed by ASCII 28 + assert.strictEqual(matchesKey("\x1b\x1c", "ctrl+alt+\\"), true); + assert.strictEqual(parseKey("\x1b\x1c"), "ctrl+alt+\\"); + // Ctrl+Alt+] sends ESC followed by ASCII 29 + assert.strictEqual(matchesKey("\x1b\x1d", "ctrl+alt+]"), true); + assert.strictEqual(parseKey("\x1b\x1d"), "ctrl+alt+]"); + // Ctrl+_ sends ASCII 31 (Unit Separator) in legacy terminals + // Ctrl+- is on the same physical key on US keyboards + assert.strictEqual(matchesKey("\x1b\x1f", "ctrl+alt+_"), true); + assert.strictEqual(matchesKey("\x1b\x1f", "ctrl+alt+-"), true); + assert.strictEqual(parseKey("\x1b\x1f"), "ctrl+alt+-"); + }); + + it("should treat raw 0x08 as plain backspace outside Windows Terminal", () => { + setKittyProtocolActive(false); + withEnv("WT_SESSION", undefined, () => { + assert.strictEqual(matchesKey("\x7f", "backspace"), true); + assert.strictEqual(matchesKey("\x7f", "ctrl+backspace"), false); + assert.strictEqual(parseKey("\x7f"), "backspace"); + assert.strictEqual(matchesKey("\x08", "backspace"), true); + assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), false); + assert.strictEqual(parseKey("\x08"), "backspace"); + assert.strictEqual(matchesKey("\x08", "ctrl+h"), true); + }); + }); + + it("should treat raw 0x08 as ctrl+backspace in local Windows Terminal", () => { + setKittyProtocolActive(false); + withEnvVars( + { + WT_SESSION: "test-session", + SSH_CONNECTION: undefined, + SSH_CLIENT: undefined, + SSH_TTY: undefined, + }, + () => { + assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), true); + assert.strictEqual(matchesKey("\x08", "backspace"), false); + assert.strictEqual(parseKey("\x08"), "ctrl+backspace"); + assert.strictEqual(matchesKey("\x08", "ctrl+h"), true); + }, + ); + }); + + it("should treat raw 0x08 as plain backspace in Windows Terminal over SSH", () => { + setKittyProtocolActive(false); + withEnvVars( + { + WT_SESSION: "test-session", + SSH_CONNECTION: "1 2 3 4", + SSH_CLIENT: "1 2 3", + SSH_TTY: "/dev/pts/1", + }, + () => { + assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), false); + assert.strictEqual(matchesKey("\x08", "backspace"), true); + assert.strictEqual(parseKey("\x08"), "backspace"); + assert.strictEqual(matchesKey("\x08", "ctrl+h"), true); + }, + ); + }); + + it("should parse legacy alt-prefixed sequences when kitty inactive", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b ", "alt+space"), true); + assert.strictEqual(parseKey("\x1b "), "alt+space"); + assert.strictEqual(matchesKey("\x1b\b", "alt+backspace"), true); + assert.strictEqual(parseKey("\x1b\b"), "alt+backspace"); + assert.strictEqual(matchesKey("\x1b\x03", "ctrl+alt+c"), true); + assert.strictEqual(parseKey("\x1b\x03"), "ctrl+alt+c"); + assert.strictEqual(matchesKey("\x1bB", "alt+left"), true); + assert.strictEqual(parseKey("\x1bB"), "alt+left"); + assert.strictEqual(matchesKey("\x1bF", "alt+right"), true); + assert.strictEqual(parseKey("\x1bF"), "alt+right"); + assert.strictEqual(matchesKey("\x1ba", "alt+a"), true); + assert.strictEqual(parseKey("\x1ba"), "alt+a"); + assert.strictEqual(matchesKey("\x1b1", "alt+1"), true); + assert.strictEqual(parseKey("\x1b1"), "alt+1"); + assert.strictEqual(matchesKey("\x1by", "alt+y"), true); + assert.strictEqual(parseKey("\x1by"), "alt+y"); + assert.strictEqual(matchesKey("\x1bz", "alt+z"), true); + assert.strictEqual(parseKey("\x1bz"), "alt+z"); + + setKittyProtocolActive(true); + assert.strictEqual(matchesKey("\x1b ", "alt+space"), false); + assert.strictEqual(parseKey("\x1b "), undefined); + assert.strictEqual(matchesKey("\x1b\b", "alt+backspace"), true); + assert.strictEqual(parseKey("\x1b\b"), "alt+backspace"); + assert.strictEqual(matchesKey("\x1b\x03", "ctrl+alt+c"), false); + assert.strictEqual(parseKey("\x1b\x03"), undefined); + assert.strictEqual(matchesKey("\x1bB", "alt+left"), false); + assert.strictEqual(parseKey("\x1bB"), undefined); + assert.strictEqual(matchesKey("\x1bF", "alt+right"), false); + assert.strictEqual(parseKey("\x1bF"), undefined); + assert.strictEqual(matchesKey("\x1ba", "alt+a"), false); + assert.strictEqual(parseKey("\x1ba"), undefined); + assert.strictEqual(matchesKey("\x1b1", "alt+1"), false); + assert.strictEqual(parseKey("\x1b1"), undefined); + assert.strictEqual(matchesKey("\x1by", "alt+y"), false); + assert.strictEqual(parseKey("\x1by"), undefined); + setKittyProtocolActive(false); + }); + + it("should match arrow keys", () => { + assert.strictEqual(matchesKey("\x1b[A", "up"), true); + assert.strictEqual(matchesKey("\x1b[B", "down"), true); + assert.strictEqual(matchesKey("\x1b[C", "right"), true); + assert.strictEqual(matchesKey("\x1b[D", "left"), true); + }); + + it("should match SS3 arrows and home/end", () => { + assert.strictEqual(matchesKey("\x1bOA", "up"), true); + assert.strictEqual(matchesKey("\x1bOB", "down"), true); + assert.strictEqual(matchesKey("\x1bOC", "right"), true); + assert.strictEqual(matchesKey("\x1bOD", "left"), true); + assert.strictEqual(matchesKey("\x1bOH", "home"), true); + assert.strictEqual(matchesKey("\x1bOF", "end"), true); + }); + + it("should match legacy function keys and clear", () => { + assert.strictEqual(matchesKey("\x1bOP", "f1"), true); + assert.strictEqual(matchesKey("\x1b[24~", "f12"), true); + assert.strictEqual(matchesKey("\x1b[E", "clear"), true); + }); + + it("should match alt+arrows", () => { + assert.strictEqual(matchesKey("\x1bp", "alt+up"), true); + assert.strictEqual(matchesKey("\x1bp", "up"), false); + }); + + it("should match rxvt modifier sequences", () => { + assert.strictEqual(matchesKey("\x1b[a", "shift+up"), true); + assert.strictEqual(matchesKey("\x1bOa", "ctrl+up"), true); + assert.strictEqual(matchesKey("\x1b[2$", "shift+insert"), true); + assert.strictEqual(matchesKey("\x1b[2^", "ctrl+insert"), true); + assert.strictEqual(matchesKey("\x1b[7$", "shift+home"), true); + }); + }); +}); + +describe("decodeKittyPrintable", () => { + it("should decode Kitty keypad functional keys to printable characters", () => { + assert.strictEqual(decodeKittyPrintable("\x1b[57399u"), "0"); + assert.strictEqual(decodeKittyPrintable("\x1b[57400u"), "1"); + assert.strictEqual(decodeKittyPrintable("\x1b[57409u"), "."); + assert.strictEqual(decodeKittyPrintable("\x1b[57410u"), "/"); + assert.strictEqual(decodeKittyPrintable("\x1b[57411u"), "*"); + assert.strictEqual(decodeKittyPrintable("\x1b[57412u"), "-"); + assert.strictEqual(decodeKittyPrintable("\x1b[57413u"), "+"); + assert.strictEqual(decodeKittyPrintable("\x1b[57415u"), "="); + assert.strictEqual(decodeKittyPrintable("\x1b[57416u"), ","); + assert.strictEqual(decodeKittyPrintable("\x1b[57417u"), undefined); + }); +}); + +describe("decodePrintableKey", () => { + it("should decode printable xterm modifyOtherKeys sequences", () => { + assert.strictEqual(decodePrintableKey("\x1b[27;2;69~"), "E"); + assert.strictEqual(decodePrintableKey("\x1b[27;2;196~"), "Ä"); + assert.strictEqual(decodePrintableKey("\x1b[27;2;32~"), " "); + assert.strictEqual(decodePrintableKey("\x1b[27;2;13~"), undefined); + assert.strictEqual(decodePrintableKey("\x1b[27;6;69~"), undefined); + }); +}); + +describe("parseKey", () => { + describe("Kitty protocol with alternate keys", () => { + it("should return Latin key name when base layout key is present", () => { + setKittyProtocolActive(true); + // Cyrillic ctrl+с with base layout 'c' + const cyrillicCtrlC = "\x1b[1089::99;5u"; + assert.strictEqual(parseKey(cyrillicCtrlC), "ctrl+c"); + setKittyProtocolActive(false); + }); + + it("should prefer codepoint for Latin letters when base layout differs", () => { + setKittyProtocolActive(true); + // Dvorak Ctrl+K reports codepoint 'k' (107) and base layout 'v' (118) + const dvorakCtrlK = "\x1b[107::118;5u"; + assert.strictEqual(parseKey(dvorakCtrlK), "ctrl+k"); + setKittyProtocolActive(false); + }); + + it("should prefer codepoint for symbol keys when base layout differs", () => { + setKittyProtocolActive(true); + // Dvorak Ctrl+/ reports codepoint '/' (47) and base layout '[' (91) + const dvorakCtrlSlash = "\x1b[47::91;5u"; + assert.strictEqual(parseKey(dvorakCtrlSlash), "ctrl+/"); + setKittyProtocolActive(false); + }); + + it("should return key name from codepoint when no base layout", () => { + setKittyProtocolActive(true); + const latinCtrlC = "\x1b[99;5u"; + assert.strictEqual(parseKey(latinCtrlC), "ctrl+c"); + setKittyProtocolActive(false); + }); + + it("should parse shifted uppercase CSI-u letters as shift+letter", () => { + setKittyProtocolActive(true); + assert.strictEqual(matchesKey("\x1b[69;2u", "shift+e"), true); + assert.strictEqual(parseKey("\x1b[69;2u"), "shift+e"); + setKittyProtocolActive(false); + }); + + it("should ignore Kitty CSI-u with unsupported modifiers", () => { + setKittyProtocolActive(true); + assert.strictEqual(parseKey("\x1b[99;17u"), undefined); + setKittyProtocolActive(false); + }); + }); + + describe("Legacy key parsing", () => { + it("should parse legacy Ctrl+letter", () => { + setKittyProtocolActive(false); + assert.strictEqual(parseKey("\x03"), "ctrl+c"); + assert.strictEqual(parseKey("\x04"), "ctrl+d"); + }); + + it("should parse special keys", () => { + assert.strictEqual(parseKey("\x1b"), "escape"); + assert.strictEqual(parseKey("\t"), "tab"); + assert.strictEqual(parseKey("\r"), "enter"); + assert.strictEqual(parseKey("\n"), "enter"); + assert.strictEqual(parseKey("\x00"), "ctrl+space"); + assert.strictEqual(parseKey(" "), "space"); + assert.strictEqual(parseKey("1"), "1"); + assert.strictEqual(matchesKey("1", "1"), true); + }); + + it("should parse arrow keys", () => { + assert.strictEqual(parseKey("\x1b[A"), "up"); + assert.strictEqual(parseKey("\x1b[B"), "down"); + assert.strictEqual(parseKey("\x1b[C"), "right"); + assert.strictEqual(parseKey("\x1b[D"), "left"); + }); + + it("should parse SS3 arrows and home/end", () => { + assert.strictEqual(parseKey("\x1bOA"), "up"); + assert.strictEqual(parseKey("\x1bOB"), "down"); + assert.strictEqual(parseKey("\x1bOC"), "right"); + assert.strictEqual(parseKey("\x1bOD"), "left"); + assert.strictEqual(parseKey("\x1bOH"), "home"); + assert.strictEqual(parseKey("\x1bOF"), "end"); + }); + + it("should parse legacy function and modifier sequences", () => { + assert.strictEqual(parseKey("\x1bOP"), "f1"); + assert.strictEqual(parseKey("\x1b[24~"), "f12"); + assert.strictEqual(parseKey("\x1b[E"), "clear"); + assert.strictEqual(parseKey("\x1b[2^"), "ctrl+insert"); + assert.strictEqual(parseKey("\x1bp"), "alt+up"); + }); + + it("should parse double bracket pageUp", () => { + assert.strictEqual(parseKey("\x1b[[5~"), "pageUp"); + }); + }); +}); diff --git a/packages/pi-tui/test/markdown.test.ts b/packages/pi-tui/test/markdown.test.ts new file mode 100644 index 000000000..c166a582a --- /dev/null +++ b/packages/pi-tui/test/markdown.test.ts @@ -0,0 +1,1442 @@ +import assert from "node:assert"; +import { afterEach, describe, it } from "node:test"; +import type { Terminal as XtermTerminalType } from "@xterm/headless"; +import { Chalk } from "chalk"; +import { Markdown } from "../src/components/markdown.ts"; +import { resetCapabilitiesCache, setCapabilities } from "../src/terminal-image.ts"; +import { type Component, TUI } from "../src/tui.ts"; +import { defaultMarkdownTheme } from "./test-themes.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +// Force full color in CI so ANSI assertions are deterministic +const chalk = new Chalk({ level: 3 }); + +function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number { + const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm; + const buffer = xterm.buffer.active; + const line = buffer.getLine(buffer.viewportY + row); + assert.ok(line, `Missing buffer line at row ${row}`); + const cell = line.getCell(col); + assert.ok(cell, `Missing cell at row ${row} col ${col}`); + return cell.isItalic(); +} + +function getCellUnderline(terminal: VirtualTerminal, row: number, col: number): number { + const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm; + const buffer = xterm.buffer.active; + const line = buffer.getLine(buffer.viewportY + row); + assert.ok(line, `Missing buffer line at row ${row}`); + const cell = line.getCell(col); + assert.ok(cell, `Missing cell at row ${row} col ${col}`); + return cell.isUnderline(); +} + +function stripAnsi(line: string): string { + return line.replace(/\x1b\[[0-9;]*m/g, ""); +} + +describe("Markdown component", () => { + describe("Lists", () => { + it("should render simple nested list", () => { + const markdown = new Markdown( + `- Item 1 + - Nested 1.1 + - Nested 1.2 +- Item 2`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + + // Check that we have content + assert.ok(lines.length > 0); + + // Strip ANSI codes for checking + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + + // Check structure + assert.ok(plainLines.some((line) => line.includes("- Item 1"))); + assert.ok(plainLines.some((line) => line.includes(" - Nested 1.1"))); + assert.ok(plainLines.some((line) => line.includes(" - Nested 1.2"))); + assert.ok(plainLines.some((line) => line.includes("- Item 2"))); + }); + + it("should render deeply nested list", () => { + const markdown = new Markdown( + `- Level 1 + - Level 2 + - Level 3 + - Level 4`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + + // Check proper indentation + assert.ok(plainLines.some((line) => line.includes("- Level 1"))); + assert.ok(plainLines.some((line) => line.includes(" - Level 2"))); + assert.ok(plainLines.some((line) => line.includes(" - Level 3"))); + assert.ok(plainLines.some((line) => line.includes(" - Level 4"))); + }); + + it("should render ordered nested list", () => { + const markdown = new Markdown( + `1. First + 1. Nested first + 2. Nested second +2. Second`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + + assert.ok(plainLines.some((line) => line.includes("1. First"))); + assert.ok(plainLines.some((line) => line.includes(" 1. Nested first"))); + assert.ok(plainLines.some((line) => line.includes(" 2. Nested second"))); + assert.ok(plainLines.some((line) => line.includes("2. Second"))); + }); + + it("should normalize ordered list markers by default", () => { + const markdown = new Markdown("1. alpha\n1. beta\n1. gamma", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["1. alpha", "2. beta", "3. gamma"]); + }); + + it("should preserve source list markers when configured", () => { + const markdown = new Markdown( + " 4. forth\n 3. third\n\n10) ten\n7) seven\n\n+ plus\n* star\n- minus\n+", + 0, + 0, + defaultMarkdownTheme, + undefined, + { + preserveOrderedListMarkers: true, + }, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, [ + "4. forth", + "3. third", + "", + "10) ten", + "7) seven", + "", + "+ plus", + "* star", + "- minus", + "+", + ]); + }); + + it("should render mixed ordered and unordered nested lists", () => { + const markdown = new Markdown( + `1. Ordered item + - Unordered nested + - Another nested +2. Second ordered + - More nested`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + + assert.ok(plainLines.some((line) => line.includes("1. Ordered item"))); + assert.ok(plainLines.some((line) => line.includes(" - Unordered nested"))); + assert.ok(plainLines.some((line) => line.includes("2. Second ordered"))); + }); + + it("should render blank lines between loose list items", () => { + const markdown = new Markdown( + `1. Lorem ipsum dolor sit amet. + + Ut enim ad minim veniam. + +2. Duis aute irure dolor. + + Excepteur sint occaecat cupidatat. + +3. Beep boop`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, [ + "1. Lorem ipsum dolor sit amet.", + "", + " Ut enim ad minim veniam.", + "", + "2. Duis aute irure dolor.", + "", + " Excepteur sint occaecat cupidatat.", + "", + "3. Beep boop", + ]); + }); + + it("should render task list markers", () => { + const markdown = new Markdown("- [ ] beep\n- [x] boop", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["- [ ] beep", "- [x] boop"]); + }); + + it("should maintain numbering when code blocks are not indented (LLM output)", () => { + // When code blocks aren't indented, marked parses each item as a separate list. + // We use token.start to preserve the original numbering. + const markdown = new Markdown( + `1. First item + +\`\`\`typescript +// code block +\`\`\` + +2. Second item + +\`\`\`typescript +// another code block +\`\`\` + +3. Third item`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trim()); + + // Find all lines that start with a number and period + const numberedLines = plainLines.filter((line) => /^\d+\./.test(line)); + + // Should have 3 numbered items + assert.strictEqual(numberedLines.length, 3, `Expected 3 numbered items, got: ${numberedLines.join(", ")}`); + + // Check the actual numbers + assert.ok(numberedLines[0].startsWith("1."), `First item should be "1.", got: ${numberedLines[0]}`); + assert.ok(numberedLines[1].startsWith("2."), `Second item should be "2.", got: ${numberedLines[1]}`); + assert.ok(numberedLines[2].startsWith("3."), `Third item should be "3.", got: ${numberedLines[2]}`); + }); + + it("should indent wrapped unordered list lines", () => { + const markdown = new Markdown("- alpha beta gamma delta epsilon", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(20).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["- alpha beta gamma", " delta epsilon"]); + }); + + it("should indent wrapped ordered list lines", () => { + const markdown = new Markdown("1. alpha beta gamma delta epsilon", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(20).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["1. alpha beta gamma", " delta epsilon"]); + }); + + it("should indent wrapped ordered list lines with multi-digit markers", () => { + const markdown = new Markdown("10. alpha beta gamma delta epsilon", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(21).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["10. alpha beta gamma", " delta epsilon"]); + }); + + it("should indent wrapped nested list lines", () => { + const markdown = new Markdown(`- parent\n - alpha beta gamma delta epsilon`, 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(24).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["- parent", " - alpha beta gamma", " delta epsilon"]); + }); + + it("should indent wrapped nested list lines under ordered parents", () => { + const markdown = new Markdown(`1. parent\n - alpha beta gamma delta epsilon`, 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(24).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["1. parent", " - alpha beta gamma", " delta epsilon"]); + }); + + it("should render and wrap blockquotes inside list items", () => { + const markdown = new Markdown("- > alpha beta gamma delta epsilon zeta", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(24).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["- │ alpha beta gamma", " │ delta epsilon zeta"]); + }); + + it("should render and wrap code blocks inside list items", () => { + const markdown = new Markdown( + "- ```ts\n alpha beta gamma delta epsilon zeta\n ```", + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(24).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["- ```ts", " alpha beta gamma", " delta epsilon zeta", " ```"]); + }); + }); + + describe("Tables", () => { + it("should render simple table", () => { + const markdown = new Markdown( + `| Name | Age | +| --- | --- | +| Alice | 30 | +| Bob | 25 |`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + + // Check table structure + assert.ok(plainLines.some((line) => line.includes("Name"))); + assert.ok(plainLines.some((line) => line.includes("Age"))); + assert.ok(plainLines.some((line) => line.includes("Alice"))); + assert.ok(plainLines.some((line) => line.includes("Bob"))); + // Check for table borders + assert.ok(plainLines.some((line) => line.includes("│"))); + assert.ok(plainLines.some((line) => line.includes("─"))); + }); + + it("should render row dividers between data rows", () => { + const markdown = new Markdown( + `| Name | Age | +| --- | --- | +| Alice | 30 | +| Bob | 25 |`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const dividerLines = plainLines.filter((line) => line.includes("┼")); + + assert.strictEqual(dividerLines.length, 2, "Expected header + row divider"); + }); + + it("should keep column width at least the longest word", () => { + const longestWord = "superlongword"; + const markdown = new Markdown( + `| Column One | Column Two | +| --- | --- | +| ${longestWord} short | otherword | +| small | tiny |`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(32); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const dataLine = plainLines.find((line) => line.includes(longestWord)); + assert.ok(dataLine, "Expected data row containing longest word"); + + const segments = dataLine.split("│").slice(1, -1); + const [firstSegment] = segments; + assert.ok(firstSegment, "Expected first column segment"); + const firstColumnWidth = firstSegment.length - 2; + + assert.ok( + firstColumnWidth >= longestWord.length, + `Expected first column width >= ${longestWord.length}, got ${firstColumnWidth}`, + ); + }); + + it("should render table with alignment", () => { + const markdown = new Markdown( + `| Left | Center | Right | +| :--- | :---: | ---: | +| A | B | C | +| Long text | Middle | End |`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + + // Check headers + assert.ok(plainLines.some((line) => line.includes("Left"))); + assert.ok(plainLines.some((line) => line.includes("Center"))); + assert.ok(plainLines.some((line) => line.includes("Right"))); + // Check content + assert.ok(plainLines.some((line) => line.includes("Long text"))); + }); + + it("should handle tables with varying column widths", () => { + const markdown = new Markdown( + `| Short | Very long column header | +| --- | --- | +| A | This is a much longer cell content | +| B | Short |`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + + // Should render without errors + assert.ok(lines.length > 0); + + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + assert.ok(plainLines.some((line) => line.includes("Very long column header"))); + assert.ok(plainLines.some((line) => line.includes("This is a much longer cell content"))); + }); + + it("should wrap table cells when table exceeds available width", () => { + const markdown = new Markdown( + `| Command | Description | Example | +| --- | --- | --- | +| npm install | Install all dependencies | npm install | +| npm run build | Build the project | npm run build |`, + 0, + 0, + defaultMarkdownTheme, + ); + + // Render at narrow width that forces wrapping + const lines = markdown.render(50); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + // All lines should fit within width + for (const line of plainLines) { + assert.ok(line.length <= 50, `Line exceeds width 50: "${line}" (length: ${line.length})`); + } + + // Content should still be present (possibly wrapped across lines) + const allText = plainLines.join(" "); + assert.ok(allText.includes("Command"), "Should contain 'Command'"); + assert.ok(allText.includes("Description"), "Should contain 'Description'"); + assert.ok(allText.includes("npm install"), "Should contain 'npm install'"); + assert.ok(allText.includes("Install"), "Should contain 'Install'"); + }); + + it("should wrap long cell content to multiple lines", () => { + const markdown = new Markdown( + `| Header | +| --- | +| This is a very long cell content that should wrap |`, + 0, + 0, + defaultMarkdownTheme, + ); + + // Render at width that forces the cell to wrap + const lines = markdown.render(25); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + // Should have multiple data rows due to wrapping + const dataRows = plainLines.filter((line) => line.startsWith("│") && !line.includes("─")); + assert.ok(dataRows.length > 2, `Expected wrapped rows, got ${dataRows.length} rows`); + + // All content should be preserved (may be split across lines) + const allText = plainLines.join(" "); + assert.ok(allText.includes("very long"), "Should preserve 'very long'"); + assert.ok(allText.includes("cell content"), "Should preserve 'cell content'"); + assert.ok(allText.includes("should wrap"), "Should preserve 'should wrap'"); + }); + + it("should wrap long unbroken tokens inside table cells (not only at line start)", () => { + // Pin to no-hyperlinks so width checks work on plain text without OSC 8 sequences. + setCapabilities({ images: null, trueColor: false, hyperlinks: false }); + const url = "https://example.com/this/is/a/very/long/url/that/should/wrap"; + const markdown = new Markdown( + `| Value | +| --- | +| prefix ${url} |`, + 0, + 0, + defaultMarkdownTheme, + ); + + const width = 30; + const lines = markdown.render(width); + resetCapabilitiesCache(); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + for (const line of plainLines) { + assert.ok(line.length <= width, `Line exceeds width ${width}: "${line}" (length: ${line.length})`); + } + + // Borders should stay intact (exactly 2 vertical borders for a 1-col table) + const tableLines = plainLines.filter((line) => line.startsWith("│")); + assert.ok(tableLines.length > 0, "Expected table rows to render"); + for (const line of tableLines) { + const borderCount = line.split("│").length - 1; + assert.strictEqual(borderCount, 2, `Expected 2 borders, got ${borderCount}: "${line}"`); + } + + // Strip box drawing characters + whitespace so we can assert the URL is preserved + // even if it was split across multiple wrapped lines. + const extracted = plainLines.join("").replace(/[│├┤─\s]/g, ""); + assert.ok(extracted.includes("prefix"), "Should preserve 'prefix'"); + assert.ok(extracted.includes(url), "Should preserve URL"); + }); + + it("should wrap styled inline code inside table cells without breaking borders", () => { + const markdown = new Markdown( + `| Code | +| --- | +| \`averyveryveryverylongidentifier\` |`, + 0, + 0, + defaultMarkdownTheme, + ); + + const width = 20; + const lines = markdown.render(width); + const joinedOutput = lines.join("\n"); + assert.ok(joinedOutput.includes("\x1b[33m"), "Inline code should be styled (yellow)"); + + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + for (const line of plainLines) { + assert.ok(line.length <= width, `Line exceeds width ${width}: "${line}" (length: ${line.length})`); + } + + const tableLines = plainLines.filter((line) => line.startsWith("│")); + for (const line of tableLines) { + const borderCount = line.split("│").length - 1; + assert.strictEqual(borderCount, 2, `Expected 2 borders, got ${borderCount}: "${line}"`); + } + }); + + it("should handle extremely narrow width gracefully", () => { + const markdown = new Markdown( + `| A | B | C | +| --- | --- | --- | +| 1 | 2 | 3 |`, + 0, + 0, + defaultMarkdownTheme, + ); + + // Very narrow width + const lines = markdown.render(15); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + // Should not crash and should produce output + assert.ok(lines.length > 0, "Should produce output"); + + // Lines should not exceed width + for (const line of plainLines) { + assert.ok(line.length <= 15, `Line exceeds width 15: "${line}" (length: ${line.length})`); + } + }); + + it("should render table correctly when it fits naturally", () => { + const markdown = new Markdown( + `| A | B | +| --- | --- | +| 1 | 2 |`, + 0, + 0, + defaultMarkdownTheme, + ); + + // Wide width where table fits naturally + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + // Should have proper table structure + const headerLine = plainLines.find((line) => line.includes("A") && line.includes("B")); + assert.ok(headerLine, "Should have header row"); + assert.ok(headerLine?.includes("│"), "Header should have borders"); + + const separatorLine = plainLines.find((line) => line.includes("├") && line.includes("┼")); + assert.ok(separatorLine, "Should have separator row"); + + const dataLine = plainLines.find((line) => line.includes("1") && line.includes("2")); + assert.ok(dataLine, "Should have data row"); + }); + + it("should respect paddingX when calculating table width", () => { + const markdown = new Markdown( + `| Column One | Column Two | +| --- | --- | +| Data 1 | Data 2 |`, + 2, // paddingX = 2 + 0, + defaultMarkdownTheme, + ); + + // Width 40 with paddingX=2 means contentWidth=36 + const lines = markdown.render(40); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + // All lines should respect width + for (const line of plainLines) { + assert.ok(line.length <= 40, `Line exceeds width 40: "${line}" (length: ${line.length})`); + } + + // Table rows should have left padding + const tableRow = plainLines.find((line) => line.includes("│")); + assert.ok(tableRow?.startsWith(" "), "Table should have left padding"); + }); + + it("should not add a trailing blank line when table is the last rendered block", () => { + const markdown = new Markdown( + `| Name | +| --- | +| Alice |`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + assert.notStrictEqual( + plainLines.at(-1), + "", + `Expected table to end without a blank line: ${JSON.stringify(plainLines)}`, + ); + }); + }); + + describe("Combined features", () => { + it("should render lists and tables together", () => { + const markdown = new Markdown( + `# Test Document + +- Item 1 + - Nested item +- Item 2 + +| Col1 | Col2 | +| --- | --- | +| A | B |`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + + // Check heading + assert.ok(plainLines.some((line) => line.includes("Test Document"))); + // Check list + assert.ok(plainLines.some((line) => line.includes("- Item 1"))); + assert.ok(plainLines.some((line) => line.includes(" - Nested item"))); + // Check table + assert.ok(plainLines.some((line) => line.includes("Col1"))); + assert.ok(plainLines.some((line) => line.includes("│"))); + }); + }); + + describe("Backslash escapes", () => { + it("should normalize escaped punctuation by default", () => { + const markdown = new Markdown(String.raw`"\"`, 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, [`""`]); + }); + + it("should preserve source backslash escapes when configured", () => { + const markdown = new Markdown(String.raw`"\"`, 0, 0, defaultMarkdownTheme, undefined, { + preserveBackslashEscapes: true, + }); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, [String.raw`"\"`]); + }); + }); + + describe("Pre-styled text (thinking traces)", () => { + it("should preserve gray italic styling after inline code", () => { + // This replicates how thinking content is rendered in assistant-message.ts + const markdown = new Markdown( + "This is thinking with `inline code` and more text after", + 1, + 0, + defaultMarkdownTheme, + { + color: (text) => chalk.gray(text), + italic: true, + }, + ); + + const lines = markdown.render(80); + const joinedOutput = lines.join("\n"); + + // Should contain the inline code block + assert.ok(joinedOutput.includes("inline code")); + + // The output should have ANSI codes for gray (90) and italic (3) + assert.ok(joinedOutput.includes("\x1b[90m"), "Should have gray color code"); + assert.ok(joinedOutput.includes("\x1b[3m"), "Should have italic code"); + + // Verify that inline code is styled (theme uses yellow) + const hasCodeColor = joinedOutput.includes("\x1b[33m"); + assert.ok(hasCodeColor, "Should style inline code"); + }); + + it("should preserve gray italic styling after bold text", () => { + const markdown = new Markdown( + "This is thinking with **bold text** and more after", + 1, + 0, + defaultMarkdownTheme, + { + color: (text) => chalk.gray(text), + italic: true, + }, + ); + + const lines = markdown.render(80); + const joinedOutput = lines.join("\n"); + + // Should contain bold text + assert.ok(joinedOutput.includes("bold text")); + + // The output should have ANSI codes for gray (90) and italic (3) + assert.ok(joinedOutput.includes("\x1b[90m"), "Should have gray color code"); + assert.ok(joinedOutput.includes("\x1b[3m"), "Should have italic code"); + + // Should have bold codes (1 or 22 for bold on/off) + assert.ok(joinedOutput.includes("\x1b[1m"), "Should have bold code"); + }); + + it("should not leak styles into following lines when rendered in TUI", async () => { + class MarkdownWithInput implements Component { + public markdownLineCount = 0; + private readonly markdown: Markdown; + + constructor(markdown: Markdown) { + this.markdown = markdown; + } + + render(width: number): string[] { + const lines = this.markdown.render(width); + this.markdownLineCount = lines.length; + return [...lines, "INPUT"]; + } + + invalidate(): void { + this.markdown.invalidate(); + } + } + + const markdown = new Markdown("This is thinking with `inline code`", 1, 0, defaultMarkdownTheme, { + color: (text) => chalk.gray(text), + italic: true, + }); + + const terminal = new VirtualTerminal(80, 6); + const tui = new TUI(terminal); + const component = new MarkdownWithInput(markdown); + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + + assert.ok(component.markdownLineCount > 0); + const inputRow = component.markdownLineCount; + assert.strictEqual(getCellItalic(terminal, inputRow, 0), 0); + tui.stop(); + }); + }); + + describe("Spacing after code blocks", () => { + it("should have only one blank line between code block and following paragraph", () => { + const markdown = new Markdown( + `hello world + +\`\`\`js +const hello = "world"; +\`\`\` + +again, hello world`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + const closingBackticksIndex = plainLines.indexOf("```"); + assert.ok(closingBackticksIndex !== -1, "Should have closing backticks"); + + const afterBackticks = plainLines.slice(closingBackticksIndex + 1); + const emptyLineCount = afterBackticks.findIndex((line) => line !== ""); + + assert.strictEqual( + emptyLineCount, + 1, + `Expected 1 empty line after code block, but found ${emptyLineCount}. Lines after backticks: ${JSON.stringify(afterBackticks.slice(0, 5))}`, + ); + }); + + it("should normalize paragraph and code block spacing to one blank line", () => { + const cases = [ + `hello this is text +\`\`\` +code block +\`\`\` +more text`, + `hello this is text + +\`\`\` +code block +\`\`\` + +more text`, + ]; + const expectedLines = ["hello this is text", "", "```", " code block", "```", "", "more text"]; + + for (const text of cases) { + const markdown = new Markdown(text, 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + assert.deepStrictEqual( + plainLines, + expectedLines, + `Unexpected spacing for markdown: ${JSON.stringify(text)}`, + ); + } + }); + + it("should not add a trailing blank line when code block is the last rendered block", () => { + const cases = ["```js\nconst hello = 'world';\n```", "hello world\n\n```js\nconst hello = 'world';\n```"]; + + for (const text of cases) { + const markdown = new Markdown(text, 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + assert.notStrictEqual( + plainLines.at(-1), + "", + `Expected code block to end without a blank line: ${JSON.stringify(plainLines)}`, + ); + } + }); + }); + + describe("Spacing after dividers", () => { + it("should have only one blank line between divider and following paragraph", () => { + const markdown = new Markdown( + `hello world + +--- + +again, hello world`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + const dividerIndex = plainLines.findIndex((line) => line.includes("─")); + assert.ok(dividerIndex !== -1, "Should have divider"); + + const afterDivider = plainLines.slice(dividerIndex + 1); + const emptyLineCount = afterDivider.findIndex((line) => line !== ""); + + assert.strictEqual( + emptyLineCount, + 1, + `Expected 1 empty line after divider, but found ${emptyLineCount}. Lines after divider: ${JSON.stringify(afterDivider.slice(0, 5))}`, + ); + }); + + it("should not add a trailing blank line when divider is the last rendered block", () => { + const markdown = new Markdown("---", 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + assert.notStrictEqual( + plainLines.at(-1), + "", + `Expected divider to end without a blank line: ${JSON.stringify(plainLines)}`, + ); + }); + }); + + describe("Spacing after headings", () => { + it("should have only one blank line between heading and following paragraph", () => { + const markdown = new Markdown( + `# Hello + +This is a paragraph`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + const headingIndex = plainLines.findIndex((line) => line.includes("Hello")); + assert.ok(headingIndex !== -1, "Should have heading"); + + const afterHeading = plainLines.slice(headingIndex + 1); + const emptyLineCount = afterHeading.findIndex((line) => line !== ""); + + assert.strictEqual( + emptyLineCount, + 1, + `Expected 1 empty line after heading, but found ${emptyLineCount}. Lines after heading: ${JSON.stringify(afterHeading.slice(0, 5))}`, + ); + }); + + it("should not add a trailing blank line when heading is the last rendered block", () => { + const markdown = new Markdown("# Hello", 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + assert.notStrictEqual( + plainLines.at(-1), + "", + `Expected heading to end without a blank line: ${JSON.stringify(plainLines)}`, + ); + }); + }); + + describe("Spacing after blockquotes", () => { + it("should have only one blank line between blockquote and following paragraph", () => { + const markdown = new Markdown( + `hello world + +> This is a quote + +again, hello world`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + const quoteIndex = plainLines.findIndex((line) => line.includes("This is a quote")); + assert.ok(quoteIndex !== -1, "Should have blockquote"); + + const afterQuote = plainLines.slice(quoteIndex + 1); + const emptyLineCount = afterQuote.findIndex((line) => line !== ""); + + assert.strictEqual( + emptyLineCount, + 1, + `Expected 1 empty line after blockquote, but found ${emptyLineCount}. Lines after quote: ${JSON.stringify(afterQuote.slice(0, 5))}`, + ); + }); + + it("should not add a trailing blank line when blockquote is the last rendered block", () => { + const markdown = new Markdown("> This is a quote", 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + assert.notStrictEqual( + plainLines.at(-1), + "", + `Expected blockquote to end without a blank line: ${JSON.stringify(plainLines)}`, + ); + }); + }); + + describe("Blockquotes with multiline content", () => { + it("should apply consistent styling to all lines in lazy continuation blockquote", () => { + // Markdown "lazy continuation" - second line without > is still part of the quote + const markdown = new Markdown( + `>Foo +bar`, + 0, + 0, + defaultMarkdownTheme, + { + color: (text) => chalk.magenta(text), // This should NOT be applied to blockquotes + }, + ); + + const lines = markdown.render(80); + + // Both lines should have the quote border + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const quotedLines = plainLines.filter((line) => line.startsWith("│ ")); + assert.strictEqual(quotedLines.length, 2, `Expected 2 quoted lines, got: ${JSON.stringify(plainLines)}`); + + // Both lines should have italic (from theme.quote styling) + const fooLine = lines.find((line) => line.includes("Foo")); + const barLine = lines.find((line) => line.includes("bar")); + assert.ok(fooLine, "Should have Foo line"); + assert.ok(barLine, "Should have bar line"); + + // Check that both have italic (\x1b[3m) - blockquotes use theme styling, not default message color + assert.ok(fooLine?.includes("\x1b[3m"), `Foo line should have italic: ${fooLine}`); + assert.ok(barLine?.includes("\x1b[3m"), `bar line should have italic: ${barLine}`); + + // Blockquotes should NOT have the default message color (magenta) + assert.ok(!fooLine?.includes("\x1b[35m"), `Foo line should NOT have magenta color: ${fooLine}`); + assert.ok(!barLine?.includes("\x1b[35m"), `bar line should NOT have magenta color: ${barLine}`); + }); + + it("should apply consistent styling to explicit multiline blockquote", () => { + const markdown = new Markdown( + `>Foo +>bar`, + 0, + 0, + defaultMarkdownTheme, + { + color: (text) => chalk.cyan(text), // This should NOT be applied to blockquotes + }, + ); + + const lines = markdown.render(80); + + // Both lines should have the quote border + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const quotedLines = plainLines.filter((line) => line.startsWith("│ ")); + assert.strictEqual(quotedLines.length, 2, `Expected 2 quoted lines, got: ${JSON.stringify(plainLines)}`); + + // Both lines should have italic (from theme.quote styling) + const fooLine = lines.find((line) => line.includes("Foo")); + const barLine = lines.find((line) => line.includes("bar")); + assert.ok(fooLine?.includes("\x1b[3m"), `Foo line should have italic: ${fooLine}`); + assert.ok(barLine?.includes("\x1b[3m"), `bar line should have italic: ${barLine}`); + + // Blockquotes should NOT have the default message color (cyan) + assert.ok(!fooLine?.includes("\x1b[36m"), `Foo line should NOT have cyan color: ${fooLine}`); + assert.ok(!barLine?.includes("\x1b[36m"), `bar line should NOT have cyan color: ${barLine}`); + }); + + it("should render list content inside blockquotes", () => { + const markdown = new Markdown( + `> 1. bla bla +> - nested bullet`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const quotedLines = plainLines.filter((line) => line.startsWith("│ ")); + + assert.ok( + quotedLines.some((line) => line.includes("1. bla bla")), + `Missing ordered list item: ${JSON.stringify(quotedLines)}`, + ); + assert.ok( + quotedLines.some((line) => line.includes("- nested bullet")), + `Missing unordered list item: ${JSON.stringify(quotedLines)}`, + ); + }); + + it("should wrap long blockquote lines and add border to each wrapped line", () => { + const longText = "This is a very long blockquote line that should wrap to multiple lines when rendered"; + const markdown = new Markdown(`> ${longText}`, 0, 0, defaultMarkdownTheme); + + // Render at narrow width to force wrapping + const lines = markdown.render(30); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + // Filter to non-empty lines (exclude trailing blank line after blockquote) + const contentLines = plainLines.filter((line) => line.length > 0); + + // Should have multiple lines due to wrapping + assert.ok(contentLines.length > 1, `Expected multiple wrapped lines, got: ${JSON.stringify(contentLines)}`); + + // Every content line should start with the quote border + for (const line of contentLines) { + assert.ok(line.startsWith("│ "), `Wrapped line should have quote border: "${line}"`); + } + + // All content should be preserved + const allText = contentLines.join(" "); + assert.ok(allText.includes("very long"), "Should preserve 'very long'"); + assert.ok(allText.includes("blockquote"), "Should preserve 'blockquote'"); + assert.ok(allText.includes("multiple"), "Should preserve 'multiple'"); + }); + + it("should properly indent wrapped blockquote lines with styling", () => { + const markdown = new Markdown( + "> This is styled text that is long enough to wrap", + 0, + 0, + defaultMarkdownTheme, + { + color: (text) => chalk.yellow(text), // This should NOT be applied to blockquotes + italic: true, + }, + ); + + const lines = markdown.render(25); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd()); + + // Filter to non-empty lines + const contentLines = plainLines.filter((line) => line.length > 0); + + // All lines should have the quote border + for (const line of contentLines) { + assert.ok(line.startsWith("│ "), `Line should have quote border: "${line}"`); + } + + // Check that italic is applied (from theme.quote) + const allOutput = lines.join("\n"); + assert.ok(allOutput.includes("\x1b[3m"), "Should have italic"); + + // Blockquotes should NOT have the default message color (yellow) + assert.ok(!allOutput.includes("\x1b[33m"), "Should NOT have yellow color from default style"); + }); + + it("should render inline formatting inside blockquotes and reapply quote styling after", () => { + const markdown = new Markdown("> Quote with **bold** and `code`", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + + // Should have the quote border + assert.ok( + plainLines.some((line) => line.startsWith("│ ")), + "Should have quote border", + ); + + // Content should be preserved + const allPlain = plainLines.join(" "); + assert.ok(allPlain.includes("Quote with"), "Should preserve 'Quote with'"); + assert.ok(allPlain.includes("bold"), "Should preserve 'bold'"); + assert.ok(allPlain.includes("code"), "Should preserve 'code'"); + + const allOutput = lines.join("\n"); + + // Should have bold styling (\x1b[1m) + assert.ok(allOutput.includes("\x1b[1m"), "Should have bold styling"); + + // Should have code styling (yellow = \x1b[33m from defaultMarkdownTheme) + assert.ok(allOutput.includes("\x1b[33m"), "Should have code styling (yellow)"); + + // Should have italic from quote styling (\x1b[3m) + assert.ok(allOutput.includes("\x1b[3m"), "Should have italic from quote styling"); + }); + }); + + describe("Heading with inline code", () => { + it("should preserve heading styling after inline code", () => { + const markdown = new Markdown("### Why `sourceInfo` should not be optional", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const joinedOutput = lines.join("\n"); + + // The heading theme is bold+cyan. After the yellow inline code, the heading + // styling (bold+cyan) must be restored so subsequent text is styled correctly. + // bold = \x1b[1m, cyan = \x1b[36m, yellow = \x1b[33m + assert.ok(joinedOutput.includes("\x1b[33m"), "Should have yellow for inline code"); + + // Find the position of "should not be optional" in the raw output. + // It must be preceded by heading style codes (bold+cyan), not appear unstyled. + const afterCodeIndex = joinedOutput.indexOf("should not be optional"); + assert.ok(afterCodeIndex > 0, "Should contain text after inline code"); + + // Look at the ANSI codes between the code span end and "should not be optional". + // There should be bold (\x1b[1m) and cyan (\x1b[36m) re-applied. + const precedingChunk = joinedOutput.slice(Math.max(0, afterCodeIndex - 40), afterCodeIndex); + assert.ok( + precedingChunk.includes("\x1b[1m"), + `Should re-apply bold before text after code: ${precedingChunk}`, + ); + assert.ok( + precedingChunk.includes("\x1b[36m"), + `Should re-apply cyan before text after code: ${precedingChunk}`, + ); + }); + + it("should preserve heading styling after inline code for h1", () => { + const markdown = new Markdown("# Title with `code` inside", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const joinedOutput = lines.join("\n"); + + const afterCodeIndex = joinedOutput.indexOf("inside"); + assert.ok(afterCodeIndex > 0, "Should contain text after inline code"); + + const precedingChunk = joinedOutput.slice(Math.max(0, afterCodeIndex - 40), afterCodeIndex); + // H1 uses heading + bold + underline + assert.ok(precedingChunk.includes("\x1b[1m"), `Should re-apply bold for h1: ${precedingChunk}`); + assert.ok(precedingChunk.includes("\x1b[36m"), `Should re-apply cyan for h1: ${precedingChunk}`); + assert.ok(precedingChunk.includes("\x1b[4m"), `Should re-apply underline for h1: ${precedingChunk}`); + }); + + it("should not leak h1 underline into padding when inline code is the last token", async () => { + const markdown = new Markdown("# Important distinction from `open()`", 0, 0, defaultMarkdownTheme); + const terminal = new VirtualTerminal(80, 4); + const tui = new TUI(terminal); + tui.addChild(markdown); + tui.start(); + await terminal.waitForRender(); + + const renderedLine = markdown.render(80)[0]; + assert.ok(renderedLine, "Should render heading line"); + const contentWidth = renderedLine.replace(/\x1b\[[0-9;]*m/g, "").trimEnd().length; + assert.ok(contentWidth > 0, "Should have visible heading content"); + + for (let col = contentWidth; col < 80; col++) { + assert.strictEqual(getCellUnderline(terminal, 0, col), 0, `Expected no underline in padding at col ${col}`); + } + + tui.stop(); + }); + + it("should preserve heading styling after bold text", () => { + const markdown = new Markdown("## Heading with **bold** and more", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const joinedOutput = lines.join("\n"); + + const afterBoldIndex = joinedOutput.indexOf("and more"); + assert.ok(afterBoldIndex > 0, "Should contain text after bold"); + + const precedingChunk = joinedOutput.slice(Math.max(0, afterBoldIndex - 40), afterBoldIndex); + assert.ok(precedingChunk.includes("\x1b[1m"), `Should re-apply bold for h2: ${precedingChunk}`); + assert.ok(precedingChunk.includes("\x1b[36m"), `Should re-apply cyan for h2: ${precedingChunk}`); + }); + }); + + describe("Strikethrough syntax", () => { + it("should render ~~text~~ as strikethrough", () => { + const markdown = new Markdown("Use ~~strikethrough~~ here", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const joinedOutput = lines.join("\n"); + const joinedPlain = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")).join(" "); + + assert.ok(joinedOutput.includes("\x1b[9m"), "Should apply strikethrough styling"); + assert.ok(joinedPlain.includes("strikethrough"), "Should include struck text content"); + assert.ok(!joinedPlain.includes("~~strikethrough~~"), "Should not render delimiters as text"); + }); + + it("should keep ~text~ as plain text", () => { + const markdown = new Markdown("Use ~strikethrough~ literally", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const joinedOutput = lines.join("\n"); + const joinedPlain = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")).join(" "); + + assert.ok(joinedPlain.includes("~strikethrough~"), "Single-tilde delimiters should remain visible"); + assert.ok(!joinedOutput.includes("\x1b[9m"), "Single-tilde text should not use strikethrough styling"); + }); + }); + + describe("Links", () => { + afterEach(() => { + resetCapabilitiesCache(); + }); + + it("should not duplicate URL for autolinked emails", () => { + // Hyperlinks capability does not affect the mailto: display check. + setCapabilities({ images: null, trueColor: false, hyperlinks: false }); + const markdown = new Markdown("Contact user@example.com for help", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const joinedPlain = plainLines.join(" "); + + // Should contain the email once, not duplicated with mailto: + assert.ok(joinedPlain.includes("user@example.com"), "Should contain email"); + assert.ok(!joinedPlain.includes("mailto:"), "Should not show mailto: prefix for autolinked emails"); + }); + + it("should not duplicate URL for bare URLs", () => { + setCapabilities({ images: null, trueColor: false, hyperlinks: false }); + const markdown = new Markdown("Visit https://example.com for more", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const joinedPlain = plainLines.join(" "); + + // URL should appear only once + const urlCount = (joinedPlain.match(/https:\/\/example\.com/g) || []).length; + assert.strictEqual(urlCount, 1, "URL should appear exactly once"); + }); + + it("should show URL in parentheses when hyperlinks are not supported", () => { + setCapabilities({ images: null, trueColor: false, hyperlinks: false }); + const markdown = new Markdown("[click here](https://example.com)", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const joinedPlain = plainLines.join(" "); + + assert.ok(joinedPlain.includes("click here"), "Should contain link text"); + assert.ok(joinedPlain.includes("(https://example.com)"), "Should show URL in parentheses"); + }); + + it("should show mailto URL in parentheses when hyperlinks are not supported", () => { + setCapabilities({ images: null, trueColor: false, hyperlinks: false }); + const markdown = new Markdown("[Email me](mailto:test@example.com)", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const joinedPlain = plainLines.join(" "); + + assert.ok(joinedPlain.includes("Email me"), "Should contain link text"); + assert.ok(joinedPlain.includes("(mailto:test@example.com)"), "Should show mailto URL in parentheses"); + }); + + it("should emit OSC 8 hyperlink sequence when terminal supports hyperlinks", () => { + setCapabilities({ images: null, trueColor: false, hyperlinks: true }); + const markdown = new Markdown("[click here](https://example.com)", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const joined = lines.join(""); + + // OSC 8 open: ESC ] 8 ; ; ESC \ + assert.ok(joined.includes("\x1b]8;;https://example.com\x1b\\"), "Should contain OSC 8 open sequence"); + // OSC 8 close: ESC ] 8 ; ; ESC \ + assert.ok(joined.includes("\x1b]8;;\x1b\\"), "Should contain OSC 8 close sequence"); + // Visible text is present + const plainLines = lines.map((line) => line.replace(/\x1b[^a-zA-Z]*[a-zA-Z]|\x1b\].*?\x1b\\/g, "")); + assert.ok(plainLines.join("").includes("click here"), "Should contain link text"); + // URL is NOT printed inline as plain text + const rawPlain = lines.map((line) => + line.replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, ""), + ); + assert.ok(!rawPlain.join("").includes("(https://example.com)"), "URL should not appear inline in parentheses"); + }); + + it("should use OSC 8 for mailto links when terminal supports hyperlinks", () => { + setCapabilities({ images: null, trueColor: false, hyperlinks: true }); + const markdown = new Markdown("[Email me](mailto:test@example.com)", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const joined = lines.join(""); + + assert.ok( + joined.includes("\x1b]8;;mailto:test@example.com\x1b\\"), + "Should contain OSC 8 open with mailto URL", + ); + assert.ok(joined.includes("\x1b]8;;\x1b\\"), "Should contain OSC 8 close sequence"); + }); + + it("should use OSC 8 for bare URLs when terminal supports hyperlinks", () => { + setCapabilities({ images: null, trueColor: false, hyperlinks: true }); + const markdown = new Markdown("Visit https://example.com for more", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const joined = lines.join(""); + + assert.ok(joined.includes("\x1b]8;;https://example.com\x1b\\"), "Should contain OSC 8 hyperlink"); + // URL should not also appear as raw parenthetical text + const rawPlain = lines.map((line) => + line.replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, ""), + ); + assert.ok(!rawPlain.join("").includes("(https://example.com)"), "URL should not appear twice"); + }); + }); + + describe("HTML-like tags in text", () => { + it("should render content with HTML-like tags as text", () => { + // When the model emits something like content in regular text, + // marked might treat it as HTML and hide the content + const markdown = new Markdown( + "This is text with hidden content that should be visible", + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const joinedPlain = plainLines.join(" "); + + // The content inside the tags should be visible + assert.ok( + joinedPlain.includes("hidden content") || joinedPlain.includes(""), + "Should render HTML-like tags or their content as text, not hide them", + ); + }); + + it("should render HTML tags in code blocks correctly", () => { + const markdown = new Markdown("```html\n
Some HTML
\n```", 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80); + const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")); + const joinedPlain = plainLines.join("\n"); + + // HTML in code blocks should be visible + assert.ok( + joinedPlain.includes("
") && joinedPlain.includes("
"), + "Should render HTML in code blocks", + ); + }); + }); + + describe("Streaming code fences", () => { + it("stabilizes partial closing fence rendering", () => { + const cases = [ + { + input: "```ts\nconst x = 1;\n``", + expected: ["```ts", " const x = 1;", "```"], + }, + { + input: "```md\nnot a closing fence:\n``\n```", + expected: ["```md", " not a closing fence:", " ``", "```"], + }, + { + input: "```ts\n``", + expected: ["```ts", "", "```"], + }, + { + input: "````\n```", + expected: ["```", "", "```"], + }, + { + input: "~~~~~\n~~~~", + expected: ["```", "", "```"], + }, + { + input: "```md\nnot a closing fence:\n``\n```\n\nafter", + expected: ["```md", " not a closing fence:", " ``", "```", "", "after"], + }, + ]; + + for (const { input, expected } of cases) { + const markdown = new Markdown(input, 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, expected); + } + + const partial = new Markdown("```ts\nconst x = 1;\n``", 0, 0, defaultMarkdownTheme); + const complete = new Markdown("```ts\nconst x = 1;\n```", 0, 0, defaultMarkdownTheme); + + assert.strictEqual(partial.render(80).length, complete.render(80).length); + }); + }); +}); diff --git a/packages/pi-tui/test/overlay-non-capturing.test.ts b/packages/pi-tui/test/overlay-non-capturing.test.ts new file mode 100644 index 000000000..64ce62039 --- /dev/null +++ b/packages/pi-tui/test/overlay-non-capturing.test.ts @@ -0,0 +1,1202 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import type { Component, Focusable } from "../src/tui.ts"; +import { Container, TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class StaticOverlay implements Component { + private lines: string[]; + + constructor(lines: string[]) { + this.lines = lines; + } + + render(): string[] { + return this.lines; + } + + invalidate(): void {} +} + +class EmptyContent implements Component { + render(): string[] { + return []; + } + invalidate(): void {} +} + +class FocusableOverlay implements Component, Focusable { + focused = false; + inputs: string[] = []; + private lines: string[]; + + constructor(lines: string[]) { + this.lines = lines; + } + + handleInput(data: string): void { + this.inputs.push(data); + } + + render(): string[] { + return this.lines; + } + + invalidate(): void {} +} + +async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise { + tui.requestRender(true); + await new Promise((resolve) => process.nextTick(resolve)); + await terminal.waitForRender(); +} + +describe("TUI overlay non-capturing", () => { + describe("focus management", () => { + it("non-capturing overlay preserves focus on creation", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay, { nonCapturing: true }); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(overlay.focused, false); + } finally { + tui.stop(); + } + }); + + it("focus() transfers focus to the overlay", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(overlay, { nonCapturing: true }); + handle.focus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, false); + assert.strictEqual(overlay.focused, true); + assert.strictEqual(handle.isFocused(), true); + } finally { + tui.stop(); + } + }); + + it("unfocus() restores previous focus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(overlay, { nonCapturing: true }); + handle.focus(); + handle.unfocus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(overlay.focused, false); + assert.strictEqual(handle.isFocused(), false); + } finally { + tui.stop(); + } + }); + + it("setHidden(false) on non-capturing overlay does not auto-focus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(overlay, { nonCapturing: true }); + handle.setHidden(true); + handle.setHidden(false); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(overlay.focused, false); + } finally { + tui.stop(); + } + }); + + it("hide() when overlay is not focused does not change focus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(overlay, { nonCapturing: true }); + handle.hide(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("hide() when focused restores focus correctly", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(overlay, { nonCapturing: true }); + handle.focus(); + handle.hide(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(overlay.focused, false); + } finally { + tui.stop(); + } + }); + + it("capturing overlay removed with non-capturing below restores focus to editor", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const nonCapturing = new FocusableOverlay(["NC"]); + const capturing = new FocusableOverlay(["CAP"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(nonCapturing, { nonCapturing: true }); + const handle = tui.showOverlay(capturing); + assert.strictEqual(capturing.focused, true); + handle.hide(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(nonCapturing.focused, false); + } finally { + tui.stop(); + } + }); + + it("sub-overlay cleanup then hideOverlay restores focus and input to editor", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const timer = new FocusableOverlay(["TIMER"]); + const controller = new FocusableOverlay(["CTRL"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const timerHandle = tui.showOverlay(timer, { nonCapturing: true }); + tui.showOverlay(controller); + assert.strictEqual(controller.focused, true); + assert.strictEqual(editor.focused, false); + timerHandle.hide(); + tui.hideOverlay(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(controller.focused, false); + assert.strictEqual(timer.focused, false); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(controller.inputs, []); + assert.deepStrictEqual(timer.inputs, []); + } finally { + tui.stop(); + } + }); + + it("removed focused child overlay does not become parent overlay fallback", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const child = new FocusableOverlay(["CHILD"]); + const parent = new FocusableOverlay(["PARENT"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const childHandle = tui.showOverlay(child, { nonCapturing: true }); + childHandle.focus(); + const parentHandle = tui.showOverlay(parent); + assert.strictEqual(parent.focused, true); + + childHandle.hide(); + parentHandle.hide(); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(child.inputs, []); + assert.deepStrictEqual(parent.inputs, []); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("microtask-deferred sub-overlay pattern (showExtensionCustom simulation) restores focus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const timer = new FocusableOverlay(["TIMER"]); + const controller = new FocusableOverlay(["CTRL"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + // Simulate showExtensionCustom: factory creates timer synchronously, + // then .then() pushes controller as a microtask + let timerHandle: ReturnType | null = null; + let doneFn: () => void = () => { + throw new Error("doneFn was not initialized"); + }; + + const overlayPromise = new Promise((resolve) => { + doneFn = () => { + if (!timerHandle) throw new Error("timerHandle was not initialized"); + timerHandle.hide(); + tui.hideOverlay(); + resolve(); + }; + timerHandle = tui.showOverlay(timer, { nonCapturing: true }); + // .then() runs as microtask — same as showExtensionCustom + Promise.resolve(controller).then((c) => { + tui.showOverlay(c); + }); + }); + + await Promise.resolve(); + await renderAndFlush(tui, terminal); + + assert.strictEqual(controller.focused, true); + assert.strictEqual(editor.focused, false); + + // Simulate Esc: cleanup + close (from inside handleInput) + doneFn(); + // Now await the promise (simulating showExtensionCustom resolving) + await overlayPromise; + await renderAndFlush(tui, terminal); + + assert.strictEqual(editor.focused, true, "editor should regain focus"); + assert.strictEqual(controller.focused, false); + assert.strictEqual(timer.focused, false); + + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"], "editor should receive input after close"); + assert.deepStrictEqual(controller.inputs, []); + } finally { + tui.stop(); + } + }); + + it("handleInput redirection skips non-capturing overlays when focused overlay becomes invisible", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const fallbackCapturing = new FocusableOverlay(["FALLBACK"]); + const nonCapturing = new FocusableOverlay(["NC"]); + const primary = new FocusableOverlay(["PRIMARY"]); + let isVisible = true; + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(fallbackCapturing); + tui.showOverlay(nonCapturing, { nonCapturing: true }); + tui.showOverlay(primary, { visible: () => isVisible }); + assert.strictEqual(primary.focused, true); + isVisible = false; + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(primary.inputs, []); + assert.deepStrictEqual(nonCapturing.inputs, []); + assert.deepStrictEqual(fallbackCapturing.inputs, ["x"]); + assert.strictEqual(fallbackCapturing.focused, true); + } finally { + tui.stop(); + } + }); + + it("active base focus replacement receives close input before overlay restore", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") { + tui.setFocus(replacement); + } + }; + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") { + tui.setFocus(editor); + } + }; + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay); + assert.strictEqual(overlay.focused, true); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + assert.strictEqual(replacement.focused, true); + + terminal.sendInput("\r"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(overlay.inputs, ["b"]); + assert.strictEqual(overlay.focused, true); + + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, ["b", "x"]); + } finally { + tui.stop(); + } + }); + + it("active replacement still receives input when it is another overlay preFocus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const passive = new FocusableOverlay(["PASSIVE"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") { + tui.setFocus(replacement); + } + }; + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") { + tui.setFocus(editor); + } + }; + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.setFocus(replacement); + tui.showOverlay(passive, { nonCapturing: true }); + tui.setFocus(editor); + tui.showOverlay(overlay); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + assert.strictEqual(replacement.focused, true); + + terminal.sendInput("1"); + terminal.sendInput("\r"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(replacement.inputs, ["1", "\r"]); + assert.deepStrictEqual(overlay.inputs, ["b"]); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("blocked replacement can move focus internally before overlay restore", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const base = new Container(); + const editor = new FocusableOverlay(["EDITOR"]); + const firstReplacement = new FocusableOverlay(["FIRST"]); + const secondReplacement = new FocusableOverlay(["SECOND"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") tui.setFocus(firstReplacement); + }; + firstReplacement.handleInput = (data: string) => { + firstReplacement.inputs.push(data); + if (data === "n") tui.setFocus(secondReplacement); + }; + secondReplacement.handleInput = (data: string) => { + secondReplacement.inputs.push(data); + if (data === "\r") { + base.clear(); + base.addChild(editor); + tui.setFocus(editor); + } + }; + base.addChild(editor); + base.addChild(firstReplacement); + base.addChild(secondReplacement); + tui.addChild(base); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + terminal.sendInput("n"); + await renderAndFlush(tui, terminal); + terminal.sendInput("2"); + terminal.sendInput("\r"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(overlay.inputs, ["b"]); + assert.deepStrictEqual(firstReplacement.inputs, ["n"]); + assert.deepStrictEqual(secondReplacement.inputs, ["2", "\r"]); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("removed replacement restores overlay even when overlay preFocus differs from next focus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const base = new Container(); + const editor = new FocusableOverlay(["EDITOR"]); + const palette = new FocusableOverlay(["PALETTE"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") tui.setFocus(replacement); + }; + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") { + base.clear(); + base.addChild(editor); + tui.setFocus(editor); + } + }; + base.addChild(editor); + base.addChild(palette); + base.addChild(replacement); + tui.addChild(base); + tui.setFocus(palette); + tui.start(); + try { + tui.showOverlay(overlay); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + terminal.sendInput("\r"); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(overlay.inputs, ["b", "x"]); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(editor.inputs, []); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("unfocus target releases a blocked overlay while replacement remains focused", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const fallback = new FocusableOverlay(["FALLBACK"]); + const target = new FocusableOverlay(["TARGET"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") tui.setFocus(fallback); + }; + tui.addChild(new EmptyContent()); + tui.start(); + try { + const overlayHandle = tui.showOverlay(overlay); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") { + tui.setFocus(replacement); + overlayHandle.unfocus({ target }); + } + }; + + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + assert.strictEqual(replacement.focused, true); + terminal.sendInput("\r"); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(overlay.inputs, ["b"]); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(fallback.inputs, []); + assert.deepStrictEqual(target.inputs, ["x"]); + } finally { + tui.stop(); + } + }); + + it("handleInput restores focus to a visible focused overlay after base focus steal", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay); + assert.strictEqual(overlay.focused, true); + tui.setFocus(replacement); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, ["x"]); + assert.deepStrictEqual(editor.inputs, []); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("handleInput restores focus to explicitly focused raw sub-overlay after base focus steal", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const controller = new FocusableOverlay(["CONTROLLER"]); + const subOverlay = new FocusableOverlay(["SUB"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(controller); + const subHandle = tui.showOverlay(subOverlay, { nonCapturing: true }); + subHandle.focus(); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(subOverlay.inputs, ["x"]); + assert.deepStrictEqual(controller.inputs, []); + assert.deepStrictEqual(editor.inputs, []); + } finally { + tui.stop(); + } + }); + + it("passive non-capturing overlay does not regain input after base focus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const passive = new FocusableOverlay(["PASSIVE"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(passive, { nonCapturing: true }); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(passive.inputs, []); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("explicitly focused non-capturing overlay regains input after base focus steal", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["NC"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(overlay, { nonCapturing: true }); + handle.focus(); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, ["x"]); + assert.deepStrictEqual(editor.inputs, []); + } finally { + tui.stop(); + } + }); + + it("unfocus() prevents visible overlay from regaining input", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(overlay); + handle.unfocus(); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(overlay.inputs, []); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("setFocus(null) explicitly clears visible overlay restore", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(overlay); + tui.setFocus(null); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, []); + assert.strictEqual(overlay.focused, false); + } finally { + tui.stop(); + } + }); + + it("blocked replacement setFocus(null) resumes the visible overlay", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") tui.setFocus(null); + }; + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") tui.setFocus(replacement); + }; + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(overlay); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + terminal.sendInput("\r"); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(overlay.inputs, ["b", "x"]); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("temporarily invisible focused overlay falls back without losing restore eligibility", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + let visible = true; + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay, { visible: () => visible }); + tui.setFocus(editor); + visible = false; + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(overlay.inputs, []); + visible = true; + terminal.sendInput("y"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(overlay.inputs, ["y"]); + } finally { + tui.stop(); + } + }); + + it("temporarily invisible focused overlay with null preFocus restores when visible again", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new FocusableOverlay(["OVERLAY"]); + let visible = true; + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(overlay, { visible: () => visible }); + visible = false; + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, []); + visible = true; + terminal.sendInput("y"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, ["y"]); + } finally { + tui.stop(); + } + }); + + it("cyclic overlay preFocus ancestry does not hang focus changes", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(overlay); + tui.start(); + try { + const handle = tui.showOverlay(overlay, { nonCapturing: true }); + handle.focus(); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(overlay.inputs, []); + } finally { + tui.stop(); + } + }); + + it("handleInput restores the focus-order top overlay after base focus steal", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const lower = new FocusableOverlay(["LOWER"]); + const upper = new FocusableOverlay(["UPPER"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const lowerHandle = tui.showOverlay(lower); + tui.showOverlay(upper); + lowerHandle.focus(); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(lower.inputs, ["x"]); + assert.deepStrictEqual(upper.inputs, []); + assert.deepStrictEqual(editor.inputs, []); + } finally { + tui.stop(); + } + }); + + it("hideOverlay() does not reassign focus when topmost overlay is non-capturing", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const capturing = new FocusableOverlay(["CAP"]); + const nonCapturing = new FocusableOverlay(["NC"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(capturing); + tui.showOverlay(nonCapturing, { nonCapturing: true }); + assert.strictEqual(capturing.focused, true); + tui.hideOverlay(); + await renderAndFlush(tui, terminal); + assert.strictEqual(capturing.focused, true); + } finally { + tui.stop(); + } + }); + + it("multiple capturing and non-capturing overlays restore focus through removals", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const c1 = new FocusableOverlay(["C1"]); + const n1 = new FocusableOverlay(["N1"]); + const c2 = new FocusableOverlay(["C2"]); + const n2 = new FocusableOverlay(["N2"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const c1Handle = tui.showOverlay(c1); + tui.showOverlay(n1, { nonCapturing: true }); + const c2Handle = tui.showOverlay(c2); + tui.showOverlay(n2, { nonCapturing: true }); + assert.strictEqual(c2.focused, true); + c2Handle.hide(); + await renderAndFlush(tui, terminal); + assert.strictEqual(c1.focused, true); + c1Handle.hide(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("capturing overlay unfocus() on topmost capturing overlay falls back to preFocus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const capturing = new FocusableOverlay(["CAP"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(capturing); + assert.strictEqual(capturing.focused, true); + handle.unfocus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(capturing.focused, false); + } finally { + tui.stop(); + } + }); + }); + + describe("no-op guards", () => { + it("focus() on hidden overlay is a no-op", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(overlay, { nonCapturing: true }); + handle.setHidden(true); + handle.focus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(handle.isFocused(), false); + } finally { + tui.stop(); + } + }); + + it("focus() after hide() is a no-op", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(overlay, { nonCapturing: true }); + handle.hide(); + handle.focus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(handle.isFocused(), false); + } finally { + tui.stop(); + } + }); + + it("unfocus() when overlay does not have focus is a no-op", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(overlay, { nonCapturing: true }); + handle.unfocus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(overlay.focused, false); + } finally { + tui.stop(); + } + }); + + it("unfocus() with null preFocus clears focus and does not route input back to overlay", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.start(); + try { + const handle = tui.showOverlay(overlay); + assert.strictEqual(overlay.focused, true); + handle.unfocus(); + assert.strictEqual(overlay.focused, false); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, []); + assert.strictEqual(handle.isFocused(), false); + } finally { + tui.stop(); + } + }); + }); + + describe("focus cycle prevention", () => { + it("toggle focus between non-capturing overlays then unfocus returns to editor", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const a = new FocusableOverlay(["A"]); + const b = new FocusableOverlay(["B"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const aHandle = tui.showOverlay(a, { nonCapturing: true }); + const bHandle = tui.showOverlay(b, { nonCapturing: true }); + aHandle.focus(); + bHandle.focus(); + aHandle.focus(); + aHandle.unfocus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(a.focused, false); + assert.strictEqual(b.focused, false); + } finally { + tui.stop(); + } + }); + + it("explicit unfocus target supports cycling between three overlays and editor", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const a = new FocusableOverlay(["A"]); + const b = new FocusableOverlay(["B"]); + const c = new FocusableOverlay(["C"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const aHandle = tui.showOverlay(a); + const bHandle = tui.showOverlay(b); + const cHandle = tui.showOverlay(c); + + aHandle.focus(); + terminal.sendInput("a"); + await renderAndFlush(tui, terminal); + bHandle.focus(); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + cHandle.focus(); + terminal.sendInput("c"); + await renderAndFlush(tui, terminal); + cHandle.unfocus({ target: editor }); + terminal.sendInput("e"); + await renderAndFlush(tui, terminal); + aHandle.focus(); + terminal.sendInput("A"); + await renderAndFlush(tui, terminal); + aHandle.unfocus({ target: editor }); + terminal.sendInput("E"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(a.inputs, ["a", "A"]); + assert.deepStrictEqual(b.inputs, ["b"]); + assert.deepStrictEqual(c.inputs, ["c"]); + assert.deepStrictEqual(editor.inputs, ["e", "E"]); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("explicit null unfocus target clears focus without restoring overlays", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.start(); + try { + const handle = tui.showOverlay(overlay); + handle.unfocus({ target: null }); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, []); + assert.strictEqual(handle.isFocused(), false); + } finally { + tui.stop(); + } + }); + + it("hiding focused overlay falls back to next visual-frontmost overlay", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const a = new FocusableOverlay(["A"]); + const b = new FocusableOverlay(["B"]); + const c = new FocusableOverlay(["C"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const aHandle = tui.showOverlay(a); + const bHandle = tui.showOverlay(b); + tui.showOverlay(c); + aHandle.focus(); + bHandle.focus(); + bHandle.setHidden(true); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(a.inputs, ["x"]); + assert.deepStrictEqual(c.inputs, []); + assert.strictEqual(a.focused, true); + } finally { + tui.stop(); + } + }); + }); + + describe("rendering order", () => { + it("focus() on already-focused overlay bumps visual order", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const aHandle = tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + aHandle.focus(); + tui.showOverlay(new StaticOverlay(["C"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "C"); + aHandle.focus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A"); + assert.strictEqual(aHandle.isFocused(), true); + } finally { + tui.stop(); + } + }); + + it("default rendering order for overlapping overlays follows creation order", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TUI(terminal); + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B"); + } finally { + tui.stop(); + } + }); + + it("focus() on lower overlay renders it on top", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TUI(terminal); + tui.addChild(new EmptyContent()); + tui.start(); + try { + const lower = tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B"); + lower.focus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A"); + } finally { + tui.stop(); + } + }); + + it("focusing middle overlay places it on top while preserving others relative order", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TUI(terminal); + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + const middle = tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + const top = tui.showOverlay(new StaticOverlay(["C"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "C"); + middle.focus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B"); + middle.hide(); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "C"); + top.hide(); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A"); + } finally { + tui.stop(); + } + }); + + it("capturing overlay hidden and shown again renders on top after unhide", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TUI(terminal); + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + const capturing = tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1 }); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B"); + capturing.setHidden(true); + tui.showOverlay(new StaticOverlay(["C"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "C"); + capturing.setHidden(false); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B"); + } finally { + tui.stop(); + } + }); + + it("unfocus() does not change visual order until another overlay is focused", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const a = tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + const b = tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B"); + a.focus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A"); + a.unfocus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A"); + b.focus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B"); + } finally { + tui.stop(); + } + }); + }); +}); diff --git a/packages/pi-tui/test/overlay-options.test.ts b/packages/pi-tui/test/overlay-options.test.ts new file mode 100644 index 000000000..c93f26cbd --- /dev/null +++ b/packages/pi-tui/test/overlay-options.test.ts @@ -0,0 +1,541 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import type { Component } from "../src/tui.ts"; +import { TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class StaticOverlay implements Component { + private lines: string[]; + requestedWidth?: number; + + constructor(lines: string[], requestedWidth?: number) { + this.lines = lines; + this.requestedWidth = requestedWidth; + } + + render(width: number): string[] { + // Store the width we were asked to render at for verification + this.requestedWidth = width; + return this.lines; + } + + invalidate(): void {} +} + +class EmptyContent implements Component { + render(): string[] { + return []; + } + invalidate(): void {} +} + +async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise { + tui.requestRender(true); + await new Promise((resolve) => process.nextTick(resolve)); + await terminal.waitForRender(); +} + +describe("TUI overlay options", () => { + describe("width overflow protection", () => { + it("should truncate overlay lines that exceed declared width", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + // Overlay declares width 20 but renders lines much wider + const overlay = new StaticOverlay(["X".repeat(100)]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { width: 20 }); + tui.start(); + await renderAndFlush(tui, terminal); + + // Should not crash, and no line should exceed terminal width + const viewport = terminal.getViewport(); + for (const line of viewport) { + // visibleWidth not available here, but line length is a rough check + // The important thing is it didn't crash + assert.ok(line !== undefined); + } + tui.stop(); + }); + + it("should handle overlay with complex ANSI sequences without crashing", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + // Simulate complex ANSI content like the crash log showed + const complexLine = + "\x1b[48;2;40;50;40m \x1b[38;2;128;128;128mSome styled content\x1b[39m\x1b[49m" + + "\x1b]8;;http://example.com\x07link\x1b]8;;\x07" + + " more content ".repeat(10); + const overlay = new StaticOverlay([complexLine, complexLine, complexLine]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { width: 60 }); + tui.start(); + await renderAndFlush(tui, terminal); + + // Should not crash + const viewport = terminal.getViewport(); + assert.ok(viewport.length > 0); + tui.stop(); + }); + + it("should handle overlay composited on styled base content", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + + // Base content with styling + class StyledContent implements Component { + render(width: number): string[] { + const styledLine = `\x1b[1m\x1b[38;2;255;0;0m${"X".repeat(width)}\x1b[0m`; + return [styledLine, styledLine, styledLine]; + } + invalidate(): void {} + } + + const overlay = new StaticOverlay(["OVERLAY"]); + + tui.addChild(new StyledContent()); + tui.showOverlay(overlay, { width: 20, anchor: "center" }); + tui.start(); + await renderAndFlush(tui, terminal); + + // Should not crash and overlay should be visible + const viewport = terminal.getViewport(); + const hasOverlay = viewport.some((line) => line?.includes("OVERLAY")); + assert.ok(hasOverlay, "Overlay should be visible"); + tui.stop(); + }); + + it("should handle wide characters at overlay boundary", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + // Wide chars (each takes 2 columns) at the edge of declared width + const wideCharLine = "中文日本語한글テスト漢字"; // Mix of CJK chars + const overlay = new StaticOverlay([wideCharLine]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { width: 15 }); // Odd width to potentially hit boundary + tui.start(); + await renderAndFlush(tui, terminal); + + // Should not crash + const viewport = terminal.getViewport(); + assert.ok(viewport.length > 0); + tui.stop(); + }); + + it("should handle overlay positioned at terminal edge", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + // Overlay positioned at right edge with content that exceeds declared width + const overlay = new StaticOverlay(["X".repeat(50)]); + + tui.addChild(new EmptyContent()); + // Position at col 60 with width 20 - should fit exactly at right edge + tui.showOverlay(overlay, { col: 60, width: 20 }); + tui.start(); + await renderAndFlush(tui, terminal); + + // Should not crash + const viewport = terminal.getViewport(); + assert.ok(viewport.length > 0); + tui.stop(); + }); + + it("should handle overlay on base content with OSC sequences", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + + // Base content with OSC 8 hyperlinks (like file paths in agent output) + class HyperlinkContent implements Component { + render(width: number): string[] { + const link = `\x1b]8;;file:///path/to/file.ts\x07file.ts\x1b]8;;\x07`; + const line = `See ${link} for details ${"X".repeat(width - 30)}`; + return [line, line, line]; + } + invalidate(): void {} + } + + const overlay = new StaticOverlay(["OVERLAY-TEXT"]); + + tui.addChild(new HyperlinkContent()); + tui.showOverlay(overlay, { anchor: "center", width: 20 }); + tui.start(); + await renderAndFlush(tui, terminal); + + // Should not crash - this was the original bug scenario + const viewport = terminal.getViewport(); + assert.ok(viewport.length > 0); + tui.stop(); + }); + }); + + describe("width percentage", () => { + it("should render overlay at percentage of terminal width", async () => { + const terminal = new VirtualTerminal(100, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["test"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { width: "50%" }); + tui.start(); + await renderAndFlush(tui, terminal); + + assert.strictEqual(overlay.requestedWidth, 50); + tui.stop(); + }); + + it("should respect minWidth when widthPercent results in smaller width", async () => { + const terminal = new VirtualTerminal(100, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["test"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { width: "10%", minWidth: 30 }); + tui.start(); + await renderAndFlush(tui, terminal); + + assert.strictEqual(overlay.requestedWidth, 30); + tui.stop(); + }); + }); + + describe("anchor positioning", () => { + it("should position overlay at top-left", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["TOP-LEFT"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { anchor: "top-left", width: 10 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.startsWith("TOP-LEFT"), `Expected TOP-LEFT at start, got: ${viewport[0]}`); + tui.stop(); + }); + + it("should position overlay at bottom-right", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["BTM-RIGHT"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { anchor: "bottom-right", width: 10 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + // Should be on last row, ending at last column + const lastRow = viewport[23]; + assert.ok(lastRow?.includes("BTM-RIGHT"), `Expected BTM-RIGHT on last row, got: ${lastRow}`); + assert.ok(lastRow?.trimEnd().endsWith("BTM-RIGHT"), `Expected BTM-RIGHT at end, got: ${lastRow}`); + tui.stop(); + }); + + it("should position overlay at top-center", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["CENTERED"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { anchor: "top-center", width: 10 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + // Should be on first row, centered horizontally + const firstRow = viewport[0]; + assert.ok(firstRow?.includes("CENTERED"), `Expected CENTERED on first row, got: ${firstRow}`); + // Check it's roughly centered (col 35 for width 10 in 80 col terminal) + const colIndex = firstRow?.indexOf("CENTERED") ?? -1; + assert.ok(colIndex >= 30 && colIndex <= 40, `Expected centered, got col ${colIndex}`); + tui.stop(); + }); + }); + + describe("margin", () => { + it("should clamp negative margins to zero", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["NEG-MARGIN"]); + + tui.addChild(new EmptyContent()); + // Negative margins should be treated as 0 + tui.showOverlay(overlay, { + anchor: "top-left", + width: 12, + margin: { top: -5, left: -10, right: 0, bottom: 0 }, + }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + // Should be at row 0, col 0 (negative margins clamped to 0) + assert.ok(viewport[0]?.startsWith("NEG-MARGIN"), `Expected NEG-MARGIN at start of row 0, got: ${viewport[0]}`); + tui.stop(); + }); + + it("should respect margin as number", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["MARGIN"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { anchor: "top-left", width: 10, margin: 5 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + // Should be on row 5 (not 0) due to margin + assert.ok(!viewport[0]?.includes("MARGIN"), "Should not be on row 0"); + assert.ok(!viewport[4]?.includes("MARGIN"), "Should not be on row 4"); + assert.ok(viewport[5]?.includes("MARGIN"), `Expected MARGIN on row 5, got: ${viewport[5]}`); + // Should start at col 5 (not 0) + const colIndex = viewport[5]?.indexOf("MARGIN") ?? -1; + assert.strictEqual(colIndex, 5, `Expected col 5, got ${colIndex}`); + tui.stop(); + }); + + it("should respect margin object", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["MARGIN"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { + anchor: "top-left", + width: 10, + margin: { top: 2, left: 3, right: 0, bottom: 0 }, + }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + assert.ok(viewport[2]?.includes("MARGIN"), `Expected MARGIN on row 2, got: ${viewport[2]}`); + const colIndex = viewport[2]?.indexOf("MARGIN") ?? -1; + assert.strictEqual(colIndex, 3, `Expected col 3, got ${colIndex}`); + tui.stop(); + }); + }); + + describe("offset", () => { + it("should apply offsetX and offsetY from anchor position", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["OFFSET"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { anchor: "top-left", width: 10, offsetX: 10, offsetY: 5 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + assert.ok(viewport[5]?.includes("OFFSET"), `Expected OFFSET on row 5, got: ${viewport[5]}`); + const colIndex = viewport[5]?.indexOf("OFFSET") ?? -1; + assert.strictEqual(colIndex, 10, `Expected col 10, got ${colIndex}`); + tui.stop(); + }); + }); + + describe("percentage positioning", () => { + it("should position with rowPercent and colPercent", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["PCT"]); + + tui.addChild(new EmptyContent()); + // 50% should center both ways + tui.showOverlay(overlay, { width: 10, row: "50%", col: "50%" }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + // Find the row with PCT + let foundRow = -1; + for (let i = 0; i < viewport.length; i++) { + if (viewport[i]?.includes("PCT")) { + foundRow = i; + break; + } + } + // Should be roughly centered vertically (row ~11-12 for 24 row terminal) + assert.ok(foundRow >= 10 && foundRow <= 13, `Expected centered row, got ${foundRow}`); + tui.stop(); + }); + + it("rowPercent 0 should position at top", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["TOP"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { width: 10, row: "0%" }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("TOP"), `Expected TOP on row 0, got: ${viewport[0]}`); + tui.stop(); + }); + + it("rowPercent 100 should position at bottom", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["BOTTOM"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { width: 10, row: "100%" }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + assert.ok(viewport[23]?.includes("BOTTOM"), `Expected BOTTOM on last row, got: ${viewport[23]}`); + tui.stop(); + }); + }); + + describe("maxHeight", () => { + it("should truncate overlay to maxHeight", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["Line 1", "Line 2", "Line 3", "Line 4", "Line 5"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { maxHeight: 3 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + const content = viewport.join("\n"); + assert.ok(content.includes("Line 1"), "Should include Line 1"); + assert.ok(content.includes("Line 2"), "Should include Line 2"); + assert.ok(content.includes("Line 3"), "Should include Line 3"); + assert.ok(!content.includes("Line 4"), "Should NOT include Line 4"); + assert.ok(!content.includes("Line 5"), "Should NOT include Line 5"); + tui.stop(); + }); + + it("should truncate overlay to maxHeightPercent", async () => { + const terminal = new VirtualTerminal(80, 10); + const tui = new TUI(terminal); + // 10 lines in a 10 row terminal with 50% maxHeight should show 5 lines + const overlay = new StaticOverlay(["L1", "L2", "L3", "L4", "L5", "L6", "L7", "L8", "L9", "L10"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { maxHeight: "50%" }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + const content = viewport.join("\n"); + assert.ok(content.includes("L1"), "Should include L1"); + assert.ok(content.includes("L5"), "Should include L5"); + assert.ok(!content.includes("L6"), "Should NOT include L6"); + tui.stop(); + }); + }); + + describe("absolute positioning", () => { + it("row and col should override anchor", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["ABSOLUTE"]); + + tui.addChild(new EmptyContent()); + // Even with bottom-right anchor, row/col should win + tui.showOverlay(overlay, { anchor: "bottom-right", row: 3, col: 5, width: 10 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + assert.ok(viewport[3]?.includes("ABSOLUTE"), `Expected ABSOLUTE on row 3, got: ${viewport[3]}`); + const colIndex = viewport[3]?.indexOf("ABSOLUTE") ?? -1; + assert.strictEqual(colIndex, 5, `Expected col 5, got ${colIndex}`); + tui.stop(); + }); + }); + + describe("stacked overlays", () => { + it("should render multiple overlays with later ones on top", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + + tui.addChild(new EmptyContent()); + + // First overlay at top-left + const overlay1 = new StaticOverlay(["FIRST-OVERLAY"]); + tui.showOverlay(overlay1, { anchor: "top-left", width: 20 }); + + // Second overlay at top-left (should cover part of first) + const overlay2 = new StaticOverlay(["SECOND"]); + tui.showOverlay(overlay2, { anchor: "top-left", width: 10 }); + + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + // Second overlay should be visible (on top) + assert.ok(viewport[0]?.includes("SECOND"), `Expected SECOND on row 0, got: ${viewport[0]}`); + // Part of first overlay might still be visible after SECOND + // FIRST-OVERLAY is 13 chars, SECOND is 6 chars, so "OVERLAY" part might show + tui.stop(); + }); + + it("should handle overlays at different positions without interference", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + + tui.addChild(new EmptyContent()); + + // Overlay at top-left + const overlay1 = new StaticOverlay(["TOP-LEFT"]); + tui.showOverlay(overlay1, { anchor: "top-left", width: 15 }); + + // Overlay at bottom-right + const overlay2 = new StaticOverlay(["BTM-RIGHT"]); + tui.showOverlay(overlay2, { anchor: "bottom-right", width: 15 }); + + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + // Both should be visible + assert.ok(viewport[0]?.includes("TOP-LEFT"), `Expected TOP-LEFT on row 0, got: ${viewport[0]}`); + assert.ok(viewport[23]?.includes("BTM-RIGHT"), `Expected BTM-RIGHT on row 23, got: ${viewport[23]}`); + tui.stop(); + }); + + it("should properly hide overlays in stack order", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + + tui.addChild(new EmptyContent()); + + // Show two overlays + const overlay1 = new StaticOverlay(["FIRST"]); + tui.showOverlay(overlay1, { anchor: "top-left", width: 10 }); + + const overlay2 = new StaticOverlay(["SECOND"]); + tui.showOverlay(overlay2, { anchor: "top-left", width: 10 }); + + tui.start(); + await renderAndFlush(tui, terminal); + + // Second should be visible + let viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("SECOND"), "SECOND should be visible initially"); + + // Hide top overlay + tui.hideOverlay(); + await renderAndFlush(tui, terminal); + + // First should now be visible + viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("FIRST"), "FIRST should be visible after hiding SECOND"); + + tui.stop(); + }); + }); +}); diff --git a/packages/pi-tui/test/overlay-short-content.test.ts b/packages/pi-tui/test/overlay-short-content.test.ts new file mode 100644 index 000000000..135d8cb9e --- /dev/null +++ b/packages/pi-tui/test/overlay-short-content.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { type Component, TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class SimpleContent implements Component { + private lines: string[]; + + constructor(lines: string[]) { + this.lines = lines; + } + + render(): string[] { + return this.lines; + } + invalidate() {} +} + +class SimpleOverlay implements Component { + render(): string[] { + return ["OVERLAY_TOP", "OVERLAY_MID", "OVERLAY_BOT"]; + } + invalidate() {} +} + +describe("TUI overlay with short content", () => { + it("should render overlay when content is shorter than terminal height", async () => { + // Terminal has 24 rows, but content only has 3 lines + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + + // Only 3 lines of content + tui.addChild(new SimpleContent(["Line 1", "Line 2", "Line 3"])); + + // Show overlay centered - should be around row 10 in a 24-row terminal + const overlay = new SimpleOverlay(); + tui.showOverlay(overlay); + + // Trigger render + tui.start(); + await terminal.waitForRender(); + + const viewport = terminal.getViewport(); + const hasOverlay = viewport.some((line) => line.includes("OVERLAY")); + + console.log("Terminal rows:", terminal.rows); + console.log("Content lines: 3"); + console.log("Overlay visible:", hasOverlay); + + if (!hasOverlay) { + console.log("\nViewport contents:"); + for (let i = 0; i < viewport.length; i++) { + console.log(` [${i}]: "${viewport[i]}"`); + } + } + + assert.ok(hasOverlay, "Overlay should be visible when content is shorter than terminal"); + + tui.stop(); + }); +}); diff --git a/packages/pi-tui/test/regression-overlay-cjk-boundary.test.ts b/packages/pi-tui/test/regression-overlay-cjk-boundary.test.ts new file mode 100644 index 000000000..2ae1f5ed8 --- /dev/null +++ b/packages/pi-tui/test/regression-overlay-cjk-boundary.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { TUI } from "../src/tui.ts"; +import { extractSegments, sliceByColumn, visibleWidth } from "../src/utils.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +type TuiComposite = { + compositeLineAt( + baseLine: string, + overlayLine: string, + startCol: number, + overlayWidth: number, + totalWidth: number, + ): string; +}; + +function compositeLineAt( + baseLine: string, + overlayLine: string, + startCol: number, + overlayWidth: number, + totalWidth: number, +): string { + const tui = new TUI(new VirtualTerminal(totalWidth, 10)) as unknown as TuiComposite; + return tui.compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth); +} + +describe("overlay CJK boundary regression", () => { + it("excludes a wide grapheme from before when overlay starts inside it", () => { + const segments = extractSegments("abcd让EFGH", 5, 9, 11, true); + + assert.strictEqual(segments.before, "abcd"); + assert.strictEqual(segments.beforeWidth, 4); + assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth); + assert.strictEqual(segments.after, "H"); + assert.strictEqual(segments.afterWidth, 1); + }); + + it("keeps ASCII before-segment behavior at the same boundary", () => { + const segments = extractSegments("abcdG EFGH", 5, 9, 11, true); + + assert.strictEqual(segments.before, "abcdG"); + assert.strictEqual(segments.beforeWidth, 5); + assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth); + }); + + it("composites an overlay at the requested column when it starts inside a wide grapheme", () => { + const out = compositeLineAt("abcd让EFGH", "│XX│", 5, 4, 20); + const prefix = sliceByColumn(out, 0, 5, true); + const overlay = sliceByColumn(out, 5, 4, true); + + assert.strictEqual(out.includes("让"), false); + assert.strictEqual(visibleWidth(out), 20); + assert.strictEqual(visibleWidth(prefix), 5); + assert.strictEqual(visibleWidth(overlay), 4); + assert.strictEqual(overlay.includes("│XX│"), true); + }); + + it("composites an overlay when it starts at a wide grapheme boundary", () => { + const out = compositeLineAt("abcd让EFGH", "│XX│", 4, 4, 20); + const overlay = sliceByColumn(out, 4, 4, true); + + assert.strictEqual(out.includes("让"), false); + assert.strictEqual(visibleWidth(out), 20); + assert.strictEqual(visibleWidth(overlay), 4); + assert.strictEqual(overlay.includes("│XX│"), true); + }); +}); diff --git a/packages/pi-tui/test/regression-regional-indicator-width.test.ts b/packages/pi-tui/test/regression-regional-indicator-width.test.ts new file mode 100644 index 000000000..6803e2b1a --- /dev/null +++ b/packages/pi-tui/test/regression-regional-indicator-width.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { visibleWidth, wrapTextWithAnsi } from "../src/utils.ts"; + +describe("regional indicator width regression", () => { + it("treats partial flag grapheme as full-width to avoid streaming render drift", () => { + // Repro context: + // During streaming, "🇨🇳" often appears as an intermediate "🇨" first. + // If "🇨" is measured as width 1 while terminal renders it as width 2, + // differential rendering can drift and leave stale characters on screen. + const partialFlag = "🇨"; + const listLine = " - 🇨"; + + assert.strictEqual(visibleWidth(partialFlag), 2); + assert.strictEqual(visibleWidth(listLine), 10); + }); + + it("wraps intermediate partial-flag list line before overflow", () => { + // Width 9 cannot fit " - 🇨" if 🇨 is width 2 (8 + 2 = 10). + // This must wrap to avoid terminal auto-wrap mismatch. + const wrapped = wrapTextWithAnsi(" - 🇨", 9); + + assert.strictEqual(wrapped.length, 2); + assert.strictEqual(visibleWidth(wrapped[0] || ""), 7); + assert.strictEqual(visibleWidth(wrapped[1] || ""), 2); + }); + + it("treats all regional-indicator singleton graphemes as width 2", () => { + for (let cp = 0x1f1e6; cp <= 0x1f1ff; cp++) { + const regionalIndicator = String.fromCodePoint(cp); + assert.strictEqual( + visibleWidth(regionalIndicator), + 2, + `Expected ${regionalIndicator} (U+${cp.toString(16).toUpperCase()}) to be width 2`, + ); + } + }); + + it("keeps full flag pairs at width 2", () => { + const samples = ["🇯🇵", "🇺🇸", "🇬🇧", "🇨🇳", "🇩🇪", "🇫🇷"]; + for (const flag of samples) { + assert.strictEqual(visibleWidth(flag), 2, `Expected ${flag} to be width 2`); + } + }); + + it("keeps common streaming emoji intermediates at stable width", () => { + const samples = ["👍", "👍🏻", "✅", "⚡", "⚡️", "👨", "👨‍💻", "🏳️‍🌈"]; + for (const sample of samples) { + assert.strictEqual(visibleWidth(sample), 2, `Expected ${sample} to be width 2`); + } + }); +}); diff --git a/packages/pi-tui/test/select-list.test.ts b/packages/pi-tui/test/select-list.test.ts new file mode 100644 index 000000000..b5c24212c --- /dev/null +++ b/packages/pi-tui/test/select-list.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { SelectList } from "../src/components/select-list.ts"; +import { visibleWidth } from "../src/utils.ts"; + +const testTheme = { + selectedPrefix: (text: string) => text, + selectedText: (text: string) => text, + description: (text: string) => text, + scrollInfo: (text: string) => text, + noMatch: (text: string) => text, +}; + +const visibleIndexOf = (line: string, text: string): number => { + const index = line.indexOf(text); + assert.notEqual(index, -1); + return visibleWidth(line.slice(0, index)); +}; + +describe("SelectList", () => { + it("normalizes multiline descriptions to single line", () => { + const items = [ + { + value: "test", + label: "test", + description: "Line one\nLine two\nLine three", + }, + ]; + + const list = new SelectList(items, 5, testTheme); + const rendered = list.render(100); + + assert.ok(rendered.length > 0); + assert.ok(!rendered[0].includes("\n")); + assert.ok(rendered[0].includes("Line one Line two Line three")); + }); + + it("keeps descriptions aligned when the primary text is truncated", () => { + const items = [ + { value: "short", label: "short", description: "short description" }, + { + value: "very-long-command-name-that-needs-truncation", + label: "very-long-command-name-that-needs-truncation", + description: "long description", + }, + ]; + + const list = new SelectList(items, 5, testTheme); + const rendered = list.render(80); + + assert.equal(visibleIndexOf(rendered[0], "short description"), visibleIndexOf(rendered[1], "long description")); + }); + + it("uses the configured minimum primary column width", () => { + const items = [ + { value: "a", label: "a", description: "first" }, + { value: "bb", label: "bb", description: "second" }, + ]; + + const list = new SelectList(items, 5, testTheme, { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 20, + }); + const rendered = list.render(80); + + assert.equal(rendered[0].indexOf("first"), 14); + assert.equal(rendered[1].indexOf("second"), 14); + }); + + it("uses the configured maximum primary column width", () => { + const items = [ + { + value: "very-long-command-name-that-needs-truncation", + label: "very-long-command-name-that-needs-truncation", + description: "first", + }, + { value: "short", label: "short", description: "second" }, + ]; + + const list = new SelectList(items, 5, testTheme, { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 20, + }); + const rendered = list.render(80); + + assert.equal(visibleIndexOf(rendered[0], "first"), 22); + assert.equal(visibleIndexOf(rendered[1], "second"), 22); + }); + + it("allows overriding primary truncation while preserving description alignment", () => { + const items = [ + { + value: "very-long-command-name-that-needs-truncation", + label: "very-long-command-name-that-needs-truncation", + description: "first", + }, + { value: "short", label: "short", description: "second" }, + ]; + + const list = new SelectList(items, 5, testTheme, { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 12, + truncatePrimary: ({ text, maxWidth }) => { + if (text.length <= maxWidth) { + return text; + } + + return `${text.slice(0, Math.max(0, maxWidth - 1))}…`; + }, + }); + const rendered = list.render(80); + + assert.ok(rendered[0].includes("…")); + assert.equal(visibleIndexOf(rendered[0], "first"), visibleIndexOf(rendered[1], "second")); + }); +}); diff --git a/packages/pi-tui/test/stdin-buffer.test.ts b/packages/pi-tui/test/stdin-buffer.test.ts new file mode 100644 index 000000000..e72c14966 --- /dev/null +++ b/packages/pi-tui/test/stdin-buffer.test.ts @@ -0,0 +1,458 @@ +/** + * Tests for StdinBuffer + * + * Based on code from OpenTUI (https://github.com/anomalyco/opentui) + * MIT License - Copyright (c) 2025 opentui + */ + +import assert from "node:assert"; +import { beforeEach, describe, it } from "node:test"; +import { StdinBuffer } from "../src/stdin-buffer.ts"; + +describe("StdinBuffer", () => { + let buffer: StdinBuffer; + let emittedSequences: string[]; + + beforeEach(() => { + buffer = new StdinBuffer({ timeout: 10 }); + + // Collect emitted sequences + emittedSequences = []; + buffer.on("data", (sequence) => { + emittedSequences.push(sequence); + }); + }); + + // Helper to process data through the buffer + function processInput(data: string | Buffer): void { + buffer.process(data); + } + + // Helper to wait for async operations + async function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + describe("Regular Characters", () => { + it("should pass through regular characters immediately", () => { + processInput("a"); + assert.deepStrictEqual(emittedSequences, ["a"]); + }); + + it("should pass through multiple regular characters", () => { + processInput("abc"); + assert.deepStrictEqual(emittedSequences, ["a", "b", "c"]); + }); + + it("should handle unicode characters", () => { + processInput("hello 世界"); + assert.deepStrictEqual(emittedSequences, ["h", "e", "l", "l", "o", " ", "世", "界"]); + }); + }); + + describe("Complete Escape Sequences", () => { + it("should pass through complete mouse SGR sequences", () => { + const mouseSeq = "\x1b[<35;20;5m"; + processInput(mouseSeq); + assert.deepStrictEqual(emittedSequences, [mouseSeq]); + }); + + it("should pass through complete arrow key sequences", () => { + const upArrow = "\x1b[A"; + processInput(upArrow); + assert.deepStrictEqual(emittedSequences, [upArrow]); + }); + + it("should pass through complete function key sequences", () => { + const f1 = "\x1b[11~"; + processInput(f1); + assert.deepStrictEqual(emittedSequences, [f1]); + }); + + it("should pass through meta key sequences", () => { + const metaA = "\x1ba"; + processInput(metaA); + assert.deepStrictEqual(emittedSequences, [metaA]); + }); + + it("should pass through SS3 sequences", () => { + const ss3 = "\x1bOA"; + processInput(ss3); + assert.deepStrictEqual(emittedSequences, [ss3]); + }); + }); + + describe("Partial Escape Sequences", () => { + it("should buffer incomplete mouse SGR sequence", async () => { + processInput("\x1b"); + assert.deepStrictEqual(emittedSequences, []); + assert.strictEqual(buffer.getBuffer(), "\x1b"); + + processInput("[<35"); + assert.deepStrictEqual(emittedSequences, []); + assert.strictEqual(buffer.getBuffer(), "\x1b[<35"); + + processInput(";20;5m"); + assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]); + assert.strictEqual(buffer.getBuffer(), ""); + }); + + it("should buffer incomplete CSI sequence", () => { + processInput("\x1b["); + assert.deepStrictEqual(emittedSequences, []); + + processInput("1;"); + assert.deepStrictEqual(emittedSequences, []); + + processInput("5H"); + assert.deepStrictEqual(emittedSequences, ["\x1b[1;5H"]); + }); + + it("should buffer split across many chunks", () => { + processInput("\x1b"); + processInput("["); + processInput("<"); + processInput("3"); + processInput("5"); + processInput(";"); + processInput("2"); + processInput("0"); + processInput(";"); + processInput("5"); + processInput("m"); + + assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]); + }); + + it("should flush incomplete sequence after timeout", async () => { + processInput("\x1b[<35"); + assert.deepStrictEqual(emittedSequences, []); + + // Wait for timeout + await wait(15); + + assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]); + }); + }); + + describe("Mixed Content", () => { + it("should handle characters followed by escape sequence", () => { + processInput("abc\x1b[A"); + assert.deepStrictEqual(emittedSequences, ["a", "b", "c", "\x1b[A"]); + }); + + it("should handle escape sequence followed by characters", () => { + processInput("\x1b[Aabc"); + assert.deepStrictEqual(emittedSequences, ["\x1b[A", "a", "b", "c"]); + }); + + it("should handle multiple complete sequences", () => { + processInput("\x1b[A\x1b[B\x1b[C"); + assert.deepStrictEqual(emittedSequences, ["\x1b[A", "\x1b[B", "\x1b[C"]); + }); + + it("should handle partial sequence with preceding characters", () => { + processInput("abc\x1b[<35"); + assert.deepStrictEqual(emittedSequences, ["a", "b", "c"]); + assert.strictEqual(buffer.getBuffer(), "\x1b[<35"); + + processInput(";20;5m"); + assert.deepStrictEqual(emittedSequences, ["a", "b", "c", "\x1b[<35;20;5m"]); + }); + }); + + describe("Kitty Keyboard Protocol", () => { + it("should handle Kitty CSI u press events", () => { + // Press 'a' in Kitty protocol + processInput("\x1b[97u"); + assert.deepStrictEqual(emittedSequences, ["\x1b[97u"]); + }); + + it("should handle Kitty CSI u release events", () => { + // Release 'a' in Kitty protocol + processInput("\x1b[97;1:3u"); + assert.deepStrictEqual(emittedSequences, ["\x1b[97;1:3u"]); + }); + + it("should handle batched Kitty press and release", () => { + // Press 'a', release 'a' batched together (common over SSH) + processInput("\x1b[97u\x1b[97;1:3u"); + assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "\x1b[97;1:3u"]); + }); + + it("should handle multiple batched Kitty events", () => { + // Press 'a', release 'a', press 'b', release 'b' + processInput("\x1b[97u\x1b[97;1:3u\x1b[98u\x1b[98;1:3u"); + assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "\x1b[97;1:3u", "\x1b[98u", "\x1b[98;1:3u"]); + }); + + it("should handle Kitty arrow keys with event type", () => { + // Up arrow press with event type + processInput("\x1b[1;1:1A"); + assert.deepStrictEqual(emittedSequences, ["\x1b[1;1:1A"]); + }); + + it("should handle Kitty functional keys with event type", () => { + // Delete key release + processInput("\x1b[3;1:3~"); + assert.deepStrictEqual(emittedSequences, ["\x1b[3;1:3~"]); + }); + + it("should split ESC+ESC+CSI into standalone ESC and the CSI sequence (WezTerm Escape key regression)", () => { + // WezTerm with enable_kitty_keyboard sends Escape key press as raw \x1b + // and the release as a full Kitty CSI-u sequence, concatenated. + // The buffer must not treat \x1b\x1b as a complete meta-key when the + // following byte starts a new escape sequence. + processInput("\x1b\x1b[27;129:3u"); + assert.deepStrictEqual(emittedSequences, ["\x1b", "\x1b[27;129:3u"]); + }); + + it("should split ESC+ESC+CSI with no modifier (no num_lock)", () => { + processInput("\x1b\x1b[27;1:3u"); + assert.deepStrictEqual(emittedSequences, ["\x1b", "\x1b[27;1:3u"]); + }); + + it("should still emit ESC+ESC as a single sequence when not followed by a new escape", () => { + // \x1b\x1b alone (no following CSI) stays as-is — e.g. ctrl+alt+[ + processInput("\x1b\x1b"); + assert.deepStrictEqual(emittedSequences, ["\x1b\x1b"]); + }); + + it("should handle plain characters mixed with Kitty sequences", () => { + // Plain 'a' followed by Kitty release + processInput("a\x1b[97;1:3u"); + assert.deepStrictEqual(emittedSequences, ["a", "\x1b[97;1:3u"]); + }); + + it("should drop raw duplicate character after matching Kitty printable sequence", () => { + processInput("\x1b[224uà"); + assert.deepStrictEqual(emittedSequences, ["\x1b[224u"]); + }); + + it("should drop raw duplicate character after matching Kitty printable sequence across chunks", () => { + processInput("\x1b[64u"); + processInput("@"); + assert.deepStrictEqual(emittedSequences, ["\x1b[64u"]); + }); + + it("should keep non-matching plain character after Kitty printable sequence", () => { + processInput("\x1b[97ub"); + assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "b"]); + }); + + it("should keep raw character after modified Kitty printable sequence", () => { + processInput("\x1b[64;3u@"); + assert.deepStrictEqual(emittedSequences, ["\x1b[64;3u", "@"]); + }); + + it("should handle rapid typing simulation with Kitty protocol", () => { + // Simulates typing "hi" quickly with releases interleaved + processInput("\x1b[104u\x1b[104;1:3u\x1b[105u\x1b[105;1:3u"); + assert.deepStrictEqual(emittedSequences, ["\x1b[104u", "\x1b[104;1:3u", "\x1b[105u", "\x1b[105;1:3u"]); + }); + }); + + describe("Mouse Events", () => { + it("should handle mouse press event", () => { + processInput("\x1b[<0;10;5M"); + assert.deepStrictEqual(emittedSequences, ["\x1b[<0;10;5M"]); + }); + + it("should handle mouse release event", () => { + processInput("\x1b[<0;10;5m"); + assert.deepStrictEqual(emittedSequences, ["\x1b[<0;10;5m"]); + }); + + it("should handle mouse move event", () => { + processInput("\x1b[<35;20;5m"); + assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]); + }); + + it("should handle split mouse events", () => { + processInput("\x1b[<3"); + processInput("5;1"); + processInput("5;"); + processInput("10m"); + assert.deepStrictEqual(emittedSequences, ["\x1b[<35;15;10m"]); + }); + + it("should handle multiple mouse events", () => { + processInput("\x1b[<35;1;1m\x1b[<35;2;2m\x1b[<35;3;3m"); + assert.deepStrictEqual(emittedSequences, ["\x1b[<35;1;1m", "\x1b[<35;2;2m", "\x1b[<35;3;3m"]); + }); + + it("should handle old-style mouse sequence (ESC[M + 3 bytes)", () => { + processInput("\x1b[M abc"); + assert.deepStrictEqual(emittedSequences, ["\x1b[M ab", "c"]); + }); + + it("should buffer incomplete old-style mouse sequence", () => { + processInput("\x1b[M"); + assert.strictEqual(buffer.getBuffer(), "\x1b[M"); + + processInput(" a"); + assert.strictEqual(buffer.getBuffer(), "\x1b[M a"); + + processInput("b"); + assert.deepStrictEqual(emittedSequences, ["\x1b[M ab"]); + }); + }); + + describe("Edge Cases", () => { + it("should handle empty input", () => { + processInput(""); + // Empty string emits an empty data event + assert.deepStrictEqual(emittedSequences, [""]); + }); + + it("should handle lone escape character with timeout", async () => { + processInput("\x1b"); + assert.deepStrictEqual(emittedSequences, []); + + // After timeout, should emit + await wait(15); + assert.deepStrictEqual(emittedSequences, ["\x1b"]); + }); + + it("should handle lone escape character with explicit flush", () => { + processInput("\x1b"); + assert.deepStrictEqual(emittedSequences, []); + + const flushed = buffer.flush(); + assert.deepStrictEqual(flushed, ["\x1b"]); + }); + + it("should handle buffer input", () => { + processInput(Buffer.from("\x1b[A")); + assert.deepStrictEqual(emittedSequences, ["\x1b[A"]); + }); + + it("should handle very long sequences", () => { + const longSeq = `\x1b[${"1;".repeat(50)}H`; + processInput(longSeq); + assert.deepStrictEqual(emittedSequences, [longSeq]); + }); + }); + + describe("Flush", () => { + it("should flush incomplete sequences", () => { + processInput("\x1b[<35"); + const flushed = buffer.flush(); + assert.deepStrictEqual(flushed, ["\x1b[<35"]); + assert.strictEqual(buffer.getBuffer(), ""); + }); + + it("should return empty array if nothing to flush", () => { + const flushed = buffer.flush(); + assert.deepStrictEqual(flushed, []); + }); + + it("should emit flushed data via timeout", async () => { + processInput("\x1b[<35"); + assert.deepStrictEqual(emittedSequences, []); + + // Wait for timeout to flush + await wait(15); + + assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]); + }); + }); + + describe("Clear", () => { + it("should clear buffered content without emitting", () => { + processInput("\x1b[<35"); + assert.strictEqual(buffer.getBuffer(), "\x1b[<35"); + + buffer.clear(); + assert.strictEqual(buffer.getBuffer(), ""); + assert.deepStrictEqual(emittedSequences, []); + }); + }); + + describe("Bracketed Paste", () => { + let emittedPaste: string[] = []; + + beforeEach(() => { + buffer = new StdinBuffer({ timeout: 10 }); + + // Collect emitted sequences + emittedSequences = []; + buffer.on("data", (sequence) => { + emittedSequences.push(sequence); + }); + + // Collect paste events + emittedPaste = []; + buffer.on("paste", (data) => { + emittedPaste.push(data); + }); + }); + + it("should emit paste event for complete bracketed paste", () => { + const pasteStart = "\x1b[200~"; + const pasteEnd = "\x1b[201~"; + const content = "hello world"; + + processInput(pasteStart + content + pasteEnd); + + assert.deepStrictEqual(emittedPaste, ["hello world"]); + assert.deepStrictEqual(emittedSequences, []); // No data events during paste + }); + + it("should handle paste arriving in chunks", () => { + processInput("\x1b[200~"); + assert.deepStrictEqual(emittedPaste, []); + + processInput("hello "); + assert.deepStrictEqual(emittedPaste, []); + + processInput("world\x1b[201~"); + assert.deepStrictEqual(emittedPaste, ["hello world"]); + assert.deepStrictEqual(emittedSequences, []); + }); + + it("should handle paste with input before and after", () => { + processInput("a"); + processInput("\x1b[200~pasted\x1b[201~"); + processInput("b"); + + assert.deepStrictEqual(emittedSequences, ["a", "b"]); + assert.deepStrictEqual(emittedPaste, ["pasted"]); + }); + + it("should handle paste with newlines", () => { + processInput("\x1b[200~line1\nline2\nline3\x1b[201~"); + + assert.deepStrictEqual(emittedPaste, ["line1\nline2\nline3"]); + assert.deepStrictEqual(emittedSequences, []); + }); + + it("should handle paste with unicode", () => { + processInput("\x1b[200~Hello 世界 🎉\x1b[201~"); + + assert.deepStrictEqual(emittedPaste, ["Hello 世界 🎉"]); + assert.deepStrictEqual(emittedSequences, []); + }); + }); + + describe("Destroy", () => { + it("should clear buffer on destroy", () => { + processInput("\x1b[<35"); + assert.strictEqual(buffer.getBuffer(), "\x1b[<35"); + + buffer.destroy(); + assert.strictEqual(buffer.getBuffer(), ""); + }); + + it("should clear pending timeouts on destroy", async () => { + processInput("\x1b[<35"); + buffer.destroy(); + + // Wait longer than timeout + await wait(15); + + // Should not have emitted anything + assert.deepStrictEqual(emittedSequences, []); + }); + }); +}); diff --git a/packages/pi-tui/test/tab-width.test.ts b/packages/pi-tui/test/tab-width.test.ts new file mode 100644 index 000000000..427a77db7 --- /dev/null +++ b/packages/pi-tui/test/tab-width.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { extractSegments, sliceWithWidth, visibleWidth } from "../src/utils.ts"; + +describe("tab width accounting", () => { + it("keeps slice helper widths consistent with visible width", () => { + const text = "out 192M\t.pi/skill-tests/results-ha"; + const slice = sliceWithWidth(text, 0, 10, true); + + assert.strictEqual(slice.text, "out 192M"); + assert.strictEqual(slice.width, 8); + assert.strictEqual(visibleWidth(slice.text), slice.width); + }); + + it("keeps overlay segment widths consistent with visible width", () => { + const text = "out 192M\t.pi/skill-tests/results-ha"; + const segments = extractSegments(text, 10, 13, 10, true); + + assert.strictEqual(segments.before, "out 192M"); + assert.strictEqual(segments.beforeWidth, 8); + assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth); + + const tabFits = extractSegments(text, 11, 13, 10, true); + assert.strictEqual(tabFits.before, "out 192M\t"); + assert.strictEqual(tabFits.beforeWidth, 11); + assert.strictEqual(visibleWidth(tabFits.before), tabFits.beforeWidth); + }); +}); diff --git a/packages/pi-tui/test/terminal-colors.test.ts b/packages/pi-tui/test/terminal-colors.test.ts new file mode 100644 index 000000000..d777e0617 --- /dev/null +++ b/packages/pi-tui/test/terminal-colors.test.ts @@ -0,0 +1,249 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { + type Component, + parseOsc11BackgroundColor, + parseTerminalColorSchemeReport, + type Terminal, + TUI, +} from "../src/index.ts"; + +class TestTerminal implements Terminal { + private inputHandler?: (data: string) => void; + private resizeHandler?: () => void; + private readonly columnCount: number; + private readonly rowCount: number; + readonly writes: string[] = []; + + constructor(columnCount = 80, rowCount = 24) { + this.columnCount = columnCount; + this.rowCount = rowCount; + } + + start(onInput: (data: string) => void, onResize: () => void): void { + this.inputHandler = onInput; + this.resizeHandler = onResize; + } + + stop(): void { + this.inputHandler = undefined; + this.resizeHandler = undefined; + } + + async drainInput(_maxMs?: number, _idleMs?: number): Promise {} + + write(data: string): void { + this.writes.push(data); + } + + get columns(): number { + return this.columnCount; + } + + get rows(): number { + return this.rowCount; + } + + get kittyProtocolActive(): boolean { + return false; + } + + moveBy(_lines: number): void {} + + hideCursor(): void {} + + showCursor(): void {} + + clearLine(): void {} + + clearFromCursor(): void {} + + clearScreen(): void {} + + setTitle(_title: string): void {} + + setProgress(_active: boolean): void {} + + sendInput(data: string): void { + this.inputHandler?.(data); + } + + sendResize(): void { + this.resizeHandler?.(); + } +} + +class InputRecorder implements Component { + readonly inputs: string[] = []; + + render(_width: number): string[] { + return []; + } + + handleInput(data: string): void { + this.inputs.push(data); + } + + invalidate(): void {} +} + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe("parseOsc11BackgroundColor", () => { + it("parses 16-bit OSC 11 rgb responses", () => { + assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07"), { + r: 0, + g: 128, + b: 255, + }); + }); + + it("parses OSC 11 hex responses", () => { + assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;#ffffff\x1b\\"), { r: 255, g: 255, b: 255 }); + assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;#000000\x07"), { r: 0, g: 0, b: 0 }); + }); + + it("rejects non-strict OSC 11 responses", () => { + assert.strictEqual(parseOsc11BackgroundColor(`x\x1b]11;#ffffff\x07`), undefined); + assert.strictEqual(parseOsc11BackgroundColor("\x1b]10;#ffffff\x07"), undefined); + assert.strictEqual(parseOsc11BackgroundColor("\x1b]11;#ffffff\x07x"), undefined); + }); +}); + +describe("parseTerminalColorSchemeReport", () => { + it("parses color scheme reports", () => { + assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;1n"), "dark"); + assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;2n"), "light"); + assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;3n"), undefined); + assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?996n"), undefined); + assert.strictEqual(parseTerminalColorSchemeReport("x\x1b[?997;1n"), undefined); + }); +}); + +describe("TUI.queryTerminalBackgroundColor", () => { + it("writes OSC 11 query and resolves with the parsed RGB reply", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + tui.start(); + try { + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }); + assert.ok(terminal.writes.includes("\x1b]11;?\x07")); + + terminal.sendInput("\x1b]11;#ffffff\x07"); + + assert.deepStrictEqual(await query, { r: 255, g: 255, b: 255 }); + } finally { + tui.stop(); + } + }); + + it("consumes OSC 11 replies before input listeners and focused component dispatch", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }); + + terminal.sendInput("\x1b]11;#000000\x07"); + + assert.deepStrictEqual(await query, { r: 0, g: 0, b: 0 }); + assert.deepStrictEqual(listenerInputs, []); + assert.deepStrictEqual(component.inputs, []); + } finally { + tui.stop(); + } + }); + + it("consumes unparseable strict OSC 11 replies and resolves undefined", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }); + + terminal.sendInput("\x1b]11;not-a-color\x07"); + + assert.strictEqual(await query, undefined); + assert.deepStrictEqual(listenerInputs, []); + assert.deepStrictEqual(component.inputs, []); + } finally { + tui.stop(); + } + }); + + it("dispatches non-matching input normally while waiting for an OSC 11 reply", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + let settled = false; + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }).then((rgb) => { + settled = true; + return rgb; + }); + + terminal.sendInput("x"); + await Promise.resolve(); + + assert.strictEqual(settled, false); + assert.deepStrictEqual(listenerInputs, ["x"]); + assert.deepStrictEqual(component.inputs, ["x"]); + + terminal.sendInput("\x1b]11;#ffffff\x07"); + assert.deepStrictEqual(await query, { r: 255, g: 255, b: 255 }); + } finally { + tui.stop(); + } + }); + + it("keeps consuming a late OSC 11 reply after timeout", async () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1 }); + await wait(5); + + assert.strictEqual(await query, undefined); + + terminal.sendInput("\x1b]11;#ffffff\x07"); + + assert.deepStrictEqual(listenerInputs, []); + assert.deepStrictEqual(component.inputs, []); + } finally { + tui.stop(); + } + }); +}); diff --git a/packages/pi-tui/test/terminal-image.test.ts b/packages/pi-tui/test/terminal-image.test.ts new file mode 100644 index 000000000..cc7e01e59 --- /dev/null +++ b/packages/pi-tui/test/terminal-image.test.ts @@ -0,0 +1,492 @@ +/** + * Tests for terminal image detection and line handling + */ + +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { Image } from "../src/components/image.ts"; +import { + deleteAllKittyImages, + deleteKittyImage, + detectCapabilities, + encodeKitty, + hyperlink, + isImageLine, + renderImage, + resetCapabilitiesCache, + setCapabilities, + setCellDimensions, +} from "../src/terminal-image.ts"; + +const ENV_KEYS = [ + "TERM", + "TERM_PROGRAM", + "TERMINAL_EMULATOR", + "COLORTERM", + "TMUX", + "KITTY_WINDOW_ID", + "GHOSTTY_RESOURCES_DIR", + "WEZTERM_PANE", + "ITERM_SESSION_ID", + "WT_SESSION", + "CMUX_WORKSPACE_ID", + "WARP_SESSION_ID", + "WARP_TERMINAL_SESSION_UUID", +] as const; + +function withEnv(overrides: Record, fn: () => void): void { + const saved: Record = {}; + for (const key of ENV_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } + try { + for (const [k, v] of Object.entries(overrides)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + fn(); + } finally { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + } +} + +describe("isImageLine", () => { + describe("iTerm2 image protocol", () => { + it("should detect iTerm2 image escape sequence at start of line", () => { + // iTerm2 image escape sequence: ESC ]1337;File=... + const iterm2ImageLine = "\x1b]1337;File=size=100,100;inline=1:base64encodeddata==\x07"; + assert.strictEqual(isImageLine(iterm2ImageLine), true); + }); + + it("should detect iTerm2 image escape sequence with text before it", () => { + // Simulating a line that has text then image data (bug scenario) + const lineWithTextAndImage = "Some text \x1b]1337;File=size=100,100;inline=1:base64data==\x07 more text"; + assert.strictEqual(isImageLine(lineWithTextAndImage), true); + }); + + it("should detect iTerm2 image escape sequence in middle of long line", () => { + // Simulate a very long line with image data in the middle + const longLineWithImage = + "Text before image..." + "\x1b]1337;File=inline=1:verylongbase64data==" + "...text after"; + assert.strictEqual(isImageLine(longLineWithImage), true); + }); + + it("should detect iTerm2 image escape sequence at end of line", () => { + const lineWithImageAtEnd = "Regular text ending with \x1b]1337;File=inline=1:base64data==\x07"; + assert.strictEqual(isImageLine(lineWithImageAtEnd), true); + }); + + it("should detect minimal iTerm2 image escape sequence", () => { + const minimalImageLine = "\x1b]1337;File=:\x07"; + assert.strictEqual(isImageLine(minimalImageLine), true); + }); + }); + + describe("Kitty image protocol", () => { + it("should detect Kitty image escape sequence at start of line", () => { + // Kitty image escape sequence: ESC _G + const kittyImageLine = "\x1b_Ga=T,f=100,t=f,d=base64data...\x1b\\\x1b_Gm=i=1;\x1b\\"; + assert.strictEqual(isImageLine(kittyImageLine), true); + }); + + it("should detect Kitty image escape sequence with text before it", () => { + // Bug scenario: text + image data in same line + const lineWithTextAndKittyImage = "Output: \x1b_Ga=T,f=100;data...\x1b\\\x1b_Gm=i=1;\x1b\\"; + assert.strictEqual(isImageLine(lineWithTextAndKittyImage), true); + }); + + it("should detect Kitty image escape sequence with padding", () => { + // Kitty protocol adds padding to escape sequences + const kittyWithPadding = " \x1b_Ga=T,f=100...\x1b\\\x1b_Gm=i=1;\x1b\\ "; + assert.strictEqual(isImageLine(kittyWithPadding), true); + }); + }); + + describe("Bug regression tests", () => { + it("should detect image sequences in very long lines (304k+ chars)", () => { + // This simulates the crash scenario: a line with 304,401 chars + // containing image escape sequences somewhere + const base64Char = "A".repeat(100); // 100 chars of base64-like data + const imageSequence = "\x1b]1337;File=size=800,600;inline=1:"; + + // Build a long line with image sequence + const longLine = + "Text prefix " + + imageSequence + + base64Char.repeat(3000) + // ~300,000 chars + " suffix"; + + assert.strictEqual(longLine.length > 300000, true); + assert.strictEqual(isImageLine(longLine), true); + }); + + it("should detect image sequences when terminal doesn't support images", () => { + // The bug occurred when getImageEscapePrefix() returned null + // isImageLine should still detect image sequences regardless + const lineWithImage = "Read image file [image/jpeg]\x1b]1337;File=inline=1:base64data==\x07"; + assert.strictEqual(isImageLine(lineWithImage), true); + }); + + it("should detect image sequences with ANSI codes before them", () => { + // Text might have ANSI styling before image data + const lineWithAnsiAndImage = "\x1b[31mError output \x1b]1337;File=inline=1:image==\x07"; + assert.strictEqual(isImageLine(lineWithAnsiAndImage), true); + }); + + it("should detect image sequences with ANSI codes after them", () => { + const lineWithImageAndAnsi = "\x1b_Ga=T,f=100:data...\x1b\\\x1b_Gm=i=1;\x1b\\\x1b[0m reset"; + assert.strictEqual(isImageLine(lineWithImageAndAnsi), true); + }); + }); + + describe("Negative cases - lines without images", () => { + it("should not detect images in plain text lines", () => { + const plainText = "This is just a regular text line without any escape sequences"; + assert.strictEqual(isImageLine(plainText), false); + }); + + it("should not detect images in lines with only ANSI codes", () => { + const ansiText = "\x1b[31mRed text\x1b[0m and \x1b[32mgreen text\x1b[0m"; + assert.strictEqual(isImageLine(ansiText), false); + }); + + it("should not detect images in lines with cursor movement codes", () => { + const cursorCodes = "\x1b[1A\x1b[2KLine cleared and moved up"; + assert.strictEqual(isImageLine(cursorCodes), false); + }); + + it("should not detect images in lines with partial iTerm2 sequences", () => { + // Similar prefix but missing the complete sequence + const partialSequence = "Some text with ]1337;File but missing ESC at start"; + assert.strictEqual(isImageLine(partialSequence), false); + }); + + it("should not detect images in lines with partial Kitty sequences", () => { + // Similar prefix but missing the complete sequence + const partialSequence = "Some text with _G but missing ESC at start"; + assert.strictEqual(isImageLine(partialSequence), false); + }); + + it("should not detect images in empty lines", () => { + assert.strictEqual(isImageLine(""), false); + }); + + it("should not detect images in lines with newlines only", () => { + assert.strictEqual(isImageLine("\n"), false); + assert.strictEqual(isImageLine("\n\n"), false); + }); + }); + + describe("Mixed content scenarios", () => { + it("should detect images when line has both Kitty and iTerm2 sequences", () => { + const mixedLine = "Kitty: \x1b_Ga=T...\x1b\\\x1b_Gm=i=1;\x1b\\ iTerm2: \x1b]1337;File=inline=1:data==\x07"; + assert.strictEqual(isImageLine(mixedLine), true); + }); + + it("should detect image in line with multiple text and image segments", () => { + const complexLine = "Start \x1b]1337;File=img1==\x07 middle \x1b]1337;File=img2==\x07 end"; + assert.strictEqual(isImageLine(complexLine), true); + }); + + it("should not falsely detect image in line with file path containing keywords", () => { + // File path might contain "1337" or "File" but without escape sequences + const filePathLine = "/path/to/File_1337_backup/image.jpg"; + assert.strictEqual(isImageLine(filePathLine), false); + }); + }); +}); + +describe("detectCapabilities", () => { + it("defaults to hyperlinks: false for unknown terminals", () => { + withEnv({}, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.hyperlinks, false); + assert.strictEqual(caps.images, null); + }); + }); + + it("enables hyperlinks under tmux when the client forwards them", () => { + withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => { + const caps = detectCapabilities(() => true); + assert.strictEqual(caps.hyperlinks, true); + assert.strictEqual(caps.images, null); + }); + }); + + it("disables hyperlinks under tmux when the client does not forward them", () => { + withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => { + const caps = detectCapabilities(() => false); + assert.strictEqual(caps.hyperlinks, false); + assert.strictEqual(caps.images, null); + }); + }); + + it("checks tmux capability when TERM starts with 'tmux'", () => { + withEnv({ TERM: "tmux-256color", TERM_PROGRAM: "iterm.app" }, () => { + const caps = detectCapabilities(() => true); + assert.strictEqual(caps.hyperlinks, true); + assert.strictEqual(caps.images, null); + + const caps2 = detectCapabilities(() => false); + assert.strictEqual(caps2.hyperlinks, false); + }); + }); + + it("forces hyperlinks: false when TERM starts with 'screen'", () => { + withEnv({ TERM: "screen-256color" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.hyperlinks, false); + assert.strictEqual(caps.images, null); + }); + }); + + it("enables hyperlinks for Ghostty", () => { + withEnv({ TERM_PROGRAM: "ghostty" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.hyperlinks, true); + }); + }); + + it("does not disable Ghostty images solely because cmux is present", () => { + withEnv({ TERM_PROGRAM: "ghostty", CMUX_WORKSPACE_ID: "workspace" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.images, "kitty"); + assert.strictEqual(caps.hyperlinks, true); + }); + }); + + it("enables hyperlinks for Kitty", () => { + withEnv({ KITTY_WINDOW_ID: "1" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.hyperlinks, true); + }); + }); + + it("enables hyperlinks for WezTerm", () => { + withEnv({ WEZTERM_PANE: "0" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.hyperlinks, true); + }); + }); + + it("enables images and hyperlinks for Warp via TERM_PROGRAM", () => { + withEnv({ TERM_PROGRAM: "WarpTerminal" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.images, "kitty"); + assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, true); + }); + }); + + it("enables images and hyperlinks for Warp via WARP_SESSION_ID", () => { + withEnv({ WARP_SESSION_ID: "some-session-id" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.images, "kitty"); + assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, true); + }); + }); + + it("enables images and hyperlinks for Warp via WARP_TERMINAL_SESSION_UUID", () => { + withEnv({ WARP_TERMINAL_SESSION_UUID: "d0e1a2e5-7ca7-44cd-9037-ac7222011161" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.images, "kitty"); + assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, true); + }); + }); + + it("disables images for Warp inside tmux", () => { + withEnv( + { + TERM_PROGRAM: "WarpTerminal", + TMUX: "/tmp/tmux-1000/default,1234,0", + TERM: "tmux-256color", + }, + () => { + const caps = detectCapabilities(() => true); + assert.strictEqual(caps.images, null); + assert.strictEqual(caps.hyperlinks, true); + }, + ); + }); + + it("enables hyperlinks for iTerm2", () => { + withEnv({ TERM_PROGRAM: "iterm.app" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.hyperlinks, true); + }); + }); + + it("enables hyperlinks for VSCode", () => { + withEnv({ TERM_PROGRAM: "vscode" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.hyperlinks, true); + }); + }); + + it("enables truecolor and hyperlinks for Windows Terminal outside multiplexers", () => { + withEnv({ WT_SESSION: "session", TERM: "xterm-256color" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, true); + assert.strictEqual(caps.images, null); + }); + }); + + it("enables truecolor without hyperlinks for JetBrains terminal", () => { + withEnv({ TERMINAL_EMULATOR: "JetBrains-JediTerm", TERM: "xterm-256color" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, false); + assert.strictEqual(caps.images, null); + }); + }); + + it("does not inherit Windows Terminal truecolor through tmux", () => { + withEnv({ WT_SESSION: "session", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => { + const caps = detectCapabilities(() => false); + assert.strictEqual(caps.trueColor, false); + assert.strictEqual(caps.hyperlinks, false); + assert.strictEqual(caps.images, null); + }); + }); + + it("trusts explicit truecolor hints through tmux", () => { + withEnv({ COLORTERM: "truecolor", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => { + const caps = detectCapabilities(() => false); + assert.strictEqual(caps.trueColor, true); + assert.strictEqual(caps.hyperlinks, false); + assert.strictEqual(caps.images, null); + }); + }); +}); + +describe("Kitty image cursor movement", () => { + it("can request no terminal-side cursor movement", () => { + const sequence = encodeKitty("AAAA", { columns: 2, rows: 2, moveCursor: false }); + assert.ok(sequence.startsWith("\x1b_Ga=T,f=100,q=2,C=1,c=2,r=2;")); + }); + + it("suppresses Kitty replies for delete commands", () => { + assert.strictEqual(deleteKittyImage(42), "\x1b_Ga=d,d=I,i=42,q=2\x1b\\"); + assert.strictEqual(deleteAllKittyImages(), "\x1b_Ga=d,d=A,q=2\x1b\\"); + }); + + it("preserves renderImage's default terminal-side cursor movement", () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const result = renderImage("AAAA", { widthPx: 20, heightPx: 20 }, { maxWidthCells: 2 }); + assert.ok(result); + assert.ok(!result.sequence.includes(",C=1,")); + assert.strictEqual(result.rows, 2); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("can opt renderImage into no terminal-side cursor movement", () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const result = renderImage("AAAA", { widthPx: 20, heightPx: 20 }, { maxWidthCells: 2, moveCursor: false }); + assert.ok(result); + assert.ok(result.sequence.includes(",C=1,")); + assert.strictEqual(result.rows, 2); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("honors maxHeightCells by reducing rendered width", () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const result = renderImage("AAAA", { widthPx: 10, heightPx: 100 }, { maxWidthCells: 10, maxHeightCells: 5 }); + assert.ok(result); + assert.strictEqual(result.rows, 5); + assert.ok(result.sequence.includes(",c=1,r=5")); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("caps Image component height to a square pixel box by default", () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 20 }); + try { + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 10 }, + { widthPx: 10, heightPx: 100 }, + ); + const lines = image.render(12); + assert.strictEqual(lines.length, 5); + assert.ok(lines[0].includes(",c=1,r=5")); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("places image sequence on first line with empty padding rows", () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 2 }, + { widthPx: 20, heightPx: 20 }, + ); + const lines = image.render(4); + const imageId = image.getImageId(); + assert.strictEqual(typeof imageId, "number"); + assert.ok(lines[0].startsWith("\x1b_G")); + assert.ok(lines[0].includes(",C=1,")); + assert.ok(lines[0].includes(`,i=${imageId}`)); + assert.ok(lines[0].endsWith("\x1b\\")); + assert.deepStrictEqual(lines.slice(1, lines.length), [""]); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); +}); + +describe("hyperlink", () => { + it("wraps text in OSC 8 open and close sequences", () => { + const result = hyperlink("click me", "https://example.com"); + assert.strictEqual(result, "\x1b]8;;https://example.com\x1b\\click me\x1b]8;;\x1b\\"); + }); + + it("preserves ANSI styling inside the hyperlink", () => { + const styled = "\x1b[4m\x1b[34mclick me\x1b[0m"; + const result = hyperlink(styled, "https://example.com"); + assert.ok(result.startsWith("\x1b]8;;https://example.com\x1b\\")); + assert.ok(result.includes(styled)); + assert.ok(result.endsWith("\x1b]8;;\x1b\\")); + }); + + it("works with empty text", () => { + const result = hyperlink("", "https://example.com"); + assert.strictEqual(result, "\x1b]8;;https://example.com\x1b\\\x1b]8;;\x1b\\"); + }); + + it("works with file:// URIs", () => { + const result = hyperlink("README.md", "file:///home/user/README.md"); + assert.ok(result.includes("file:///home/user/README.md")); + assert.ok(result.includes("README.md")); + }); +}); diff --git a/packages/pi-tui/test/terminal.test.ts b/packages/pi-tui/test/terminal.test.ts new file mode 100644 index 000000000..a356bff1d --- /dev/null +++ b/packages/pi-tui/test/terminal.test.ts @@ -0,0 +1,233 @@ +import assert from "node:assert"; +import { describe, it, mock } from "node:test"; +import { setKittyProtocolActive } from "../src/keys.ts"; +import { normalizeAppleTerminalInput, ProcessTerminal } from "../src/terminal.ts"; + +describe("normalizeAppleTerminalInput", () => { + it("rewrites Apple Terminal Return to CSI-u Shift+Enter when Shift is pressed", () => { + assert.equal(normalizeAppleTerminalInput("\r", true, true), "\x1b[13;2u"); + }); + + it("leaves Apple Terminal Return unchanged when Shift is not pressed", () => { + assert.equal(normalizeAppleTerminalInput("\r", true, false), "\r"); + }); + + it("leaves non-Apple Terminal Return unchanged when Shift is pressed", () => { + assert.equal(normalizeAppleTerminalInput("\r", false, true), "\r"); + }); + + it("leaves non-Return input unchanged", () => { + assert.equal(normalizeAppleTerminalInput("\x1b[13;2u", true, true), "\x1b[13;2u"); + assert.equal(normalizeAppleTerminalInput("a", true, true), "a"); + }); +}); + +describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { + type NegotiationHarness = { + terminal: ProcessTerminal; + writes: string[]; + send(data: string): void; + getInput(): string | undefined; + cleanup(): void; + }; + + function setupNegotiation(): NegotiationHarness { + const terminal = new ProcessTerminal(); + const writes: string[] = []; + let input: string | undefined; + let dataHandler: ((data: string) => void) | undefined; + let cleaned = false; + const previousWrite = process.stdout.write; + const previousOn = process.stdin.on; + + process.stdout.write = ((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + process.stdin.on = ((event: string | symbol, listener: (...args: unknown[]) => void) => { + if (event === "data") dataHandler = listener as (data: string) => void; + return process.stdin; + }) as typeof process.stdin.on; + + ( + terminal as unknown as { + inputHandler?: (data: string) => void; + queryAndEnableKittyProtocol(): void; + } + ).inputHandler = (data) => { + input = data; + }; + (terminal as unknown as { queryAndEnableKittyProtocol(): void }).queryAndEnableKittyProtocol(); + + return { + terminal, + writes, + send(data: string): void { + dataHandler?.(data); + }, + getInput(): string | undefined { + return input; + }, + cleanup(): void { + if (cleaned) return; + cleaned = true; + try { + terminal.stop(); + } finally { + process.stdout.write = previousWrite; + process.stdin.on = previousOn; + setKittyProtocolActive(false); + } + }, + }; + } + + it("queries Kitty mode before enabling modifyOtherKeys fallback", () => { + const harness = setupNegotiation(); + try { + assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); + assert.equal(harness.writes.includes("\x1b[>4;2m"), false); + assert.equal(harness.terminal.kittyProtocolActive, false); + } finally { + harness.cleanup(); + } + }); + + it("activates Kitty mode for non-zero negotiated flags", () => { + const harness = setupNegotiation(); + try { + harness.send("\x1b[?7u"); + + assert.equal(harness.getInput(), undefined); + assert.equal(harness.terminal.kittyProtocolActive, true); + assert.equal(harness.writes.includes("\x1b[>4;2m"), false); + assert.equal(harness.writes.includes("\x1b[>4;0m"), false); + + harness.cleanup(); + assert.equal(harness.writes.filter((write) => write === "\x1b[4;0m"), false); + } finally { + harness.cleanup(); + } + }); + + it("falls back to modifyOtherKeys for zero Kitty flags", () => { + const harness = setupNegotiation(); + try { + harness.send("\x1b[?0u"); + + assert.equal(harness.getInput(), undefined); + assert.equal(harness.terminal.kittyProtocolActive, false); + assert.equal(harness.writes.filter((write) => write === "\x1b[>4;2m").length, 1); + + harness.cleanup(); + assert.equal(harness.writes.filter((write) => write === "\x1b[>4;0m").length, 1); + } finally { + harness.cleanup(); + } + }); + + it("falls back to modifyOtherKeys for device attributes without Kitty flags", () => { + const harness = setupNegotiation(); + try { + harness.send("\x1b[?62;4;52c"); + + assert.equal(harness.getInput(), undefined); + assert.equal(harness.terminal.kittyProtocolActive, false); + assert.equal(harness.writes.filter((write) => write === "\x1b[>4;2m").length, 1); + } finally { + harness.cleanup(); + } + }); + + it("forwards normal input while waiting for Kitty response", () => { + const harness = setupNegotiation(); + try { + harness.send("a"); + + assert.equal(harness.getInput(), "a"); + assert.equal(harness.terminal.kittyProtocolActive, false); + } finally { + harness.cleanup(); + } + }); + + it("tracks split Kitty confirmation", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + harness.send("\x1b[?7"); + mock.timers.tick(10); + + assert.equal(harness.getInput(), undefined); + + harness.send("u"); + + assert.equal(harness.terminal.kittyProtocolActive, true); + assert.equal(harness.writes.includes("\x1b[>4;2m"), false); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); + + it("replays buffered CSI-prefix input when it is not a Kitty response", () => { + mock.timers.enable({ apis: ["setTimeout"] }); + const harness = setupNegotiation(); + try { + harness.send("\x1b["); + mock.timers.tick(10); + + assert.equal(harness.getInput(), undefined); + + mock.timers.tick(150); + + assert.equal(harness.getInput(), "\x1b["); + } finally { + harness.cleanup(); + mock.timers.reset(); + } + }); +}); + +describe("ProcessTerminal dimensions", () => { + it("falls back to COLUMNS and LINES before default dimensions", () => { + const previousColumnsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "columns"); + const previousRowsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "rows"); + const previousColumns = process.env.COLUMNS; + const previousLines = process.env.LINES; + + try { + Object.defineProperty(process.stdout, "columns", { value: undefined, configurable: true }); + Object.defineProperty(process.stdout, "rows", { value: undefined, configurable: true }); + process.env.COLUMNS = "123"; + process.env.LINES = "45"; + + const terminal = new ProcessTerminal(); + + assert.equal(terminal.columns, 123); + assert.equal(terminal.rows, 45); + } finally { + if (previousColumnsDescriptor) { + Object.defineProperty(process.stdout, "columns", previousColumnsDescriptor); + } else { + Reflect.deleteProperty(process.stdout, "columns"); + } + if (previousRowsDescriptor) { + Object.defineProperty(process.stdout, "rows", previousRowsDescriptor); + } else { + Reflect.deleteProperty(process.stdout, "rows"); + } + if (previousColumns === undefined) { + delete process.env.COLUMNS; + } else { + process.env.COLUMNS = previousColumns; + } + if (previousLines === undefined) { + delete process.env.LINES; + } else { + process.env.LINES = previousLines; + } + } + }); +}); diff --git a/packages/pi-tui/test/test-themes.ts b/packages/pi-tui/test/test-themes.ts new file mode 100644 index 000000000..33e3c39e1 --- /dev/null +++ b/packages/pi-tui/test/test-themes.ts @@ -0,0 +1,38 @@ +/** + * Default themes for TUI tests using chalk + */ + +import { Chalk } from "chalk"; +import type { EditorTheme, MarkdownTheme, SelectListTheme } from "../src/index.ts"; + +const chalk = new Chalk({ level: 3 }); + +export const defaultSelectListTheme: SelectListTheme = { + selectedPrefix: (text: string) => chalk.blue(text), + selectedText: (text: string) => chalk.bold(text), + description: (text: string) => chalk.dim(text), + scrollInfo: (text: string) => chalk.dim(text), + noMatch: (text: string) => chalk.dim(text), +}; + +export const defaultMarkdownTheme: MarkdownTheme = { + heading: (text: string) => chalk.bold.cyan(text), + link: (text: string) => chalk.blue(text), + linkUrl: (text: string) => chalk.dim(text), + code: (text: string) => chalk.yellow(text), + codeBlock: (text: string) => chalk.green(text), + codeBlockBorder: (text: string) => chalk.dim(text), + quote: (text: string) => chalk.italic(text), + quoteBorder: (text: string) => chalk.dim(text), + hr: (text: string) => chalk.dim(text), + listBullet: (text: string) => chalk.cyan(text), + bold: (text: string) => chalk.bold(text), + italic: (text: string) => chalk.italic(text), + strikethrough: (text: string) => chalk.strikethrough(text), + underline: (text: string) => chalk.underline(text), +}; + +export const defaultEditorTheme: EditorTheme = { + borderColor: (text: string) => chalk.dim(text), + selectList: defaultSelectListTheme, +}; diff --git a/packages/pi-tui/test/truncate-to-width.test.ts b/packages/pi-tui/test/truncate-to-width.test.ts new file mode 100644 index 000000000..321ba8983 --- /dev/null +++ b/packages/pi-tui/test/truncate-to-width.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { normalizeTerminalOutput, truncateToWidth, visibleWidth } from "../src/utils.ts"; + +describe("truncateToWidth", () => { + it("keeps output within width for very large unicode input", () => { + const text = "🙂界".repeat(100_000); + const truncated = truncateToWidth(text, 40, "…"); + + assert.ok(visibleWidth(truncated) <= 40); + assert.strictEqual(truncated.endsWith("…\x1b[0m"), true); + }); + + it("preserves ANSI styling for kept text and resets before and after ellipsis", () => { + const text = `\x1b[31m${"hello ".repeat(1000)}\x1b[0m`; + const truncated = truncateToWidth(text, 20, "…"); + + assert.ok(visibleWidth(truncated) <= 20); + assert.strictEqual(truncated.includes("\x1b[31m"), true); + assert.strictEqual(truncated.endsWith("\x1b[0m…\x1b[0m"), true); + }); + + it("handles malformed ANSI escape prefixes without hanging", () => { + const text = `abc\x1bnot-ansi ${"🙂".repeat(1000)}`; + const truncated = truncateToWidth(text, 20, "…"); + + assert.ok(visibleWidth(truncated) <= 20); + }); + + it("clips wide ellipsis safely and brackets it with resets", () => { + assert.strictEqual(truncateToWidth("abcdef", 1, "🙂"), ""); + assert.strictEqual(truncateToWidth("abcdef", 2, "🙂"), "\x1b[0m🙂\x1b[0m"); + assert.ok(visibleWidth(truncateToWidth("abcdef", 2, "🙂")) <= 2); + }); + + it("returns the original text when it already fits even if ellipsis is too wide", () => { + assert.strictEqual(truncateToWidth("a", 2, "🙂"), "a"); + assert.strictEqual(truncateToWidth("界", 2, "🙂"), "界"); + }); + + it("pads truncated output to requested width", () => { + const truncated = truncateToWidth("🙂界🙂界🙂界", 8, "…", true); + assert.strictEqual(visibleWidth(truncated), 8); + }); + + it("adds a trailing reset when truncating without an ellipsis", () => { + const truncated = truncateToWidth(`\x1b[31m${"hello".repeat(100)}`, 10, ""); + assert.ok(visibleWidth(truncated) <= 10); + assert.strictEqual(truncated.endsWith("\x1b[0m"), true); + }); + + it("keeps a contiguous prefix instead of skipping a wide grapheme and resuming later", () => { + const truncated = truncateToWidth("🙂\t界 \x1b_abc\x07", 7, "…", true); + assert.strictEqual(truncated, "🙂\t\x1b[0m…\x1b[0m "); + }); +}); + +describe("visibleWidth", () => { + it("counts tabs inline and skips ANSI inline", () => { + assert.strictEqual(visibleWidth("\t\x1b[31m界\x1b[0m"), 5); + }); + + it("keeps Thai and Lao AM clusters at their normal cell width", () => { + assert.strictEqual(visibleWidth("ำ"), 1); + assert.strictEqual(visibleWidth("ຳ"), 1); + assert.strictEqual(visibleWidth("กำ"), 2); + assert.strictEqual(visibleWidth("ກຳ"), 2); + }); + + it("normalizes Thai and Lao AM vowels only for terminal output", () => { + assert.strictEqual(normalizeTerminalOutput("ำ"), "ํา"); + assert.strictEqual(normalizeTerminalOutput("ຳ"), "ໍາ"); + assert.strictEqual(visibleWidth(normalizeTerminalOutput("ำabc")), visibleWidth("ำabc")); + assert.strictEqual(visibleWidth(normalizeTerminalOutput("ຳabc")), visibleWidth("ຳabc")); + }); +}); diff --git a/packages/pi-tui/test/truncated-text.test.ts b/packages/pi-tui/test/truncated-text.test.ts new file mode 100644 index 000000000..a25034e27 --- /dev/null +++ b/packages/pi-tui/test/truncated-text.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { Chalk } from "chalk"; +import { TruncatedText } from "../src/components/truncated-text.ts"; +import { visibleWidth } from "../src/utils.ts"; + +// Force full color in CI so ANSI assertions are deterministic +const chalk = new Chalk({ level: 3 }); + +describe("TruncatedText component", () => { + it("pads output lines to exactly match width", () => { + const text = new TruncatedText("Hello world", 1, 0); + const lines = text.render(50); + + // Should have exactly one content line (no vertical padding) + assert.strictEqual(lines.length, 1); + + // Line should be exactly 50 visible characters + const visibleLen = visibleWidth(lines[0]); + assert.strictEqual(visibleLen, 50); + }); + + it("pads output with vertical padding lines to width", () => { + const text = new TruncatedText("Hello", 0, 2); + const lines = text.render(40); + + // Should have 2 padding lines + 1 content line + 2 padding lines = 5 total + assert.strictEqual(lines.length, 5); + + // All lines should be exactly 40 characters + for (const line of lines) { + assert.strictEqual(visibleWidth(line), 40); + } + }); + + it("truncates long text and pads to width", () => { + const longText = "This is a very long piece of text that will definitely exceed the available width"; + const text = new TruncatedText(longText, 1, 0); + const lines = text.render(30); + + assert.strictEqual(lines.length, 1); + + // Should be exactly 30 characters + assert.strictEqual(visibleWidth(lines[0]), 30); + + // Should contain ellipsis + const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, ""); + assert.ok(stripped.includes("...")); + }); + + it("preserves ANSI codes in output and pads correctly", () => { + const styledText = `${chalk.red("Hello")} ${chalk.blue("world")}`; + const text = new TruncatedText(styledText, 1, 0); + const lines = text.render(40); + + assert.strictEqual(lines.length, 1); + + // Should be exactly 40 visible characters (ANSI codes don't count) + assert.strictEqual(visibleWidth(lines[0]), 40); + + // Should preserve the color codes + assert.ok(lines[0].includes("\x1b[")); + }); + + it("truncates styled text and adds reset code before ellipsis", () => { + const longStyledText = chalk.red("This is a very long red text that will be truncated"); + const text = new TruncatedText(longStyledText, 1, 0); + const lines = text.render(20); + + assert.strictEqual(lines.length, 1); + + // Should be exactly 20 visible characters + assert.strictEqual(visibleWidth(lines[0]), 20); + + // Should contain reset code before ellipsis + assert.ok(lines[0].includes("\x1b[0m...")); + }); + + it("handles text that fits exactly", () => { + // With paddingX=1, available width is 30-2=28 + // "Hello world" is 11 chars, fits comfortably + const text = new TruncatedText("Hello world", 1, 0); + const lines = text.render(30); + + assert.strictEqual(lines.length, 1); + assert.strictEqual(visibleWidth(lines[0]), 30); + + // Should NOT contain ellipsis + const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, ""); + assert.ok(!stripped.includes("...")); + }); + + it("handles empty text", () => { + const text = new TruncatedText("", 1, 0); + const lines = text.render(30); + + assert.strictEqual(lines.length, 1); + assert.strictEqual(visibleWidth(lines[0]), 30); + }); + + it("stops at newline and only shows first line", () => { + const multilineText = "First line\nSecond line\nThird line"; + const text = new TruncatedText(multilineText, 1, 0); + const lines = text.render(40); + + assert.strictEqual(lines.length, 1); + assert.strictEqual(visibleWidth(lines[0]), 40); + + // Should only contain "First line" + const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "").trim(); + assert.ok(stripped.includes("First line")); + assert.ok(!stripped.includes("Second line")); + assert.ok(!stripped.includes("Third line")); + }); + + it("truncates first line even with newlines in text", () => { + const longMultilineText = "This is a very long first line that needs truncation\nSecond line"; + const text = new TruncatedText(longMultilineText, 1, 0); + const lines = text.render(25); + + assert.strictEqual(lines.length, 1); + assert.strictEqual(visibleWidth(lines[0]), 25); + + // Should contain ellipsis and not second line + const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, ""); + assert.ok(stripped.includes("...")); + assert.ok(!stripped.includes("Second line")); + }); +}); diff --git a/packages/pi-tui/test/tui-cell-size-input.test.ts b/packages/pi-tui/test/tui-cell-size-input.test.ts new file mode 100644 index 000000000..fab915cac --- /dev/null +++ b/packages/pi-tui/test/tui-cell-size-input.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { getCellDimensions, resetCapabilitiesCache, setCellDimensions } from "../src/terminal-image.ts"; +import { type Component, TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class InputRecorder implements Component { + readonly inputs: string[] = []; + + render(): string[] { + return [""]; + } + + handleInput(data: string): void { + this.inputs.push(data); + } + + invalidate(): void {} +} + +function withImageTerminal(fn: () => T): T { + const prevTermProgram = process.env.TERM_PROGRAM; + const prevTerm = process.env.TERM; + const prevGhosttyResourcesDir = process.env.GHOSTTY_RESOURCES_DIR; + + process.env.TERM_PROGRAM = "ghostty"; + delete process.env.TERM; + delete process.env.GHOSTTY_RESOURCES_DIR; + resetCapabilitiesCache(); + + try { + return fn(); + } finally { + if (prevTermProgram === undefined) delete process.env.TERM_PROGRAM; + else process.env.TERM_PROGRAM = prevTermProgram; + if (prevTerm === undefined) delete process.env.TERM; + else process.env.TERM = prevTerm; + if (prevGhosttyResourcesDir === undefined) delete process.env.GHOSTTY_RESOURCES_DIR; + else process.env.GHOSTTY_RESOURCES_DIR = prevGhosttyResourcesDir; + resetCapabilitiesCache(); + } +} + +describe("TUI cell size responses", () => { + it("forwards bare escape even when a cell size query was sent at startup", () => { + withImageTerminal(() => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const recorder = new InputRecorder(); + + tui.setFocus(recorder); + tui.start(); + + terminal.sendInput("\x1b"); + + assert.deepStrictEqual(recorder.inputs, ["\x1b"]); + tui.stop(); + }); + }); + + it("consumes cell size responses and still forwards later user input", () => { + withImageTerminal(() => { + setCellDimensions({ widthPx: 9, heightPx: 18 }); + + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const recorder = new InputRecorder(); + + tui.setFocus(recorder); + tui.start(); + + terminal.sendInput("\x1b[6;20;10t"); + assert.deepStrictEqual(recorder.inputs, []); + assert.deepStrictEqual(getCellDimensions(), { widthPx: 10, heightPx: 20 }); + + terminal.sendInput("q"); + assert.deepStrictEqual(recorder.inputs, ["q"]); + tui.stop(); + }); + }); +}); diff --git a/packages/pi-tui/test/tui-overlay-style-leak.test.ts b/packages/pi-tui/test/tui-overlay-style-leak.test.ts new file mode 100644 index 000000000..a44b699ee --- /dev/null +++ b/packages/pi-tui/test/tui-overlay-style-leak.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import type { Terminal as XtermTerminalType } from "@xterm/headless"; +import { type Component, TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class StaticLines implements Component { + private readonly lines: string[]; + + constructor(lines: string[]) { + this.lines = lines; + } + + render(): string[] { + return this.lines; + } + + invalidate(): void {} +} + +class StaticOverlay implements Component { + private readonly line: string; + + constructor(line: string) { + this.line = line; + } + + render(): string[] { + return [this.line]; + } + + invalidate(): void {} +} + +function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number { + const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm; + const buffer = xterm.buffer.active; + const line = buffer.getLine(buffer.viewportY + row); + assert.ok(line, `Missing buffer line at row ${row}`); + const cell = line.getCell(col); + assert.ok(cell, `Missing cell at row ${row} col ${col}`); + return cell.isItalic(); +} + +async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise { + tui.requestRender(true); + await new Promise((resolve) => process.nextTick(resolve)); + await terminal.waitForRender(); +} + +describe("TUI overlay compositing", () => { + it("should not leak styles when a trailing reset sits beyond the last visible column (no overlay)", async () => { + const width = 20; + const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`; + + const terminal = new VirtualTerminal(width, 6); + const tui = new TUI(terminal); + tui.addChild(new StaticLines([baseLine, "INPUT"])); + tui.start(); + await renderAndFlush(tui, terminal); + assert.strictEqual(getCellItalic(terminal, 1, 0), 0); + tui.stop(); + }); + + it("should not leak styles when overlay slicing drops trailing SGR resets", async () => { + const width = 20; + const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`; + + const terminal = new VirtualTerminal(width, 6); + const tui = new TUI(terminal); + tui.addChild(new StaticLines([baseLine, "INPUT"])); + + tui.showOverlay(new StaticOverlay("OVR"), { row: 0, col: 5, width: 3 }); + tui.start(); + await renderAndFlush(tui, terminal); + + assert.strictEqual(getCellItalic(terminal, 1, 0), 0); + tui.stop(); + }); +}); diff --git a/packages/pi-tui/test/tui-render.test.ts b/packages/pi-tui/test/tui-render.test.ts new file mode 100644 index 000000000..90c0bcc04 --- /dev/null +++ b/packages/pi-tui/test/tui-render.test.ts @@ -0,0 +1,802 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import type { Terminal as XtermTerminalType } from "@xterm/headless"; +import { Image } from "../src/components/image.ts"; +import { + deleteKittyImage, + encodeKitty, + resetCapabilitiesCache, + setCapabilities, + setCellDimensions, +} from "../src/terminal-image.ts"; +import { type Component, TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class TestComponent implements Component { + lines: string[] = []; + render(_width: number): string[] { + return this.lines; + } + invalidate(): void {} +} + +class LoggingVirtualTerminal extends VirtualTerminal { + private writes: string[] = []; + + override write(data: string): void { + this.writes.push(data); + super.write(data); + } + + getWrites(): string { + return this.writes.join(""); + } + + clearWrites(): void { + this.writes = []; + } +} + +async function withEnv(updates: Record, run: () => Promise): Promise { + const previousValues = new Map(); + for (const [key, value] of Object.entries(updates)) { + previousValues.set(key, process.env[key]); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + try { + return await run(); + } finally { + for (const [key, value] of previousValues) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number { + const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm; + const buffer = xterm.buffer.active; + const line = buffer.getLine(buffer.viewportY + row); + assert.ok(line, `Missing buffer line at row ${row}`); + const cell = line.getCell(col); + assert.ok(cell, `Missing cell at row ${row} col ${col}`); + return cell.isItalic(); +} + +describe("TUI Kitty image cleanup", () => { + it("clears reserved Kitty image rows before drawing appended image placements", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["before"]; + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 2 }, + { widthPx: 20, heightPx: 20 }, + ); + const imageLines = image.render(40); + const imageSequence = imageLines[0]; + component.lines = ["before", ...imageLines, "after"]; + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok( + writes.includes(`\x1b[2K\r\n\x1b[2K\x1b[1A${imageSequence}\x1b[1B`), + "reserved rows should be cleared before the image placement is drawn", + ); + assert.ok( + !writes.includes(`${imageSequence}\r\n\x1b[2K`), + "reserved row clears must not run after the image placement is drawn", + ); + + tui.stop(); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("falls back to full redraw when Kitty image pre-clear would scroll", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const terminal = new LoggingVirtualTerminal(40, 2); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["before"]; + tui.start(); + await terminal.waitForRender(); + const redrawsBeforeImage = tui.fullRedraws; + terminal.clearWrites(); + + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 3 }, + { widthPx: 30, heightPx: 30 }, + ); + component.lines = ["before", ...image.render(40), "after"]; + tui.requestRender(); + await terminal.waitForRender(); + + assert.ok(tui.fullRedraws > redrawsBeforeImage, "unsafe image pre-clear should force a full redraw"); + assert.ok(terminal.getWrites().includes("\x1b[2J"), "fallback should clear and fully redraw"); + + tui.stop(); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("reserves Kitty image rows before drawing during full redraw fallbacks", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const terminal = new LoggingVirtualTerminal(40, 5); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["l0", "l1", "l2", "l3", "l4"]; + tui.start(); + await terminal.waitForRender(); + const redrawsBeforeImage = tui.fullRedraws; + terminal.clearWrites(); + + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 3 }, + { widthPx: 30, heightPx: 30 }, + ); + const imageLines = image.render(40); + const imageSequence = imageLines[0]; + component.lines = ["l0", "l1", "l2", "l3", "l4", ...imageLines, "after"]; + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(tui.fullRedraws > redrawsBeforeImage, "scrolling image append should force a full redraw"); + assert.ok( + writes.includes(`\r\n\r\n\x1b[2A${imageSequence}\x1b[2B`), + "full redraw should reserve visible image rows before drawing the placement", + ); + assert.ok( + !writes.includes(`${imageSequence}\r\n\x1b[0m`), + "full redraw must not write reserved padding rows after drawing the placement", + ); + + tui.stop(); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("does not use cursor-up placement for Kitty images taller than the viewport", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const terminal = new LoggingVirtualTerminal(40, 5); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["before"]; + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { maxWidthCells: 6 }, + { widthPx: 60, heightPx: 60 }, + ); + const imageLines = image.render(40); + const imageSequence = imageLines[0]; + assert.ok(imageLines.length > terminal.rows, "test image should exceed the viewport height"); + + component.lines = ["before", ...imageLines, "after"]; + tui.requestRender(true); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(writes.includes(imageSequence), "image placement should be drawn"); + assert.ok( + !writes.includes(`\x1b[${imageLines.length - 1}A${imageSequence}`), + "taller-than-viewport images must keep the #4461 first-row placement path", + ); + + tui.stop(); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + + it("deletes changed image ids before drawing moved placements", async () => { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + const oldImage = encodeKitty("AAAA", { columns: 2, rows: 2, imageId: 42, moveCursor: false }); + component.lines = ["top", oldImage]; + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + const newImage = encodeKitty("BBBB", { columns: 2, rows: 1, imageId: 42, moveCursor: false }); + component.lines = [newImage, ""]; + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + const deleteIndex = writes.indexOf(deleteKittyImage(42)); + const drawIndex = writes.indexOf(newImage); + assert.ok(deleteIndex >= 0, "changed old image should be deleted"); + assert.ok(drawIndex >= 0, "new image should be drawn"); + assert.ok(deleteIndex < drawIndex, "old image must be deleted before the new placement is drawn"); + + tui.stop(); + }); + + it("redraws image lines when an earlier reserved image row changes", async () => { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + const image = encodeKitty("AAAA", { columns: 2, rows: 2, imageId: 88, moveCursor: false }); + component.lines = ["", image]; + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + component.lines = ["covered", image]; + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + const deleteIndex = writes.indexOf(deleteKittyImage(88)); + const drawIndex = writes.indexOf(image); + assert.ok(deleteIndex >= 0, "image should be deleted when a reserved row changes"); + assert.ok(drawIndex >= 0, "unchanged image line should be redrawn after deleting the placement"); + assert.ok(deleteIndex < drawIndex, "old placement must be deleted before the image line is redrawn"); + assert.ok(!writes.includes("\x1b[2J"), "reserved row changes should not force a full redraw"); + + tui.stop(); + }); + + it("deletes previously rendered image ids during full redraws", async () => { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = [encodeKitty("AAAA", { columns: 2, rows: 2, imageId: 77, moveCursor: false })]; + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + component.lines = ["plain text"]; + tui.requestRender(true); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + const deleteIndex = writes.indexOf(deleteKittyImage(77)); + const clearIndex = writes.indexOf("\x1b[2J"); + assert.ok(deleteIndex >= 0, "previous image should be deleted during full redraw"); + assert.ok(clearIndex >= 0, "full redraw should clear the screen"); + assert.ok(deleteIndex < clearIndex, "old image should be deleted before the screen is cleared"); + + tui.stop(); + }); +}); + +describe("TUI resize handling", () => { + it("triggers full re-render when terminal height changes", async () => { + await withEnv({ TERMUX_VERSION: undefined }, async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["Line 0", "Line 1", "Line 2"]; + tui.start(); + await terminal.waitForRender(); + + const initialRedraws = tui.fullRedraws; + + // Resize height + terminal.resize(40, 15); + await terminal.waitForRender(); + + // Should have triggered a full redraw + assert.ok(tui.fullRedraws > initialRedraws, "Height change should trigger full redraw"); + + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("Line 0"), "Content preserved after height change"); + + tui.stop(); + }); + }); + + it("skips full re-render on height changes in Termux", async () => { + await withEnv({ TERMUX_VERSION: "1" }, async () => { + const terminal = new LoggingVirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = Array.from({ length: 20 }, (_, i) => `Line ${i}`); + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + const initialRedraws = tui.fullRedraws; + for (const height of [15, 8, 14, 11]) { + terminal.resize(40, height); + await terminal.waitForRender(); + } + + assert.strictEqual(tui.fullRedraws, initialRedraws, "Height change should not trigger full redraw"); + assert.ok(!terminal.getWrites().includes("\x1b[2J"), "Height change should not clear the screen"); + assert.ok(!terminal.getWrites().includes("\x1b[3J"), "Height change should not clear scrollback"); + + const viewport = terminal.getViewport(); + assert.ok(viewport.join("\n").includes("Line 19"), "Latest content remains visible after resize"); + + tui.stop(); + }); + }); + + it("triggers full re-render when terminal width changes", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["Line 0", "Line 1", "Line 2"]; + tui.start(); + await terminal.waitForRender(); + + const initialRedraws = tui.fullRedraws; + + // Resize width + terminal.resize(60, 10); + await terminal.waitForRender(); + + // Should have triggered a full redraw + assert.ok(tui.fullRedraws > initialRedraws, "Width change should trigger full redraw"); + + tui.stop(); + }); +}); + +describe("TUI content shrinkage", () => { + it("clears empty rows when content shrinks significantly", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var) + const component = new TestComponent(); + tui.addChild(component); + + // Start with many lines + component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4", "Line 5"]; + tui.start(); + await terminal.waitForRender(); + + const initialRedraws = tui.fullRedraws; + + // Shrink to fewer lines + component.lines = ["Line 0", "Line 1"]; + tui.requestRender(); + await terminal.waitForRender(); + + // Should have triggered a full redraw to clear empty rows + assert.ok(tui.fullRedraws > initialRedraws, "Content shrinkage should trigger full redraw"); + + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("Line 0"), "First line preserved"); + assert.ok(viewport[1]?.includes("Line 1"), "Second line preserved"); + // Lines below should be empty (cleared) + assert.strictEqual(viewport[2]?.trim(), "", "Line 2 should be cleared"); + assert.strictEqual(viewport[3]?.trim(), "", "Line 3 should be cleared"); + + tui.stop(); + }); + + it("handles shrink to single line", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var) + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"]; + tui.start(); + await terminal.waitForRender(); + + // Shrink to single line + component.lines = ["Only line"]; + tui.requestRender(); + await terminal.waitForRender(); + + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("Only line"), "Single line rendered"); + assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared"); + + tui.stop(); + }); + + it("handles shrink to empty", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var) + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["Line 0", "Line 1", "Line 2"]; + tui.start(); + await terminal.waitForRender(); + + // Shrink to empty + component.lines = []; + tui.requestRender(); + await terminal.waitForRender(); + + const viewport = terminal.getViewport(); + // All lines should be empty + assert.strictEqual(viewport[0]?.trim(), "", "Line 0 should be cleared"); + assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared"); + + tui.stop(); + }); +}); + +describe("TUI differential rendering", () => { + it("tracks cursor correctly when content shrinks with unchanged remaining lines", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + // Initial render: 5 identical lines + component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"]; + tui.start(); + await terminal.waitForRender(); + + // Shrink to 3 lines, all identical to before (no content changes in remaining lines) + component.lines = ["Line 0", "Line 1", "Line 2"]; + tui.requestRender(); + await terminal.waitForRender(); + + // cursorRow should be 2 (last line of new content) + // Verify by doing another render with a change on line 1 + component.lines = ["Line 0", "CHANGED", "Line 2"]; + tui.requestRender(); + await terminal.waitForRender(); + + const viewport = terminal.getViewport(); + // Line 1 should show "CHANGED", proving cursor tracking was correct + assert.ok(viewport[1]?.includes("CHANGED"), `Expected "CHANGED" on line 1, got: ${viewport[1]}`); + + tui.stop(); + }); + + it("renders correctly when only a middle line changes (spinner case)", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + // Initial render + component.lines = ["Header", "Working...", "Footer"]; + tui.start(); + await terminal.waitForRender(); + + // Simulate spinner animation - only middle line changes + const spinnerFrames = ["|", "/", "-", "\\"]; + for (const frame of spinnerFrames) { + component.lines = ["Header", `Working ${frame}`, "Footer"]; + tui.requestRender(); + await terminal.waitForRender(); + + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("Header"), `Header preserved: ${viewport[0]}`); + assert.ok(viewport[1]?.includes(`Working ${frame}`), `Spinner updated: ${viewport[1]}`); + assert.ok(viewport[2]?.includes("Footer"), `Footer preserved: ${viewport[2]}`); + } + + tui.stop(); + }); + + it("resets styles after each rendered line", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["\x1b[3mItalic", "Plain"]; + tui.start(); + await terminal.waitForRender(); + + assert.strictEqual(getCellItalic(terminal, 1, 0), 0); + tui.stop(); + }); + + it("renders correctly when first line changes but rest stays same", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"]; + tui.start(); + await terminal.waitForRender(); + + // Change only first line + component.lines = ["CHANGED", "Line 1", "Line 2", "Line 3"]; + tui.requestRender(); + await terminal.waitForRender(); + + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("CHANGED"), `First line changed: ${viewport[0]}`); + assert.ok(viewport[1]?.includes("Line 1"), `Line 1 preserved: ${viewport[1]}`); + assert.ok(viewport[2]?.includes("Line 2"), `Line 2 preserved: ${viewport[2]}`); + assert.ok(viewport[3]?.includes("Line 3"), `Line 3 preserved: ${viewport[3]}`); + + tui.stop(); + }); + + it("renders correctly when last line changes but rest stays same", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"]; + tui.start(); + await terminal.waitForRender(); + + // Change only last line + component.lines = ["Line 0", "Line 1", "Line 2", "CHANGED"]; + tui.requestRender(); + await terminal.waitForRender(); + + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("Line 0"), `Line 0 preserved: ${viewport[0]}`); + assert.ok(viewport[1]?.includes("Line 1"), `Line 1 preserved: ${viewport[1]}`); + assert.ok(viewport[2]?.includes("Line 2"), `Line 2 preserved: ${viewport[2]}`); + assert.ok(viewport[3]?.includes("CHANGED"), `Last line changed: ${viewport[3]}`); + + tui.stop(); + }); + + it("renders correctly when multiple non-adjacent lines change", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"]; + tui.start(); + await terminal.waitForRender(); + + // Change lines 1 and 3, keep 0, 2, 4 the same + component.lines = ["Line 0", "CHANGED 1", "Line 2", "CHANGED 3", "Line 4"]; + tui.requestRender(); + await terminal.waitForRender(); + + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("Line 0"), `Line 0 preserved: ${viewport[0]}`); + assert.ok(viewport[1]?.includes("CHANGED 1"), `Line 1 changed: ${viewport[1]}`); + assert.ok(viewport[2]?.includes("Line 2"), `Line 2 preserved: ${viewport[2]}`); + assert.ok(viewport[3]?.includes("CHANGED 3"), `Line 3 changed: ${viewport[3]}`); + assert.ok(viewport[4]?.includes("Line 4"), `Line 4 preserved: ${viewport[4]}`); + + tui.stop(); + }); + + it("handles transition from content to empty and back to content", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + // Start with content + component.lines = ["Line 0", "Line 1", "Line 2"]; + tui.start(); + await terminal.waitForRender(); + + let viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("Line 0"), "Initial content rendered"); + + // Clear to empty + component.lines = []; + tui.requestRender(); + await terminal.waitForRender(); + + // Add content back - this should work correctly even after empty state + component.lines = ["New Line 0", "New Line 1"]; + tui.requestRender(); + await terminal.waitForRender(); + + viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("New Line 0"), `New content rendered: ${viewport[0]}`); + assert.ok(viewport[1]?.includes("New Line 1"), `New content line 1: ${viewport[1]}`); + + tui.stop(); + }); + + it("full re-renders when deleted lines move the viewport upward", async () => { + const terminal = new VirtualTerminal(20, 5); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = Array.from({ length: 12 }, (_, i) => `Line ${i}`); + tui.start(); + await terminal.waitForRender(); + + const initialRedraws = tui.fullRedraws; + + component.lines = Array.from({ length: 7 }, (_, i) => `Line ${i}`); + tui.requestRender(); + await terminal.waitForRender(); + + assert.ok(tui.fullRedraws > initialRedraws, "Shrink should trigger a full redraw"); + assert.deepStrictEqual(terminal.getViewport(), ["Line 2", "Line 3", "Line 4", "Line 5", "Line 6"]); + + tui.stop(); + }); + + it("appends after a shrink without another full redraw once the viewport is reset", async () => { + const terminal = new VirtualTerminal(20, 5); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + component.lines = Array.from({ length: 8 }, (_, i) => `Line ${i}`); + tui.start(); + await terminal.waitForRender(); + + const initialRedraws = tui.fullRedraws; + + component.lines = ["Line 0", "Line 1"]; + tui.requestRender(); + await terminal.waitForRender(); + + assert.ok(tui.fullRedraws > initialRedraws, "Shrink should reset the viewport with a full redraw"); + const redrawsAfterShrink = tui.fullRedraws; + + component.lines = ["Line 0", "Line 1", "Line 2"]; + tui.requestRender(); + await terminal.waitForRender(); + + assert.strictEqual(tui.fullRedraws, redrawsAfterShrink, "Append should stay on the differential path"); + assert.deepStrictEqual(terminal.getViewport(), ["Line 0", "Line 1", "Line 2", "", ""]); + + tui.stop(); + }); + + it("clears stale content when maxLinesRendered was inflated by a transient component", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const chat = new TestComponent(); + const editor = new TestComponent(); + tui.addChild(chat); + tui.addChild(editor); + + const longChat = Array.from({ length: 15 }, (_, i) => `Chat ${i}`); + const shortChat = Array.from({ length: 12 }, (_, i) => `Chat ${i}`); + const editorLines = ["Editor 0", "Editor 1", "Editor 2"]; + const selectorLines = Array.from({ length: 8 }, (_, i) => `Selector ${i}`); + + chat.lines = longChat; + editor.lines = editorLines; + tui.start(); + await terminal.waitForRender(); + + editor.lines = selectorLines; + tui.requestRender(); + await terminal.waitForRender(); + + editor.lines = editorLines; + tui.requestRender(); + await terminal.waitForRender(); + + const redrawsBeforeSwitch = tui.fullRedraws; + chat.lines = shortChat; + tui.requestRender(); + await terminal.waitForRender(); + + assert.strictEqual( + tui.fullRedraws, + redrawsBeforeSwitch, + "Branch switch should not trigger a full redraw (clamped to viewport)", + ); + + const viewport = terminal.getViewport(); + for (let i = 0; i < 10; i++) { + const line = viewport[i] ?? ""; + assert.ok(!line.includes("Chat 12"), `Stale "Chat 12" at viewport row ${i}`); + assert.ok(!line.includes("Chat 13"), `Stale "Chat 13" at viewport row ${i}`); + assert.ok(!line.includes("Chat 14"), `Stale "Chat 14" at viewport row ${i}`); + } + + // After clamping, the viewport keeps its previous scroll position + // (prevViewportTop=13) rather than resetting to the new content bottom. + // The stale "Chat 12/13/14" rows remain in scrollback but are not visible. + assert.deepStrictEqual(viewport, [ + "Editor 1", + "Editor 2", + "", + "", + "", + "", + "", + "", + "", + "", + ]); + + tui.stop(); + }); +}); + + +describe("TUI scrollback preservation", () => { + it("does not emit full redraw when changes are above the viewport", async () => { + const terminal = new LoggingVirtualTerminal(40, 5); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + // Render 8 lines into a height-5 terminal so previousViewportTop > 0 + // (viewport shows lines 3..7, lines 0..2 are above the viewport). + component.lines = ["line0", "line1", "line2", "line3", "line4", "line5", "line6", "line7"]; + tui.start(); + await terminal.waitForRender(); + terminal.clearWrites(); + + // Change a line above the viewport (line0); this must not trigger fullRender. + component.lines[0] = "line0-changed"; + tui.requestRender(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(!writes.includes("\x1b[3J"), "should not clear scrollback (no ESC[3J)"); + assert.ok(!writes.includes("\x1b[2J"), "should not full redraw (no ESC[2J)"); + + tui.stop(); + }); +}); \ No newline at end of file diff --git a/packages/pi-tui/test/tui-shrink.test.ts b/packages/pi-tui/test/tui-shrink.test.ts new file mode 100644 index 000000000..13fa5c4bb --- /dev/null +++ b/packages/pi-tui/test/tui-shrink.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { type Component, TUI } from "../src/tui.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class Lines implements Component { + private lines: string[]; + + constructor(lines: string[]) { + this.lines = lines; + } + + render(): string[] { + return this.lines; + } + + invalidate(): void {} +} + +describe("TUI shrinking content", () => { + it("clears all rendered lines when content shrinks to zero", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const content = new Lines(["first", "second", "third"]); + tui.addChild(content); + tui.start(); + await terminal.waitForRender(); + + assert.ok(terminal.getViewport().some((line) => line.includes("first"))); + assert.ok(terminal.getViewport().some((line) => line.includes("second"))); + assert.ok(terminal.getViewport().some((line) => line.includes("third"))); + + tui.clear(); + tui.requestRender(); + await terminal.waitForRender(); + + const viewport = terminal.getViewport(); + assert.ok(!viewport.some((line) => line.includes("first")), "first line should be cleared"); + assert.ok(!viewport.some((line) => line.includes("second")), "second line should be cleared"); + assert.ok(!viewport.some((line) => line.includes("third")), "third line should be cleared"); + + tui.stop(); + }); +}); diff --git a/packages/pi-tui/test/viewport-overwrite-repro.ts b/packages/pi-tui/test/viewport-overwrite-repro.ts new file mode 100644 index 000000000..1ef432a03 --- /dev/null +++ b/packages/pi-tui/test/viewport-overwrite-repro.ts @@ -0,0 +1,106 @@ +/** + * TUI viewport overwrite repro + * + * Place this file at: packages/tui/test/viewport-overwrite-repro.ts + * Run from repo root: npx tsx packages/tui/test/viewport-overwrite-repro.ts + * + * For reliable repro, run in a small terminal (8-12 rows) or a tmux session: + * tmux new-session -d -s tui-bug -x 80 -y 12 + * tmux send-keys -t tui-bug "npx tsx packages/tui/test/viewport-overwrite-repro.ts" Enter + * tmux attach -t tui-bug + * + * Expected behavior: + * - PRE-TOOL lines remain visible above tool output. + * - POST-TOOL lines append after tool output without overwriting earlier content. + * + * Actual behavior (bug): + * - When content exceeds the viewport and new lines arrive after a tool-call pause, + * some earlier PRE-TOOL lines near the bottom are overwritten by POST-TOOL lines. + */ +import { ProcessTerminal } from "../src/terminal.ts"; +import { type Component, TUI } from "../src/tui.ts"; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +class Lines implements Component { + private lines: string[] = []; + + set(lines: string[]): void { + this.lines = lines; + } + + append(lines: string[]): void { + this.lines.push(...lines); + } + + render(width: number): string[] { + return this.lines.map((line) => { + if (line.length > width) return line.slice(0, width); + return line.padEnd(width, " "); + }); + } + + invalidate(): void {} +} + +async function streamLines(buffer: Lines, label: string, count: number, delayMs: number, ui: TUI): Promise { + for (let i = 1; i <= count; i += 1) { + buffer.append([`${label} ${String(i).padStart(2, "0")}`]); + ui.requestRender(); + await sleep(delayMs); + } +} + +async function main(): Promise { + const ui = new TUI(new ProcessTerminal()); + const buffer = new Lines(); + ui.addChild(buffer); + ui.start(); + + const height = ui.terminal.rows; + const preCount = height + 8; // Ensure content exceeds viewport + const toolCount = height + 12; // Tool output pushes further into scrollback + const postCount = 6; + + buffer.set([ + "TUI viewport overwrite repro", + `Viewport rows detected: ${height}`, + "(Resize to ~8-12 rows for best repro)", + "", + "=== PRE-TOOL STREAM ===", + ]); + ui.requestRender(); + await sleep(300); + + // Phase 1: Stream pre-tool text until viewport is exceeded. + await streamLines(buffer, "PRE-TOOL LINE", preCount, 30, ui); + + // Phase 2: Simulate tool call pause and tool output. + buffer.append(["", "--- TOOL CALL START ---", "(pause...)", ""]); + ui.requestRender(); + await sleep(700); + + await streamLines(buffer, "TOOL OUT", toolCount, 20, ui); + + // Phase 3: Post-tool streaming. This is where overwrite often appears. + buffer.append(["", "=== POST-TOOL STREAM ==="]); + ui.requestRender(); + await sleep(300); + await streamLines(buffer, "POST-TOOL LINE", postCount, 40, ui); + + // Leave the output visible briefly, then restore terminal state. + await sleep(1500); + ui.stop(); +} + +main().catch((error) => { + // Ensure terminal is restored if something goes wrong. + try { + const ui = new TUI(new ProcessTerminal()); + ui.stop(); + } catch { + // Ignore restore errors. + } + process.stderr.write(`${String(error)}\n`); + process.exitCode = 1; +}); diff --git a/packages/pi-tui/test/virtual-terminal.ts b/packages/pi-tui/test/virtual-terminal.ts new file mode 100644 index 000000000..4e067f4e5 --- /dev/null +++ b/packages/pi-tui/test/virtual-terminal.ts @@ -0,0 +1,218 @@ +import type { Terminal as XtermTerminalType } from "@xterm/headless"; +import xterm from "@xterm/headless"; +import type { Terminal } from "../src/terminal.ts"; + +// Extract Terminal class from the module +const XtermTerminal = xterm.Terminal; + +/** + * Virtual terminal for testing using xterm.js for accurate terminal emulation + */ +export class VirtualTerminal implements Terminal { + private xterm: XtermTerminalType; + private inputHandler?: (data: string) => void; + private resizeHandler?: () => void; + private _columns: number; + private _rows: number; + + constructor(columns = 80, rows = 24) { + this._columns = columns; + this._rows = rows; + + // Create xterm instance with specified dimensions + this.xterm = new XtermTerminal({ + cols: columns, + rows: rows, + // Disable all interactive features for testing + disableStdin: true, + allowProposedApi: true, + }); + } + + start(onInput: (data: string) => void, onResize: () => void): void { + this.inputHandler = onInput; + this.resizeHandler = onResize; + // Enable bracketed paste mode for consistency with ProcessTerminal + this.xterm.write("\x1b[?2004h"); + } + + async drainInput(_maxMs?: number, _idleMs?: number): Promise { + // No-op for virtual terminal - no stdin to drain + } + + stop(): void { + // Disable bracketed paste mode + this.xterm.write("\x1b[?2004l"); + this.inputHandler = undefined; + this.resizeHandler = undefined; + } + + write(data: string): void { + this.xterm.write(data); + } + + get columns(): number { + return this._columns; + } + + get rows(): number { + return this._rows; + } + + get kittyProtocolActive(): boolean { + // Virtual terminal always reports Kitty protocol as active for testing + return true; + } + + moveBy(lines: number): void { + if (lines > 0) { + // Move down + this.xterm.write(`\x1b[${lines}B`); + } else if (lines < 0) { + // Move up + this.xterm.write(`\x1b[${-lines}A`); + } + // lines === 0: no movement + } + + hideCursor(): void { + this.xterm.write("\x1b[?25l"); + } + + showCursor(): void { + this.xterm.write("\x1b[?25h"); + } + + clearLine(): void { + this.xterm.write("\x1b[K"); + } + + clearFromCursor(): void { + this.xterm.write("\x1b[J"); + } + + clearScreen(): void { + this.xterm.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 + this.xterm.write(`\x1b]0;${title}\x07`); + } + + setProgress(_active: boolean): void {} + + // Test-specific methods not in Terminal interface + + /** + * Simulate keyboard input + */ + sendInput(data: string): void { + if (this.inputHandler) { + this.inputHandler(data); + } + } + + /** + * Resize the terminal + */ + resize(columns: number, rows: number): void { + this._columns = columns; + this._rows = rows; + this.xterm.resize(columns, rows); + if (this.resizeHandler) { + this.resizeHandler(); + } + } + + /** + * Wait for all pending writes to complete. Viewport and scroll buffer will be updated. + */ + async flush(): Promise { + // Write an empty string to ensure all previous writes are flushed + return new Promise((resolve) => { + this.xterm.write("", () => resolve()); + }); + } + + /** + * Flush and get viewport - convenience method for tests + */ + async flushAndGetViewport(): Promise { + await this.flush(); + return this.getViewport(); + } + + /** + * Get the visible viewport (what's currently on screen) + * Note: You should use getViewportAfterWrite() for testing after writing data + */ + getViewport(): string[] { + const lines: string[] = []; + const buffer = this.xterm.buffer.active; + + // Get only the visible lines (viewport) + for (let i = 0; i < this.xterm.rows; i++) { + const line = buffer.getLine(buffer.viewportY + i); + if (line) { + lines.push(line.translateToString(true)); + } else { + lines.push(""); + } + } + + return lines; + } + + /** + * Get the entire scroll buffer + */ + getScrollBuffer(): string[] { + const lines: string[] = []; + const buffer = this.xterm.buffer.active; + + // Get all lines in the buffer (including scrollback) + for (let i = 0; i < buffer.length; i++) { + const line = buffer.getLine(i); + if (line) { + lines.push(line.translateToString(true)); + } else { + lines.push(""); + } + } + + return lines; + } + + /** + * Clear the terminal viewport + */ + clear(): void { + this.xterm.clear(); + } + + /** + * Reset the terminal completely + */ + reset(): void { + this.xterm.reset(); + } + + /** + * Get cursor position + */ + getCursorPosition(): { x: number; y: number } { + const buffer = this.xterm.buffer.active; + return { + x: buffer.cursorX, + y: buffer.cursorY, + }; + } + + /** Wait for TUI's throttled render pipeline to settle. */ + async waitForRender(): Promise { + await new Promise((resolve) => process.nextTick(resolve)); + await new Promise((resolve) => setTimeout(resolve, 20)); + await this.flush(); + } +} diff --git a/packages/pi-tui/test/word-navigation.test.ts b/packages/pi-tui/test/word-navigation.test.ts new file mode 100644 index 000000000..2e58e960b --- /dev/null +++ b/packages/pi-tui/test/word-navigation.test.ts @@ -0,0 +1,191 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { findWordBackward, findWordForward } from "../src/word-navigation.ts"; + +describe("findWordBackward", () => { + it("basic words: hello world", () => { + const text = "hello world"; + assert.strictEqual(findWordBackward(text, 11), 6); + assert.strictEqual(findWordBackward(text, 6), 0); + }); + + it("dotted: foo.bar", () => { + const text = "foo.bar"; + assert.strictEqual(findWordBackward(text, 7), 4); + assert.strictEqual(findWordBackward(text, 4), 3); + assert.strictEqual(findWordBackward(text, 3), 0); + }); + + it("colon: foo:bar", () => { + const text = "foo:bar"; + assert.strictEqual(findWordBackward(text, 7), 4); + assert.strictEqual(findWordBackward(text, 4), 3); + assert.strictEqual(findWordBackward(text, 3), 0); + }); + + it("path: path/to/file", () => { + const text = "path/to/file"; + assert.strictEqual(findWordBackward(text, 12), 8); + assert.strictEqual(findWordBackward(text, 8), 7); + // "/to" is one word-like segment with "/" as punctuation boundary + assert.strictEqual(findWordBackward(text, 7), 5); + assert.strictEqual(findWordBackward(text, 5), 4); + assert.strictEqual(findWordBackward(text, 4), 0); + }); + + it("CJK mixed", () => { + const text = "你好世界 test"; + assert.strictEqual(findWordBackward(text, text.length), 5); + // Intl.Segmenter treats each CJK char as a separate word-like segment + assert.strictEqual(findWordBackward(text, 5), 2); + assert.strictEqual(findWordBackward(text, 2), 0); + }); + + it("whitespace at boundaries", () => { + const text = " hello "; + assert.strictEqual(findWordBackward(text, 9), 2); + assert.strictEqual(findWordBackward(text, 2), 0); + }); + + it("punctuation run: foo...bar", () => { + const text = "foo...bar"; + assert.strictEqual(findWordBackward(text, 9), 6); + assert.strictEqual(findWordBackward(text, 6), 3); + assert.strictEqual(findWordBackward(text, 3), 0); + }); + + it("cursor at 0 returns 0", () => { + assert.strictEqual(findWordBackward("hello", 0), 0); + }); +}); + +describe("findWordForward", () => { + it("basic words: hello world", () => { + const text = "hello world"; + assert.strictEqual(findWordForward(text, 0), 5); + assert.strictEqual(findWordForward(text, 5), 11); + }); + + it("dotted: foo.bar", () => { + const text = "foo.bar"; + assert.strictEqual(findWordForward(text, 0), 3); + assert.strictEqual(findWordForward(text, 3), 4); + assert.strictEqual(findWordForward(text, 4), 7); + }); + + it("colon: foo:bar", () => { + const text = "foo:bar"; + assert.strictEqual(findWordForward(text, 0), 3); + assert.strictEqual(findWordForward(text, 3), 4); + assert.strictEqual(findWordForward(text, 4), 7); + }); + + it("path: path/to/file", () => { + const text = "path/to/file"; + assert.strictEqual(findWordForward(text, 0), 4); + assert.strictEqual(findWordForward(text, 4), 5); + assert.strictEqual(findWordForward(text, 5), 7); + assert.strictEqual(findWordForward(text, 7), 8); + assert.strictEqual(findWordForward(text, 8), 12); + }); + + it("CJK mixed", () => { + const text = "你好世界 test"; + const firstEnd = findWordForward(text, 0); + assert.ok(firstEnd > 0); + assert.ok(firstEnd <= 4); + // Walk to end + let pos = 0; + while (pos < text.length) { + const next = findWordForward(text, pos); + if (next === pos) break; + pos = next; + } + assert.strictEqual(pos, text.length); + }); + + it("whitespace at boundaries", () => { + const text = " hello "; + assert.strictEqual(findWordForward(text, 0), 7); + assert.strictEqual(findWordForward(text, 7), 9); + }); + + it("punctuation run: foo...bar", () => { + const text = "foo...bar"; + assert.strictEqual(findWordForward(text, 0), 3); + assert.strictEqual(findWordForward(text, 3), 6); + assert.strictEqual(findWordForward(text, 6), 9); + }); + + it("cursor at end returns end", () => { + assert.strictEqual(findWordForward("hello", 5), 5); + }); +}); + +describe("atomic segments", () => { + const marker = "[paste #1 +5 lines]"; + const text = `hello ${marker} world`; + const isAtomic = (s: string) => s === marker; + + // The functions slice text before calling segment(), so we map each expected + // substring to its pre-split segments. + const segmentMap = new Map([ + [ + text, // full text (not used but for clarity) + [ + { segment: "hello", index: 0, input: text, isWordLike: true }, + { segment: " ", index: 5, input: text, isWordLike: false }, + { segment: marker, index: 6, input: text, isWordLike: true }, + { segment: " ", index: 25, input: text, isWordLike: false }, + { segment: "world", index: 26, input: text, isWordLike: true }, + ], + ], + [ + // backward from end: slice(0, 31) = full text + text.slice(0, text.length), + [ + { segment: "hello", index: 0, input: text, isWordLike: true }, + { segment: " ", index: 5, input: text, isWordLike: false }, + { segment: marker, index: 6, input: text, isWordLike: true }, + { segment: " ", index: 25, input: text, isWordLike: false }, + { segment: "world", index: 26, input: text, isWordLike: true }, + ], + ], + [ + // backward from 26: slice(0, 26) = "hello [paste #1 +5 lines] " + text.slice(0, 26), + [ + { segment: "hello", index: 0, input: text, isWordLike: true }, + { segment: " ", index: 5, input: text, isWordLike: false }, + { segment: marker, index: 6, input: text, isWordLike: true }, + { segment: " ", index: 25, input: text, isWordLike: false }, + ], + ], + [ + // forward from 6: slice(6) = "[paste #1 +5 lines] world" + text.slice(6), + [ + { segment: marker, index: 0, input: text, isWordLike: true }, + { segment: " ", index: 19, input: text, isWordLike: false }, + { segment: "world", index: 20, input: text, isWordLike: true }, + ], + ], + ]); + + const opts = { + segment: (input: string) => segmentMap.get(input) ?? [], + isAtomicSegment: isAtomic, + }; + + it("backward skips word then stops before atomic marker", () => { + assert.strictEqual(findWordBackward(text, text.length, opts), 26); + }); + + it("backward skips whitespace then atomic marker as one unit", () => { + assert.strictEqual(findWordBackward(text, 26, opts), 6); + }); + + it("forward skips atomic marker as one unit", () => { + assert.strictEqual(findWordForward(text, 6, opts), 6 + marker.length); + }); +}); diff --git a/packages/pi-tui/test/wrap-ansi.test.ts b/packages/pi-tui/test/wrap-ansi.test.ts new file mode 100644 index 000000000..a1183f750 --- /dev/null +++ b/packages/pi-tui/test/wrap-ansi.test.ts @@ -0,0 +1,246 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { visibleWidth, wrapTextWithAnsi } from "../src/utils.ts"; + +describe("wrapTextWithAnsi", () => { + describe("underline styling", () => { + it("should not apply underline style before the styled text", () => { + const underlineOn = "\x1b[4m"; + const underlineOff = "\x1b[24m"; + const url = "https://example.com/very/long/path/that/will/wrap"; + const text = `read this thread ${underlineOn}${url}${underlineOff}`; + + const wrapped = wrapTextWithAnsi(text, 40); + + // First line should NOT contain underline code - it's just "read this thread" + assert.strictEqual(wrapped[0], "read this thread"); + + // Second line should start with underline, have URL content + assert.strictEqual(wrapped[1].startsWith(underlineOn), true); + assert.ok(wrapped[1].includes("https://")); + }); + + it("should not have whitespace before underline reset code", () => { + const underlineOn = "\x1b[4m"; + const underlineOff = "\x1b[24m"; + const textWithUnderlinedTrailingSpace = `${underlineOn}underlined text here ${underlineOff}more`; + + const wrapped = wrapTextWithAnsi(textWithUnderlinedTrailingSpace, 18); + + assert.ok(!wrapped[0].includes(` ${underlineOff}`)); + }); + + it("should not bleed underline to padding - each line should end with reset for underline only", () => { + const underlineOn = "\x1b[4m"; + const underlineOff = "\x1b[24m"; + const url = "https://example.com/very/long/path/that/will/definitely/wrap"; + const text = `prefix ${underlineOn}${url}${underlineOff} suffix`; + + const wrapped = wrapTextWithAnsi(text, 30); + + // Middle lines (with underlined content) should end with underline-off, not full reset + // Line 1 and 2 contain underlined URL parts + for (let i = 1; i < wrapped.length - 1; i++) { + const line = wrapped[i]; + if (line.includes(underlineOn)) { + // Should end with underline off, NOT full reset + assert.strictEqual(line.endsWith(underlineOff), true); + assert.strictEqual(line.endsWith("\x1b[0m"), false); + } + } + }); + }); + + describe("background color preservation", () => { + it("should preserve background color across wrapped lines without full reset", () => { + const bgBlue = "\x1b[44m"; + const reset = "\x1b[0m"; + const text = `${bgBlue}hello world this is blue background text${reset}`; + + const wrapped = wrapTextWithAnsi(text, 15); + + // Each line should have background color + for (const line of wrapped) { + assert.ok(line.includes(bgBlue)); + } + + // Middle lines should NOT end with full reset (kills background for padding) + for (let i = 0; i < wrapped.length - 1; i++) { + assert.strictEqual(wrapped[i].endsWith("\x1b[0m"), false); + } + }); + + it("should reset underline but preserve background when wrapping underlined text inside background", () => { + const underlineOn = "\x1b[4m"; + const underlineOff = "\x1b[24m"; + const reset = "\x1b[0m"; + + const text = `\x1b[41mprefix ${underlineOn}UNDERLINED_CONTENT_THAT_WRAPS${underlineOff} suffix${reset}`; + + const wrapped = wrapTextWithAnsi(text, 20); + + // All lines should have background color 41 (either as \x1b[41m or combined like \x1b[4;41m) + for (const line of wrapped) { + const hasBgColor = line.includes("[41m") || line.includes(";41m") || line.includes("[41;"); + assert.ok(hasBgColor); + } + + // Lines with underlined content should use underline-off at end, not full reset + for (let i = 0; i < wrapped.length - 1; i++) { + const line = wrapped[i]; + // If this line has underline on, it should end with underline off (not full reset) + if ( + (line.includes("[4m") || line.includes("[4;") || line.includes(";4m")) && + !line.includes(underlineOff) + ) { + assert.strictEqual(line.endsWith(underlineOff), true); + assert.strictEqual(line.endsWith("\x1b[0m"), false); + } + } + }); + }); + + describe("basic wrapping", () => { + it("should wrap plain text correctly", () => { + const text = "hello world this is a test"; + const wrapped = wrapTextWithAnsi(text, 10); + + assert.ok(wrapped.length > 1); + for (const line of wrapped) { + assert.ok(visibleWidth(line) <= 10); + } + }); + + it("should break CJK runs at grapheme boundaries after Latin text", () => { + const text = "This is an example 中文汉字测试段落内容中文汉字测试段落内容."; + const wrapped = wrapTextWithAnsi(text, 40); + + assert.deepStrictEqual(wrapped, ["This is an example 中文汉字测试段落内容", "中文汉字测试段落内容."]); + for (const line of wrapped) { + assert.ok(visibleWidth(line) <= 40); + } + }); + + it("should preserve color codes when wrapping CJK runs", () => { + const red = "\x1b[31m"; + const reset = "\x1b[0m"; + const text = `${red}This is an example 中文汉字测试段落内容中文汉字测试段落内容.${reset}`; + const wrapped = wrapTextWithAnsi(text, 40); + + assert.strictEqual(wrapped.length, 2); + assert.strictEqual(wrapped[0], `${red}This is an example 中文汉字测试段落内容`); + assert.strictEqual(wrapped[1], `${red}中文汉字测试段落内容.${reset}`); + for (const line of wrapped) { + assert.ok(visibleWidth(line) <= 40); + } + }); + + it("should ignore OSC 133 semantic markers in visible width", () => { + const text = "\x1b]133;A\x07hello\x1b]133;B\x07"; + assert.strictEqual(visibleWidth(text), 5); + }); + + it("should ignore OSC sequences terminated with ST in visible width", () => { + const text = "\x1b]133;A\x1b\\hello\x1b]133;B\x1b\\"; + assert.strictEqual(visibleWidth(text), 5); + }); + + it("should treat isolated regional indicators as width 2", () => { + assert.strictEqual(visibleWidth("🇨"), 2); + assert.strictEqual(visibleWidth("🇨🇳"), 2); + }); + + it("should truncate trailing whitespace that exceeds width", () => { + const twoSpacesWrappedToWidth1 = wrapTextWithAnsi(" ", 1); + assert.ok(visibleWidth(twoSpacesWrappedToWidth1[0]) <= 1); + }); + + it("should preserve color codes across wraps", () => { + const red = "\x1b[31m"; + const reset = "\x1b[0m"; + const text = `${red}hello world this is red${reset}`; + + const wrapped = wrapTextWithAnsi(text, 10); + + // Each continuation line should start with red code + for (let i = 1; i < wrapped.length; i++) { + assert.strictEqual(wrapped[i].startsWith(red), true); + } + + // Middle lines should not end with full reset + for (let i = 0; i < wrapped.length - 1; i++) { + assert.strictEqual(wrapped[i].endsWith("\x1b[0m"), false); + } + }); + }); +}); + +describe("wrapTextWithAnsi with OSC 8 hyperlinks", () => { + it("re-emits OSC 8 open at the start of continuation lines", () => { + // A hyperlink whose text is long enough to wrap + const url = "https://example.com"; + // OSC 8 open + text that is 10 visible chars + OSC 8 close + const input = `\x1b]8;;${url}\x1b\\0123456789\x1b]8;;\x1b\\`; + const lines = wrapTextWithAnsi(input, 6); + + // Every line that contains visible text from inside the hyperlink + // should start with the OSC 8 open sequence (or be preceded by it). + for (const line of lines) { + // If the line has visible content it must begin with the OSC 8 re-open + // OR it is the line where the close appeared with no following content. + const stripped = line.replace(/\x1b\]8;;[^\x1b\x07]*\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, ""); + if (stripped.trim().length > 0) { + assert.ok( + line.startsWith(`\x1b]8;;${url}\x1b\\`) || line.includes(`\x1b]8;;${url}\x1b\\`), + `Line "${line}" has visible text but no OSC 8 re-open`, + ); + } + } + }); + + it("closes OSC 8 before each line break", () => { + const url = "https://example.com"; + const input = `\x1b]8;;${url}\x1b\\0123456789\x1b]8;;\x1b\\`; + const lines = wrapTextWithAnsi(input, 6); + + for (let i = 0; i < lines.length - 1; i++) { + const line = lines[i]; + // Every non-final line that is inside a hyperlink should end with the close + if (line.includes(`\x1b]8;;${url}\x1b\\`)) { + assert.ok( + line.endsWith("\x1b]8;;\x1b\\"), + `Non-final line "${line}" is inside a hyperlink but does not close it`, + ); + } + } + }); + + it("preserves BEL terminators when wrapping OAuth-style hyperlinks", () => { + const url = `https://example.com/oauth/${"a".repeat(32)}`; + const input = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`; + const lines = wrapTextWithAnsi(input, 20); + + assert.ok(lines.length > 1); + for (const line of lines) { + assert.ok(line.includes(`\x1b]8;;${url}\x07`), `Line "${line}" does not reopen the hyperlink with BEL`); + assert.ok(!line.includes(`\x1b]8;;${url}\x1b\\`), `Line "${line}" reopens the hyperlink with ST`); + } + for (const line of lines.slice(0, -1)) { + assert.ok(line.endsWith("\x1b]8;;\x07"), `Line "${line}" does not close the hyperlink with BEL`); + } + }); + + it("does not emit OSC 8 sequences on lines that are outside the hyperlink", () => { + const url = "https://example.com"; + const input = `before \x1b]8;;${url}\x1b\\link\x1b]8;;\x1b\\ after`; + const lines = wrapTextWithAnsi(input, 80); + + // With width 80 everything fits on one line; there should be exactly one + // OSC 8 open and one OSC 8 close. + assert.strictEqual(lines.length, 1); + const openCount = (lines[0].match(/\x1b\]8;;https:[^\x1b]+\x1b\\/g) ?? []).length; + const closeCount = (lines[0].match(/\x1b\]8;;\x1b\\/g) ?? []).length; + assert.strictEqual(openCount, 1); + assert.strictEqual(closeCount, 1); + }); +}); diff --git a/packages/pi-tui/tsconfig.json b/packages/pi-tui/tsconfig.json new file mode 100644 index 000000000..5c3ca434f --- /dev/null +++ b/packages/pi-tui/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["ESNext"] + }, + "include": ["src"] +} diff --git a/packages/pi-tui/vitest.config.ts b/packages/pi-tui/vitest.config.ts new file mode 100644 index 000000000..8dbb6fd11 --- /dev/null +++ b/packages/pi-tui/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +// pi-tui's suite is written for `node:test` and runs through the package +// `test` script (`node --test test/*.test.ts`). Vitest cannot execute +// `node:test`-style tests, so the repo's projects-mode `vitest run` is +// pointed at no files here and allowed to pass with none. Convert a test +// file to vitest's API and add it to `include` to opt it in. +export default defineConfig({ + test: { + include: [], + passWithNoTests: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc17ed388..aba2f6ecd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,9 +72,6 @@ importers: apps/kimi-code: devDependencies: - '@earendil-works/pi-tui': - specifier: ^0.74.0 - version: 0.74.0 '@moonshot-ai/acp-adapter': specifier: workspace:^ version: link:../../packages/acp-adapter @@ -93,6 +90,9 @@ importers: '@moonshot-ai/migration-legacy': specifier: workspace:^ version: link:../../packages/migration-legacy + '@moonshot-ai/pi-tui': + specifier: workspace:^ + version: link:../../packages/pi-tui '@moonshot-ai/server': specifier: workspace:^ version: link:../../packages/server @@ -145,9 +145,6 @@ importers: '@mariozechner/clipboard': specifier: ^0.3.9 version: 0.3.9 - koffi: - specifier: ^2.16.0 - version: 2.16.0 node-pty: specifier: ^1.1.0 version: 1.1.0 @@ -576,6 +573,22 @@ importers: specifier: ^4.1.4 version: 4.1.4 + packages/pi-tui: + dependencies: + get-east-asian-width: + specifier: 1.6.0 + version: 1.6.0 + marked: + specifier: 18.0.5 + version: 18.0.5 + devDependencies: + '@xterm/headless': + specifier: 5.5.0 + version: 5.5.0 + chalk: + specifier: ^5.4.1 + version: 5.6.2 + packages/protocol: dependencies: ulid: @@ -1046,10 +1059,6 @@ packages: search-insights: optional: true - '@earendil-works/pi-tui@0.74.0': - resolution: {integrity: sha512-1aIfXZp7D/z+1VlZX8BZcs6pgO8rjmil7kwyhctNDsWvce3Yfl8GVgu4eq+I0Mjhr8Cj+ipBiv9CLIzdoyCOIQ==} - engines: {node: '>=20.0.0'} - '@electron/asar@3.4.1': resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} engines: {node: '>=10.12.0'} @@ -2955,9 +2964,6 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} - '@types/mime-types@2.1.4': - resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} - '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -3209,6 +3215,9 @@ packages: '@xterm/addon-fit@0.11.0': resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + '@xterm/headless@5.5.0': + resolution: {integrity: sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==} + '@xterm/xterm@6.0.0': resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} @@ -4541,8 +4550,8 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.5.0: - resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} get-intrinsic@1.3.0: @@ -5129,9 +5138,6 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} - koffi@2.16.0: - resolution: {integrity: sha512-h/2NJueOKWd0YYycEOWDspomizgNfuOKf/V7ZE2fytvuRtHoY9Tb+y4x6GJ6pFqaVndWn9dLK+sCI14eWtu5rA==} - layout-base@1.0.2: resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} @@ -5363,16 +5369,16 @@ packages: peerDependencies: marked: '>=1 <16' - marked@15.0.12: - resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} - engines: {node: '>= 18'} - hasBin: true - marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} hasBin: true + marked@18.0.5: + resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + engines: {node: '>= 20'} + hasBin: true + marked@9.1.6: resolution: {integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==} engines: {node: '>= 16'} @@ -8075,16 +8081,6 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@earendil-works/pi-tui@0.74.0': - dependencies: - '@types/mime-types': 2.1.4 - chalk: 5.6.2 - get-east-asian-width: 1.5.0 - marked: 15.0.12 - mime-types: 3.0.2 - optionalDependencies: - koffi: 2.16.0 - '@electron/asar@3.4.1': dependencies: commander: 5.1.0 @@ -9760,8 +9756,6 @@ snapshots: '@types/mdurl@2.0.0': {} - '@types/mime-types@2.1.4': {} - '@types/ms@2.1.0': {} '@types/node@12.20.55': {} @@ -10067,6 +10061,8 @@ snapshots: '@xterm/addon-fit@0.11.0': {} + '@xterm/headless@5.5.0': {} + '@xterm/xterm@6.0.0': {} a-sync-waterfall@1.0.1: {} @@ -11694,7 +11690,7 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.5.0: {} + get-east-asian-width@1.6.0: {} get-intrinsic@1.3.0: dependencies: @@ -12076,7 +12072,7 @@ snapshots: is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 is-generator-function@1.1.2: dependencies: @@ -12361,9 +12357,6 @@ snapshots: kind-of@6.0.3: {} - koffi@2.16.0: - optional: true - layout-base@1.0.2: {} layout-base@2.0.1: {} @@ -12596,10 +12589,10 @@ snapshots: node-emoji: 2.2.0 supports-hyperlinks: 3.2.0 - marked@15.0.12: {} - marked@16.4.2: {} + marked@18.0.5: {} + marked@9.1.6: {} markstream-core@1.0.3: {} @@ -14180,12 +14173,12 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 string-width@8.2.0: dependencies: - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 string.prototype.trim@1.2.10: diff --git a/tsconfig.json b/tsconfig.json index d133d0122..39db3e2cd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,9 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2024", "module": "preserve", "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "lib": ["ES2023"], "jsx": "react-jsx", "jsxImportSource": "react",