@jecruz/merc-tui 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +14 -1
  2. package/dist/autocomplete.d.ts +1 -0
  3. package/dist/autocomplete.d.ts.map +1 -1
  4. package/dist/autocomplete.js +85 -24
  5. package/dist/autocomplete.js.map +1 -1
  6. package/dist/components/box.d.ts +1 -0
  7. package/dist/components/box.d.ts.map +1 -1
  8. package/dist/components/box.js +6 -1
  9. package/dist/components/box.js.map +1 -1
  10. package/dist/components/editor.d.ts.map +1 -1
  11. package/dist/components/editor.js +80 -115
  12. package/dist/components/editor.js.map +1 -1
  13. package/dist/components/image.d.ts +12 -0
  14. package/dist/components/image.d.ts.map +1 -1
  15. package/dist/components/image.js +45 -6
  16. package/dist/components/image.js.map +1 -1
  17. package/dist/components/input.d.ts.map +1 -1
  18. package/dist/components/input.js +7 -55
  19. package/dist/components/input.js.map +1 -1
  20. package/dist/components/markdown.d.ts +1 -0
  21. package/dist/components/markdown.d.ts.map +1 -1
  22. package/dist/components/markdown.js +18 -6
  23. package/dist/components/markdown.js.map +1 -1
  24. package/dist/components/settings-list.d.ts.map +1 -1
  25. package/dist/components/settings-list.js +4 -7
  26. package/dist/components/settings-list.js.map +1 -1
  27. package/dist/components/text.d.ts +1 -0
  28. package/dist/components/text.d.ts.map +1 -1
  29. package/dist/components/text.js +11 -5
  30. package/dist/components/text.js.map +1 -1
  31. package/dist/fuzzy.d.ts.map +1 -1
  32. package/dist/fuzzy.js +23 -22
  33. package/dist/fuzzy.js.map +1 -1
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/keys.d.ts.map +1 -1
  39. package/dist/keys.js +9 -1
  40. package/dist/keys.js.map +1 -1
  41. package/dist/native-modifier-digests.d.ts +6 -0
  42. package/dist/native-modifier-digests.d.ts.map +1 -0
  43. package/dist/native-modifier-digests.js +6 -0
  44. package/dist/native-modifier-digests.js.map +1 -0
  45. package/dist/native-modifiers.d.ts +11 -0
  46. package/dist/native-modifiers.d.ts.map +1 -0
  47. package/dist/native-modifiers.js +98 -0
  48. package/dist/native-modifiers.js.map +1 -0
  49. package/dist/stdin-buffer.d.ts +25 -2
  50. package/dist/stdin-buffer.d.ts.map +1 -1
  51. package/dist/stdin-buffer.js +176 -3
  52. package/dist/stdin-buffer.js.map +1 -1
  53. package/dist/terminal-image.d.ts +10 -0
  54. package/dist/terminal-image.d.ts.map +1 -1
  55. package/dist/terminal-image.js +102 -17
  56. package/dist/terminal-image.js.map +1 -1
  57. package/dist/terminal.d.ts +17 -0
  58. package/dist/terminal.d.ts.map +1 -1
  59. package/dist/terminal.js +165 -56
  60. package/dist/terminal.js.map +1 -1
  61. package/dist/tui.d.ts +28 -7
  62. package/dist/tui.d.ts.map +1 -1
  63. package/dist/tui.js +322 -125
  64. package/dist/tui.js.map +1 -1
  65. package/dist/utils.d.ts +16 -1
  66. package/dist/utils.d.ts.map +1 -1
  67. package/dist/utils.js +115 -29
  68. package/dist/utils.js.map +1 -1
  69. package/dist/word-navigation.d.ts +25 -0
  70. package/dist/word-navigation.d.ts.map +1 -0
  71. package/dist/word-navigation.js +96 -0
  72. package/dist/word-navigation.js.map +1 -0
  73. package/native/darwin/README.md +13 -0
  74. package/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  75. package/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  76. package/native/darwin/prebuilds/manifest.json +24 -0
  77. package/package.json +4 -1
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAKA;;GAEG;AACH,wBAAgB,YAAY,IAAI,IAAI,CAAC,SAAS,CAE7C;AA6LD;;GAEG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAuDhD;AAWD,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAG3D;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAsCjG;AA+VD;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAoBtE;AAmFD;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEvD;AAuED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,MAAM,CASzG;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAC9B,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,QAAQ,GAAE,MAAc,EACxB,GAAG,GAAE,OAAe,GAClB,MAAM,CAmIR;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,UAAQ,GAAG,MAAM,CAEpG;AAED,kFAAkF;AAClF,wBAAgB,cAAc,CAC7B,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,MAAM,UAAQ,GACZ;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAwCjC;AAKD;;;;GAIG;AACH,wBAAgB,eAAe,CAC9B,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,WAAW,UAAQ,GACjB;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAiE5E","sourcesContent":["import { eastAsianWidth } from \"get-east-asian-width\";\n\n// Grapheme segmenter (shared instance)\nconst segmenter = new Intl.Segmenter(undefined, { granularity: \"grapheme\" });\n\n/**\n * Get the shared grapheme segmenter instance.\n */\nexport function getSegmenter(): Intl.Segmenter {\n\treturn segmenter;\n}\n\n/**\n * Check if a grapheme cluster (after segmentation) could possibly be an RGI emoji.\n * This is a fast heuristic to avoid the expensive rgiEmojiRegex test.\n * The tested Unicode blocks are deliberately broad to account for future\n * Unicode additions.\n */\nfunction couldBeEmoji(segment: string): boolean {\n\tconst cp = segment.codePointAt(0)!;\n\treturn (\n\t\t(cp >= 0x1f000 && cp <= 0x1fbff) || // Emoji and Pictograph\n\t\t(cp >= 0x2300 && cp <= 0x23ff) || // Misc technical\n\t\t(cp >= 0x2600 && cp <= 0x27bf) || // Misc symbols, dingbats\n\t\t(cp >= 0x2b50 && cp <= 0x2b55) || // Specific stars/circles\n\t\tsegment.includes(\"\\uFE0F\") || // Contains VS16 (emoji presentation selector)\n\t\tsegment.length > 2 // Multi-codepoint sequences (ZWJ, skin tones, etc.)\n\t);\n}\n\n// Regexes for character classification (same as string-width library)\nconst zeroWidthRegex = /^(?:\\p{Default_Ignorable_Code_Point}|\\p{Control}|\\p{Mark}|\\p{Surrogate})+$/v;\nconst leadingNonPrintingRegex = /^[\\p{Default_Ignorable_Code_Point}\\p{Control}\\p{Format}\\p{Mark}\\p{Surrogate}]+/v;\nconst rgiEmojiRegex = /^\\p{RGI_Emoji}$/v;\n\n// Cache for non-ASCII strings\nconst WIDTH_CACHE_SIZE = 512;\nconst widthCache = new Map<string, number>();\nconst cjkBreakRegex =\n\t/[\\p{Script_Extensions=Han}\\p{Script_Extensions=Hiragana}\\p{Script_Extensions=Katakana}\\p{Script_Extensions=Hangul}\\p{Script_Extensions=Bopomofo}]/u;\n\nfunction isPrintableAscii(str: string): boolean {\n\tfor (let i = 0; i < str.length; i++) {\n\t\tconst code = str.charCodeAt(i);\n\t\tif (code < 0x20 || code > 0x7e) {\n\t\t\treturn false;\n\t\t}\n\t}\n\treturn true;\n}\n\nfunction truncateFragmentToWidth(text: string, maxWidth: number): { text: string; width: number } {\n\tif (maxWidth <= 0 || text.length === 0) {\n\t\treturn { text: \"\", width: 0 };\n\t}\n\n\tif (isPrintableAscii(text)) {\n\t\tconst clipped = text.slice(0, maxWidth);\n\t\treturn { text: clipped, width: clipped.length };\n\t}\n\n\tconst hasAnsi = text.includes(\"\\x1b\");\n\tconst hasTabs = text.includes(\"\\t\");\n\tif (!hasAnsi && !hasTabs) {\n\t\tlet result = \"\";\n\t\tlet width = 0;\n\t\tfor (const { segment } of segmenter.segment(text)) {\n\t\t\tconst w = graphemeWidth(segment);\n\t\t\tif (width + w > maxWidth) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tresult += segment;\n\t\t\twidth += w;\n\t\t}\n\t\treturn { text: result, width };\n\t}\n\n\tlet result = \"\";\n\tlet width = 0;\n\tlet i = 0;\n\tlet pendingAnsi = \"\";\n\n\twhile (i < text.length) {\n\t\tconst ansi = extractAnsiCode(text, i);\n\t\tif (ansi) {\n\t\t\tpendingAnsi += ansi.code;\n\t\t\ti += ansi.length;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (text[i] === \"\\t\") {\n\t\t\tif (width + 3 > maxWidth) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tif (pendingAnsi) {\n\t\t\t\tresult += pendingAnsi;\n\t\t\t\tpendingAnsi = \"\";\n\t\t\t}\n\t\t\tresult += \"\\t\";\n\t\t\twidth += 3;\n\t\t\ti++;\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet end = i;\n\t\twhile (end < text.length && text[end] !== \"\\t\") {\n\t\t\tconst nextAnsi = extractAnsiCode(text, end);\n\t\t\tif (nextAnsi) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tend++;\n\t\t}\n\n\t\tfor (const { segment } of segmenter.segment(text.slice(i, end))) {\n\t\t\tconst w = graphemeWidth(segment);\n\t\t\tif (width + w > maxWidth) {\n\t\t\t\treturn { text: result, width };\n\t\t\t}\n\t\t\tif (pendingAnsi) {\n\t\t\t\tresult += pendingAnsi;\n\t\t\t\tpendingAnsi = \"\";\n\t\t\t}\n\t\t\tresult += segment;\n\t\t\twidth += w;\n\t\t}\n\t\ti = end;\n\t}\n\n\treturn { text: result, width };\n}\n\nfunction finalizeTruncatedResult(\n\tprefix: string,\n\tprefixWidth: number,\n\tellipsis: string,\n\tellipsisWidth: number,\n\tmaxWidth: number,\n\tpad: boolean,\n): string {\n\tconst reset = \"\\x1b[0m\";\n\tconst visibleWidth = prefixWidth + ellipsisWidth;\n\tlet result: string;\n\n\tif (ellipsis.length > 0) {\n\t\tresult = `${prefix}${reset}${ellipsis}${reset}`;\n\t} else {\n\t\tresult = `${prefix}${reset}`;\n\t}\n\n\treturn pad ? result + \" \".repeat(Math.max(0, maxWidth - visibleWidth)) : result;\n}\n\n/**\n * Calculate the terminal width of a single grapheme cluster.\n * Based on code from the string-width library, but includes a possible-emoji\n * check to avoid running the RGI_Emoji regex unnecessarily.\n */\nfunction graphemeWidth(segment: string): number {\n\t// Zero-width clusters\n\tif (zeroWidthRegex.test(segment)) {\n\t\treturn 0;\n\t}\n\n\t// Emoji check with pre-filter\n\tif (couldBeEmoji(segment) && rgiEmojiRegex.test(segment)) {\n\t\treturn 2;\n\t}\n\n\t// Get base visible codepoint\n\tconst base = segment.replace(leadingNonPrintingRegex, \"\");\n\tconst cp = base.codePointAt(0);\n\tif (cp === undefined) {\n\t\treturn 0;\n\t}\n\n\t// Regional indicator symbols (U+1F1E6..U+1F1FF) are often rendered as\n\t// full-width emoji in terminals, even when isolated during streaming.\n\t// Keep width conservative (2) to avoid terminal auto-wrap drift artifacts.\n\tif (cp >= 0x1f1e6 && cp <= 0x1f1ff) {\n\t\treturn 2;\n\t}\n\n\tlet width = eastAsianWidth(cp);\n\n\t// Trailing halfwidth/fullwidth forms and AM vowels that segment with a base.\n\tif (segment.length > 1) {\n\t\tfor (const char of segment.slice(1)) {\n\t\t\tconst c = char.codePointAt(0)!;\n\t\t\tif (c >= 0xff00 && c <= 0xffef) {\n\t\t\t\twidth += eastAsianWidth(c);\n\t\t\t} else if (c === 0x0e33 || c === 0x0eb3) {\n\t\t\t\twidth += 1;\n\t\t\t}\n\t\t}\n\t}\n\n\treturn width;\n}\n\n/**\n * Calculate the visible width of a string in terminal columns.\n */\nexport function visibleWidth(str: string): number {\n\tif (str.length === 0) {\n\t\treturn 0;\n\t}\n\n\t// Fast path: pure ASCII printable\n\tif (isPrintableAscii(str)) {\n\t\treturn str.length;\n\t}\n\n\t// Check cache\n\tconst cached = widthCache.get(str);\n\tif (cached !== undefined) {\n\t\treturn cached;\n\t}\n\n\t// Normalize: tabs to 3 spaces, strip ANSI escape codes\n\tlet clean = str;\n\tif (str.includes(\"\\t\")) {\n\t\tclean = clean.replace(/\\t/g, \" \");\n\t}\n\tif (clean.includes(\"\\x1b\")) {\n\t\t// Strip supported ANSI/OSC/APC escape sequences in one pass.\n\t\t// This covers CSI styling/cursor codes, OSC hyperlinks and prompt markers,\n\t\t// and APC sequences like CURSOR_MARKER.\n\t\tlet stripped = \"\";\n\t\tlet i = 0;\n\t\twhile (i < clean.length) {\n\t\t\tconst ansi = extractAnsiCode(clean, i);\n\t\t\tif (ansi) {\n\t\t\t\ti += ansi.length;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tstripped += clean[i];\n\t\t\ti++;\n\t\t}\n\t\tclean = stripped;\n\t}\n\n\t// Calculate width\n\tlet width = 0;\n\tfor (const { segment } of segmenter.segment(clean)) {\n\t\twidth += graphemeWidth(segment);\n\t}\n\n\t// Cache result\n\tif (widthCache.size >= WIDTH_CACHE_SIZE) {\n\t\tconst firstKey = widthCache.keys().next().value;\n\t\tif (firstKey !== undefined) {\n\t\t\twidthCache.delete(firstKey);\n\t\t}\n\t}\n\twidthCache.set(str, width);\n\n\treturn width;\n}\n\n/**\n * Normalize text for terminal output without changing logical editor content.\n * Some terminals render precomposed Thai/Lao AM vowels inconsistently during\n * differential repaint. Their compatibility decompositions have the same cell\n * width but avoid stale-cell artifacts in terminal renderers.\n */\nconst THAI_LAO_AM_REGEX = /[\\u0e33\\u0eb3]/;\nconst THAI_LAO_AM_GLOBAL_REGEX = /[\\u0e33\\u0eb3]/g;\n\nexport function normalizeTerminalOutput(str: string): string {\n\tif (!THAI_LAO_AM_REGEX.test(str)) return str;\n\treturn str.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) => (char === \"\\u0e33\" ? \"\\u0e4d\\u0e32\" : \"\\u0ecd\\u0eb2\"));\n}\n\n/**\n * Extract ANSI escape sequences from a string at the given position.\n */\nexport function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {\n\tif (pos >= str.length || str[pos] !== \"\\x1b\") return null;\n\n\tconst next = str[pos + 1];\n\n\t// CSI sequence: ESC [ ... m/G/K/H/J\n\tif (next === \"[\") {\n\t\tlet j = pos + 2;\n\t\twhile (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++;\n\t\tif (j < str.length) return { code: str.substring(pos, j + 1), length: j + 1 - pos };\n\t\treturn null;\n\t}\n\n\t// OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \\)\n\t// Used for hyperlinks (OSC 8), window titles, etc.\n\tif (next === \"]\") {\n\t\tlet j = pos + 2;\n\t\twhile (j < str.length) {\n\t\t\tif (str[j] === \"\\x07\") return { code: str.substring(pos, j + 1), length: j + 1 - pos };\n\t\t\tif (str[j] === \"\\x1b\" && str[j + 1] === \"\\\\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos };\n\t\t\tj++;\n\t\t}\n\t\treturn null;\n\t}\n\n\t// APC sequence: ESC _ ... BEL or ESC _ ... ST (ESC \\)\n\t// Used for cursor marker and application-specific commands\n\tif (next === \"_\") {\n\t\tlet j = pos + 2;\n\t\twhile (j < str.length) {\n\t\t\tif (str[j] === \"\\x07\") return { code: str.substring(pos, j + 1), length: j + 1 - pos };\n\t\t\tif (str[j] === \"\\x1b\" && str[j + 1] === \"\\\\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos };\n\t\t\tj++;\n\t\t}\n\t\treturn null;\n\t}\n\n\treturn null;\n}\n\ntype Osc8Terminator = \"\\x07\" | \"\\x1b\\\\\";\n\ninterface ActiveHyperlink {\n\tparams: string;\n\turl: string;\n\tterminator: Osc8Terminator;\n}\n\nfunction parseOsc8Hyperlink(ansiCode: string): ActiveHyperlink | null | undefined {\n\tif (!ansiCode.startsWith(\"\\x1b]8;\")) {\n\t\treturn undefined;\n\t}\n\n\tconst terminator: Osc8Terminator = ansiCode.endsWith(\"\\x07\") ? \"\\x07\" : \"\\x1b\\\\\";\n\tconst body = ansiCode.slice(4, terminator === \"\\x07\" ? -1 : -2);\n\tconst separatorIndex = body.indexOf(\";\");\n\tif (separatorIndex === -1) {\n\t\treturn undefined;\n\t}\n\n\tconst params = body.slice(0, separatorIndex);\n\tconst url = body.slice(separatorIndex + 1);\n\tif (!url) {\n\t\treturn null;\n\t}\n\treturn { params, url, terminator };\n}\n\nfunction formatOsc8Hyperlink(hyperlink: ActiveHyperlink): string {\n\treturn `\\x1b]8;${hyperlink.params};${hyperlink.url}${hyperlink.terminator}`;\n}\n\nfunction formatOsc8Close(terminator: Osc8Terminator): string {\n\treturn `\\x1b]8;;${terminator}`;\n}\n\n/**\n * Track active ANSI SGR codes to preserve styling across line breaks.\n */\nclass AnsiCodeTracker {\n\t// Track individual attributes separately so we can reset them specifically\n\tprivate bold = false;\n\tprivate dim = false;\n\tprivate italic = false;\n\tprivate underline = false;\n\tprivate blink = false;\n\tprivate inverse = false;\n\tprivate hidden = false;\n\tprivate strikethrough = false;\n\tprivate fgColor: string | null = null; // Stores the full code like \"31\" or \"38;5;240\"\n\tprivate bgColor: string | null = null; // Stores the full code like \"41\" or \"48;5;240\"\n\tprivate activeHyperlink: ActiveHyperlink | null = null;\n\n\tprocess(ansiCode: string): void {\n\t\t// OSC 8 hyperlink: \\x1b]8;;<url>\\x1b\\\\ (open) or \\x1b]8;;\\x1b\\\\ (close).\n\t\t// Preserve the original terminator because some terminals only make BEL-terminated\n\t\t// links clickable. OAuth login URLs use BEL, so reopening wrapped lines with ST\n\t\t// made only the first physical line clickable in those terminals.\n\t\tconst hyperlink = parseOsc8Hyperlink(ansiCode);\n\t\tif (hyperlink !== undefined) {\n\t\t\tthis.activeHyperlink = hyperlink;\n\t\t\treturn;\n\t\t}\n\n\t\tif (!ansiCode.endsWith(\"m\")) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Extract the parameters between \\x1b[ and m\n\t\tconst match = ansiCode.match(/\\x1b\\[([\\d;]*)m/);\n\t\tif (!match) return;\n\n\t\tconst params = match[1];\n\t\tif (params === \"\" || params === \"0\") {\n\t\t\t// Full reset\n\t\t\tthis.reset();\n\t\t\treturn;\n\t\t}\n\n\t\t// Parse parameters (can be semicolon-separated)\n\t\tconst parts = params.split(\";\");\n\t\tlet i = 0;\n\t\twhile (i < parts.length) {\n\t\t\tconst code = Number.parseInt(parts[i], 10);\n\n\t\t\t// Handle 256-color and RGB codes which consume multiple parameters\n\t\t\tif (code === 38 || code === 48) {\n\t\t\t\t// 38;5;N (256 color fg) or 38;2;R;G;B (RGB fg)\n\t\t\t\t// 48;5;N (256 color bg) or 48;2;R;G;B (RGB bg)\n\t\t\t\tif (parts[i + 1] === \"5\" && parts[i + 2] !== undefined) {\n\t\t\t\t\t// 256 color: 38;5;N or 48;5;N\n\t\t\t\t\tconst colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]}`;\n\t\t\t\t\tif (code === 38) {\n\t\t\t\t\t\tthis.fgColor = colorCode;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.bgColor = colorCode;\n\t\t\t\t\t}\n\t\t\t\t\ti += 3;\n\t\t\t\t\tcontinue;\n\t\t\t\t} else if (parts[i + 1] === \"2\" && parts[i + 4] !== undefined) {\n\t\t\t\t\t// RGB color: 38;2;R;G;B or 48;2;R;G;B\n\t\t\t\t\tconst colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]};${parts[i + 3]};${parts[i + 4]}`;\n\t\t\t\t\tif (code === 38) {\n\t\t\t\t\t\tthis.fgColor = colorCode;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.bgColor = colorCode;\n\t\t\t\t\t}\n\t\t\t\t\ti += 5;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Standard SGR codes\n\t\t\tswitch (code) {\n\t\t\t\tcase 0:\n\t\t\t\t\tthis.reset();\n\t\t\t\t\tbreak;\n\t\t\t\tcase 1:\n\t\t\t\t\tthis.bold = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 2:\n\t\t\t\t\tthis.dim = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 3:\n\t\t\t\t\tthis.italic = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 4:\n\t\t\t\t\tthis.underline = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 5:\n\t\t\t\t\tthis.blink = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 7:\n\t\t\t\t\tthis.inverse = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 8:\n\t\t\t\t\tthis.hidden = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 9:\n\t\t\t\t\tthis.strikethrough = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 21:\n\t\t\t\t\tthis.bold = false;\n\t\t\t\t\tbreak; // Some terminals\n\t\t\t\tcase 22:\n\t\t\t\t\tthis.bold = false;\n\t\t\t\t\tthis.dim = false;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 23:\n\t\t\t\t\tthis.italic = false;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 24:\n\t\t\t\t\tthis.underline = false;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 25:\n\t\t\t\t\tthis.blink = false;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 27:\n\t\t\t\t\tthis.inverse = false;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 28:\n\t\t\t\t\tthis.hidden = false;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 29:\n\t\t\t\t\tthis.strikethrough = false;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 39:\n\t\t\t\t\tthis.fgColor = null;\n\t\t\t\t\tbreak; // Default fg\n\t\t\t\tcase 49:\n\t\t\t\t\tthis.bgColor = null;\n\t\t\t\t\tbreak; // Default bg\n\t\t\t\tdefault:\n\t\t\t\t\t// Standard foreground colors 30-37, 90-97\n\t\t\t\t\tif ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {\n\t\t\t\t\t\tthis.fgColor = String(code);\n\t\t\t\t\t}\n\t\t\t\t\t// Standard background colors 40-47, 100-107\n\t\t\t\t\telse if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {\n\t\t\t\t\t\tthis.bgColor = String(code);\n\t\t\t\t\t}\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t\ti++;\n\t\t}\n\t}\n\n\tprivate reset(): void {\n\t\tthis.bold = false;\n\t\tthis.dim = false;\n\t\tthis.italic = false;\n\t\tthis.underline = false;\n\t\tthis.blink = false;\n\t\tthis.inverse = false;\n\t\tthis.hidden = false;\n\t\tthis.strikethrough = false;\n\t\tthis.fgColor = null;\n\t\tthis.bgColor = null;\n\t\t// SGR reset does not affect OSC 8 hyperlink state\n\t}\n\n\t/** Clear all state for reuse. */\n\tclear(): void {\n\t\tthis.reset();\n\t\tthis.activeHyperlink = null;\n\t}\n\n\tgetActiveCodes(): string {\n\t\tconst codes: string[] = [];\n\t\tif (this.bold) codes.push(\"1\");\n\t\tif (this.dim) codes.push(\"2\");\n\t\tif (this.italic) codes.push(\"3\");\n\t\tif (this.underline) codes.push(\"4\");\n\t\tif (this.blink) codes.push(\"5\");\n\t\tif (this.inverse) codes.push(\"7\");\n\t\tif (this.hidden) codes.push(\"8\");\n\t\tif (this.strikethrough) codes.push(\"9\");\n\t\tif (this.fgColor) codes.push(this.fgColor);\n\t\tif (this.bgColor) codes.push(this.bgColor);\n\n\t\tlet result = codes.length > 0 ? `\\x1b[${codes.join(\";\")}m` : \"\";\n\t\tif (this.activeHyperlink) {\n\t\t\tresult += formatOsc8Hyperlink(this.activeHyperlink);\n\t\t}\n\t\treturn result;\n\t}\n\n\thasActiveCodes(): boolean {\n\t\treturn (\n\t\t\tthis.bold ||\n\t\t\tthis.dim ||\n\t\t\tthis.italic ||\n\t\t\tthis.underline ||\n\t\t\tthis.blink ||\n\t\t\tthis.inverse ||\n\t\t\tthis.hidden ||\n\t\t\tthis.strikethrough ||\n\t\t\tthis.fgColor !== null ||\n\t\t\tthis.bgColor !== null ||\n\t\t\tthis.activeHyperlink !== null\n\t\t);\n\t}\n\n\t/**\n\t * Get reset codes for attributes that need to be turned off at line end.\n\t * Underline must be closed to prevent bleeding into padding.\n\t * Active OSC 8 hyperlinks must be closed and re-opened on the next line.\n\t * Returns empty string if no attributes need closing.\n\t */\n\tgetLineEndReset(): string {\n\t\tlet result = \"\";\n\t\tif (this.underline) {\n\t\t\tresult += \"\\x1b[24m\"; // Underline off only\n\t\t}\n\t\tif (this.activeHyperlink) {\n\t\t\tresult += formatOsc8Close(this.activeHyperlink.terminator); // Re-opened at line start via getActiveCodes()\n\t\t}\n\t\treturn result;\n\t}\n}\n\nfunction updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void {\n\tlet i = 0;\n\twhile (i < text.length) {\n\t\tconst ansiResult = extractAnsiCode(text, i);\n\t\tif (ansiResult) {\n\t\t\ttracker.process(ansiResult.code);\n\t\t\ti += ansiResult.length;\n\t\t} else {\n\t\t\ti++;\n\t\t}\n\t}\n}\n\n/**\n * Split text into words while keeping ANSI codes attached.\n */\nfunction splitIntoTokensWithAnsi(text: string): string[] {\n\tconst tokens: string[] = [];\n\tlet current = \"\";\n\tlet pendingAnsi = \"\"; // ANSI codes waiting to be attached to next visible content\n\tlet currentKind: \"space\" | \"word\" | null = null;\n\tlet i = 0;\n\n\tconst flushCurrent = (): void => {\n\t\tif (!current) return;\n\t\ttokens.push(current);\n\t\tcurrent = \"\";\n\t\tcurrentKind = null;\n\t};\n\n\twhile (i < text.length) {\n\t\tconst ansiResult = extractAnsiCode(text, i);\n\t\tif (ansiResult) {\n\t\t\t// Hold ANSI codes separately - they'll be attached to the next visible char\n\t\t\tpendingAnsi += ansiResult.code;\n\t\t\ti += ansiResult.length;\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet end = i;\n\t\twhile (end < text.length && !extractAnsiCode(text, end)) {\n\t\t\tend++;\n\t\t}\n\n\t\tfor (const { segment } of segmenter.segment(text.slice(i, end))) {\n\t\t\tconst segmentIsSpace = segment === \" \";\n\t\t\tif (!segmentIsSpace && cjkBreakRegex.test(segment)) {\n\t\t\t\tflushCurrent();\n\t\t\t\ttokens.push(pendingAnsi + segment);\n\t\t\t\tpendingAnsi = \"\";\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst segmentKind = segmentIsSpace ? \"space\" : \"word\";\n\t\t\tif (current && currentKind !== segmentKind) {\n\t\t\t\tflushCurrent();\n\t\t\t}\n\n\t\t\tif (pendingAnsi) {\n\t\t\t\tcurrent += pendingAnsi;\n\t\t\t\tpendingAnsi = \"\";\n\t\t\t}\n\n\t\t\tcurrentKind = segmentKind;\n\t\t\tcurrent += segment;\n\t\t}\n\n\t\ti = end;\n\t}\n\n\t// Handle any remaining pending ANSI codes (attach to last token)\n\tif (pendingAnsi) {\n\t\tif (current) {\n\t\t\tcurrent += pendingAnsi;\n\t\t} else if (tokens.length > 0) {\n\t\t\ttokens[tokens.length - 1] += pendingAnsi;\n\t\t} else {\n\t\t\tcurrent = pendingAnsi;\n\t\t}\n\t}\n\n\tif (current) {\n\t\ttokens.push(current);\n\t}\n\n\treturn tokens;\n}\n\n/**\n * Wrap text with ANSI codes preserved.\n *\n * ONLY does word wrapping - NO padding, NO background colors.\n * Returns lines where each line is <= width visible chars.\n * Active ANSI codes are preserved across line breaks.\n *\n * @param text - Text to wrap (may contain ANSI codes and newlines)\n * @param width - Maximum visible width per line\n * @returns Array of wrapped lines (NOT padded to width)\n */\nexport function wrapTextWithAnsi(text: string, width: number): string[] {\n\tif (!text) {\n\t\treturn [\"\"];\n\t}\n\n\t// Handle newlines by processing each line separately\n\t// Track ANSI state across lines so styles carry over after literal newlines\n\tconst inputLines = text.split(\"\\n\");\n\tconst result: string[] = [];\n\tconst tracker = new AnsiCodeTracker();\n\n\tfor (const inputLine of inputLines) {\n\t\t// Prepend active ANSI codes from previous lines (except for first line)\n\t\tconst prefix = result.length > 0 ? tracker.getActiveCodes() : \"\";\n\t\tresult.push(...wrapSingleLine(prefix + inputLine, width));\n\t\t// Update tracker with codes from this line for next iteration\n\t\tupdateTrackerFromText(inputLine, tracker);\n\t}\n\n\treturn result.length > 0 ? result : [\"\"];\n}\n\nfunction wrapSingleLine(line: string, width: number): string[] {\n\tif (!line) {\n\t\treturn [\"\"];\n\t}\n\n\tconst visibleLength = visibleWidth(line);\n\tif (visibleLength <= width) {\n\t\treturn [line];\n\t}\n\n\tconst wrapped: string[] = [];\n\tconst tracker = new AnsiCodeTracker();\n\tconst tokens = splitIntoTokensWithAnsi(line);\n\n\tlet currentLine = \"\";\n\tlet currentVisibleLength = 0;\n\n\tfor (const token of tokens) {\n\t\tconst tokenVisibleLength = visibleWidth(token);\n\t\tconst isWhitespace = token.trim() === \"\";\n\n\t\t// Token itself is too long - break it character by character\n\t\tif (tokenVisibleLength > width && !isWhitespace) {\n\t\t\tif (currentLine) {\n\t\t\t\t// Add specific reset for underline only (preserves background)\n\t\t\t\tconst lineEndReset = tracker.getLineEndReset();\n\t\t\t\tif (lineEndReset) {\n\t\t\t\t\tcurrentLine += lineEndReset;\n\t\t\t\t}\n\t\t\t\twrapped.push(currentLine);\n\t\t\t\tcurrentLine = \"\";\n\t\t\t\tcurrentVisibleLength = 0;\n\t\t\t}\n\n\t\t\t// Break long token - breakLongWord handles its own resets\n\t\t\tconst broken = breakLongWord(token, width, tracker);\n\t\t\twrapped.push(...broken.slice(0, -1));\n\t\t\tcurrentLine = broken[broken.length - 1];\n\t\t\tcurrentVisibleLength = visibleWidth(currentLine);\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Check if adding this token would exceed width\n\t\tconst totalNeeded = currentVisibleLength + tokenVisibleLength;\n\n\t\tif (totalNeeded > width && currentVisibleLength > 0) {\n\t\t\t// Trim trailing whitespace, then add underline reset (not full reset, to preserve background)\n\t\t\tlet lineToWrap = currentLine.trimEnd();\n\t\t\tconst lineEndReset = tracker.getLineEndReset();\n\t\t\tif (lineEndReset) {\n\t\t\t\tlineToWrap += lineEndReset;\n\t\t\t}\n\t\t\twrapped.push(lineToWrap);\n\t\t\tif (isWhitespace) {\n\t\t\t\t// Don't start new line with whitespace\n\t\t\t\tcurrentLine = tracker.getActiveCodes();\n\t\t\t\tcurrentVisibleLength = 0;\n\t\t\t} else {\n\t\t\t\tcurrentLine = tracker.getActiveCodes() + token;\n\t\t\t\tcurrentVisibleLength = tokenVisibleLength;\n\t\t\t}\n\t\t} else {\n\t\t\t// Add to current line\n\t\t\tcurrentLine += token;\n\t\t\tcurrentVisibleLength += tokenVisibleLength;\n\t\t}\n\n\t\tupdateTrackerFromText(token, tracker);\n\t}\n\n\tif (currentLine) {\n\t\t// No reset at end of final line - let caller handle it\n\t\twrapped.push(currentLine);\n\t}\n\n\t// Trailing whitespace can cause lines to exceed the requested width\n\treturn wrapped.length > 0 ? wrapped.map((line) => line.trimEnd()) : [\"\"];\n}\n\nconst PUNCTUATION_REGEX = /[(){}[\\]<>.,;:'\"!?+\\-=*/\\\\|&%^$#@~`]/;\n\n/**\n * Check if a character is whitespace.\n */\nexport function isWhitespaceChar(char: string): boolean {\n\treturn /\\s/.test(char);\n}\n\n/**\n * Check if a character is punctuation.\n */\nexport function isPunctuationChar(char: string): boolean {\n\treturn PUNCTUATION_REGEX.test(char);\n}\n\nfunction breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): string[] {\n\tconst lines: string[] = [];\n\tlet currentLine = tracker.getActiveCodes();\n\tlet currentWidth = 0;\n\n\t// First, separate ANSI codes from visible content\n\t// We need to handle ANSI codes specially since they're not graphemes\n\tlet i = 0;\n\tconst segments: Array<{ type: \"ansi\" | \"grapheme\"; value: string }> = [];\n\n\twhile (i < word.length) {\n\t\tconst ansiResult = extractAnsiCode(word, i);\n\t\tif (ansiResult) {\n\t\t\tsegments.push({ type: \"ansi\", value: ansiResult.code });\n\t\t\ti += ansiResult.length;\n\t\t} else {\n\t\t\t// Find the next ANSI code or end of string\n\t\t\tlet end = i;\n\t\t\twhile (end < word.length) {\n\t\t\t\tconst nextAnsi = extractAnsiCode(word, end);\n\t\t\t\tif (nextAnsi) break;\n\t\t\t\tend++;\n\t\t\t}\n\t\t\t// Segment this non-ANSI portion into graphemes\n\t\t\tconst textPortion = word.slice(i, end);\n\t\t\tfor (const seg of segmenter.segment(textPortion)) {\n\t\t\t\tsegments.push({ type: \"grapheme\", value: seg.segment });\n\t\t\t}\n\t\t\ti = end;\n\t\t}\n\t}\n\n\t// Now process segments\n\tfor (const seg of segments) {\n\t\tif (seg.type === \"ansi\") {\n\t\t\tcurrentLine += seg.value;\n\t\t\ttracker.process(seg.value);\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst grapheme = seg.value;\n\t\t// Skip empty graphemes to avoid issues with string-width calculation\n\t\tif (!grapheme) continue;\n\n\t\tconst graphemeWidth = visibleWidth(grapheme);\n\n\t\tif (currentWidth + graphemeWidth > width) {\n\t\t\t// Add specific reset for underline only (preserves background)\n\t\t\tconst lineEndReset = tracker.getLineEndReset();\n\t\t\tif (lineEndReset) {\n\t\t\t\tcurrentLine += lineEndReset;\n\t\t\t}\n\t\t\tlines.push(currentLine);\n\t\t\tcurrentLine = tracker.getActiveCodes();\n\t\t\tcurrentWidth = 0;\n\t\t}\n\n\t\tcurrentLine += grapheme;\n\t\tcurrentWidth += graphemeWidth;\n\t}\n\n\tif (currentLine) {\n\t\t// No reset at end of final segment - caller handles continuation\n\t\tlines.push(currentLine);\n\t}\n\n\treturn lines.length > 0 ? lines : [\"\"];\n}\n\n/**\n * Apply background color to a line, padding to full width.\n *\n * @param line - Line of text (may contain ANSI codes)\n * @param width - Total width to pad to\n * @param bgFn - Background color function\n * @returns Line with background applied and padded to width\n */\nexport function applyBackgroundToLine(line: string, width: number, bgFn: (text: string) => string): string {\n\t// Calculate padding needed\n\tconst visibleLen = visibleWidth(line);\n\tconst paddingNeeded = Math.max(0, width - visibleLen);\n\tconst padding = \" \".repeat(paddingNeeded);\n\n\t// Apply background to content + padding\n\tconst withPadding = line + padding;\n\treturn bgFn(withPadding);\n}\n\n/**\n * Truncate text to fit within a maximum visible width, adding ellipsis if needed.\n * Optionally pad with spaces to reach exactly maxWidth.\n * Properly handles ANSI escape codes (they don't count toward width).\n *\n * @param text - Text to truncate (may contain ANSI codes)\n * @param maxWidth - Maximum visible width\n * @param ellipsis - Ellipsis string to append when truncating (default: \"...\")\n * @param pad - If true, pad result with spaces to exactly maxWidth (default: false)\n * @returns Truncated text, optionally padded to exactly maxWidth\n */\nexport function truncateToWidth(\n\ttext: string,\n\tmaxWidth: number,\n\tellipsis: string = \"...\",\n\tpad: boolean = false,\n): string {\n\tif (maxWidth <= 0) {\n\t\treturn \"\";\n\t}\n\n\tif (text.length === 0) {\n\t\treturn pad ? \" \".repeat(maxWidth) : \"\";\n\t}\n\n\tconst ellipsisWidth = visibleWidth(ellipsis);\n\tif (ellipsisWidth >= maxWidth) {\n\t\tconst textWidth = visibleWidth(text);\n\t\tif (textWidth <= maxWidth) {\n\t\t\treturn pad ? text + \" \".repeat(maxWidth - textWidth) : text;\n\t\t}\n\n\t\tconst clippedEllipsis = truncateFragmentToWidth(ellipsis, maxWidth);\n\t\tif (clippedEllipsis.width === 0) {\n\t\t\treturn pad ? \" \".repeat(maxWidth) : \"\";\n\t\t}\n\t\treturn finalizeTruncatedResult(\"\", 0, clippedEllipsis.text, clippedEllipsis.width, maxWidth, pad);\n\t}\n\n\tif (isPrintableAscii(text)) {\n\t\tif (text.length <= maxWidth) {\n\t\t\treturn pad ? text + \" \".repeat(maxWidth - text.length) : text;\n\t\t}\n\t\tconst targetWidth = maxWidth - ellipsisWidth;\n\t\treturn finalizeTruncatedResult(text.slice(0, targetWidth), targetWidth, ellipsis, ellipsisWidth, maxWidth, pad);\n\t}\n\n\tconst targetWidth = maxWidth - ellipsisWidth;\n\tlet result = \"\";\n\tlet pendingAnsi = \"\";\n\tlet visibleSoFar = 0;\n\tlet keptWidth = 0;\n\tlet keepContiguousPrefix = true;\n\tlet overflowed = false;\n\tlet exhaustedInput = false;\n\tconst hasAnsi = text.includes(\"\\x1b\");\n\tconst hasTabs = text.includes(\"\\t\");\n\n\tif (!hasAnsi && !hasTabs) {\n\t\tfor (const { segment } of segmenter.segment(text)) {\n\t\t\tconst width = graphemeWidth(segment);\n\t\t\tif (keepContiguousPrefix && keptWidth + width <= targetWidth) {\n\t\t\t\tresult += segment;\n\t\t\t\tkeptWidth += width;\n\t\t\t} else {\n\t\t\t\tkeepContiguousPrefix = false;\n\t\t\t}\n\t\t\tvisibleSoFar += width;\n\t\t\tif (visibleSoFar > maxWidth) {\n\t\t\t\toverflowed = true;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\texhaustedInput = !overflowed;\n\t} else {\n\t\tlet i = 0;\n\t\twhile (i < text.length) {\n\t\t\tconst ansi = extractAnsiCode(text, i);\n\t\t\tif (ansi) {\n\t\t\t\tpendingAnsi += ansi.code;\n\t\t\t\ti += ansi.length;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (text[i] === \"\\t\") {\n\t\t\t\tif (keepContiguousPrefix && keptWidth + 3 <= targetWidth) {\n\t\t\t\t\tif (pendingAnsi) {\n\t\t\t\t\t\tresult += pendingAnsi;\n\t\t\t\t\t\tpendingAnsi = \"\";\n\t\t\t\t\t}\n\t\t\t\t\tresult += \"\\t\";\n\t\t\t\t\tkeptWidth += 3;\n\t\t\t\t} else {\n\t\t\t\t\tkeepContiguousPrefix = false;\n\t\t\t\t\tpendingAnsi = \"\";\n\t\t\t\t}\n\t\t\t\tvisibleSoFar += 3;\n\t\t\t\tif (visibleSoFar > maxWidth) {\n\t\t\t\t\toverflowed = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\ti++;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tlet end = i;\n\t\t\twhile (end < text.length && text[end] !== \"\\t\") {\n\t\t\t\tconst nextAnsi = extractAnsiCode(text, end);\n\t\t\t\tif (nextAnsi) {\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tend++;\n\t\t\t}\n\n\t\t\tfor (const { segment } of segmenter.segment(text.slice(i, end))) {\n\t\t\t\tconst width = graphemeWidth(segment);\n\t\t\t\tif (keepContiguousPrefix && keptWidth + width <= targetWidth) {\n\t\t\t\t\tif (pendingAnsi) {\n\t\t\t\t\t\tresult += pendingAnsi;\n\t\t\t\t\t\tpendingAnsi = \"\";\n\t\t\t\t\t}\n\t\t\t\t\tresult += segment;\n\t\t\t\t\tkeptWidth += width;\n\t\t\t\t} else {\n\t\t\t\t\tkeepContiguousPrefix = false;\n\t\t\t\t\tpendingAnsi = \"\";\n\t\t\t\t}\n\n\t\t\t\tvisibleSoFar += width;\n\t\t\t\tif (visibleSoFar > maxWidth) {\n\t\t\t\t\toverflowed = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (overflowed) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\ti = end;\n\t\t}\n\t\texhaustedInput = i >= text.length;\n\t}\n\n\tif (!overflowed && exhaustedInput) {\n\t\treturn pad ? text + \" \".repeat(Math.max(0, maxWidth - visibleSoFar)) : text;\n\t}\n\n\treturn finalizeTruncatedResult(result, keptWidth, ellipsis, ellipsisWidth, maxWidth, pad);\n}\n\n/**\n * Extract a range of visible columns from a line. Handles ANSI codes and wide chars.\n * @param strict - If true, exclude wide chars at boundary that would extend past the range\n */\nexport function sliceByColumn(line: string, startCol: number, length: number, strict = false): string {\n\treturn sliceWithWidth(line, startCol, length, strict).text;\n}\n\n/** Like sliceByColumn but also returns the actual visible width of the result. */\nexport function sliceWithWidth(\n\tline: string,\n\tstartCol: number,\n\tlength: number,\n\tstrict = false,\n): { text: string; width: number } {\n\tif (length <= 0) return { text: \"\", width: 0 };\n\tconst endCol = startCol + length;\n\tlet result = \"\",\n\t\tresultWidth = 0,\n\t\tcurrentCol = 0,\n\t\ti = 0,\n\t\tpendingAnsi = \"\";\n\n\twhile (i < line.length) {\n\t\tconst ansi = extractAnsiCode(line, i);\n\t\tif (ansi) {\n\t\t\tif (currentCol >= startCol && currentCol < endCol) result += ansi.code;\n\t\t\telse if (currentCol < startCol) pendingAnsi += ansi.code;\n\t\t\ti += ansi.length;\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet textEnd = i;\n\t\twhile (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;\n\n\t\tfor (const { segment } of segmenter.segment(line.slice(i, textEnd))) {\n\t\t\tconst w = graphemeWidth(segment);\n\t\t\tconst inRange = currentCol >= startCol && currentCol < endCol;\n\t\t\tconst fits = !strict || currentCol + w <= endCol;\n\t\t\tif (inRange && fits) {\n\t\t\t\tif (pendingAnsi) {\n\t\t\t\t\tresult += pendingAnsi;\n\t\t\t\t\tpendingAnsi = \"\";\n\t\t\t\t}\n\t\t\t\tresult += segment;\n\t\t\t\tresultWidth += w;\n\t\t\t}\n\t\t\tcurrentCol += w;\n\t\t\tif (currentCol >= endCol) break;\n\t\t}\n\t\ti = textEnd;\n\t\tif (currentCol >= endCol) break;\n\t}\n\treturn { text: result, width: resultWidth };\n}\n\n// Pooled tracker instance for extractSegments (avoids allocation per call)\nconst pooledStyleTracker = new AnsiCodeTracker();\n\n/**\n * Extract \"before\" and \"after\" segments from a line in a single pass.\n * Used for overlay compositing where we need content before and after the overlay region.\n * Preserves styling from before the overlay that should affect content after it.\n */\nexport function extractSegments(\n\tline: string,\n\tbeforeEnd: number,\n\tafterStart: number,\n\tafterLen: number,\n\tstrictAfter = false,\n): { before: string; beforeWidth: number; after: string; afterWidth: number } {\n\tlet before = \"\",\n\t\tbeforeWidth = 0,\n\t\tafter = \"\",\n\t\tafterWidth = 0;\n\tlet currentCol = 0,\n\t\ti = 0;\n\tlet pendingAnsiBefore = \"\";\n\tlet afterStarted = false;\n\tconst afterEnd = afterStart + afterLen;\n\n\t// Track styling state so \"after\" inherits styling from before the overlay\n\tpooledStyleTracker.clear();\n\n\twhile (i < line.length) {\n\t\tconst ansi = extractAnsiCode(line, i);\n\t\tif (ansi) {\n\t\t\t// Track all SGR codes to know styling state at afterStart\n\t\t\tpooledStyleTracker.process(ansi.code);\n\t\t\t// Include ANSI codes in their respective segments\n\t\t\tif (currentCol < beforeEnd) {\n\t\t\t\tpendingAnsiBefore += ansi.code;\n\t\t\t} else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) {\n\t\t\t\t// Only include after we've started \"after\" (styling already prepended)\n\t\t\t\tafter += ansi.code;\n\t\t\t}\n\t\t\ti += ansi.length;\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet textEnd = i;\n\t\twhile (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;\n\n\t\tfor (const { segment } of segmenter.segment(line.slice(i, textEnd))) {\n\t\t\tconst w = graphemeWidth(segment);\n\n\t\t\tif (currentCol < beforeEnd) {\n\t\t\t\tif (pendingAnsiBefore) {\n\t\t\t\t\tbefore += pendingAnsiBefore;\n\t\t\t\t\tpendingAnsiBefore = \"\";\n\t\t\t\t}\n\t\t\t\tbefore += segment;\n\t\t\t\tbeforeWidth += w;\n\t\t\t} else if (currentCol >= afterStart && currentCol < afterEnd) {\n\t\t\t\tconst fits = !strictAfter || currentCol + w <= afterEnd;\n\t\t\t\tif (fits) {\n\t\t\t\t\t// On first \"after\" grapheme, prepend inherited styling from before overlay\n\t\t\t\t\tif (!afterStarted) {\n\t\t\t\t\t\tafter += pooledStyleTracker.getActiveCodes();\n\t\t\t\t\t\tafterStarted = true;\n\t\t\t\t\t}\n\t\t\t\t\tafter += segment;\n\t\t\t\t\tafterWidth += w;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tcurrentCol += w;\n\t\t\t// Early exit: done with \"before\" only, or done with both segments\n\t\t\tif (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break;\n\t\t}\n\t\ti = textEnd;\n\t\tif (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break;\n\t}\n\n\treturn { before, beforeWidth, after, afterWidth };\n}\n"]}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAMA;;GAEG;AACH,wBAAgB,oBAAoB,IAAI,IAAI,CAAC,SAAS,CAErD;AAED;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,IAAI,CAAC,SAAS,CAEjD;AAmCD,eAAO,MAAM,aAAa,QAC2H,CAAC;AAGtJ,eAAO,MAAM,mBAAmB,QAG/B,CAAC;AACF,eAAO,MAAM,0BAA0B,QAA2D,CAAC;AACnG,eAAO,MAAM,yBAAyB,QAAgE,CAAC;AAkLvG;;GAEG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAuDhD;AAaD,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAsB3D;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAsCjG;AAqXD;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAE3D;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAoBtE;AAiFD,eAAO,MAAM,iBAAiB,QAAyC,CAAC;AAExE;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEvD;AAuED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,MAAM,CASzG;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAC9B,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,QAAQ,GAAE,MAAc,EACxB,GAAG,GAAE,OAAe,GAClB,MAAM,CAmIR;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,UAAQ,GAAG,MAAM,CAEpG;AAED,kFAAkF;AAClF,wBAAgB,cAAc,CAC7B,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,MAAM,UAAQ,GACZ;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CA2CjC;AAKD;;;;GAIG;AACH,wBAAgB,eAAe,CAC9B,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,WAAW,UAAQ,GACjB;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAiE5E","sourcesContent":["import { eastAsianWidth } from \"get-east-asian-width\";\n\n// segmenters (shared instance)\nconst graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: \"grapheme\" });\nconst wordSegmenter = new Intl.Segmenter(undefined, { granularity: \"word\" });\n\n/**\n * Get the shared grapheme segmenter instance.\n */\nexport function getGraphemeSegmenter(): Intl.Segmenter {\n\treturn graphemeSegmenter;\n}\n\n/**\n * Get the shared word segmenter instance.\n */\nexport function getWordSegmenter(): Intl.Segmenter {\n\treturn wordSegmenter;\n}\n\n/**\n * Check if a grapheme cluster (after segmentation) could possibly be an RGI emoji.\n * This is a fast heuristic to avoid the expensive rgiEmojiRegex test.\n * The tested Unicode blocks are deliberately broad to account for future\n * Unicode additions.\n */\nfunction couldBeEmoji(segment: string): boolean {\n\tconst cp = segment.codePointAt(0)!;\n\treturn (\n\t\t(cp >= 0x1f000 && cp <= 0x1fbff) || // Emoji and Pictograph\n\t\t(cp >= 0x2300 && cp <= 0x23ff) || // Misc technical\n\t\t(cp >= 0x2600 && cp <= 0x27bf) || // Misc symbols, dingbats\n\t\t(cp >= 0x2b50 && cp <= 0x2b55) || // Specific stars/circles\n\t\tsegment.includes(\"\\uFE0F\") || // Contains VS16 (emoji presentation selector)\n\t\tsegment.length > 2 // Multi-codepoint sequences (ZWJ, skin tones, etc.)\n\t);\n}\n\n// Regexes for character classification (same as string-width library)\nconst zeroWidthRegex = /^(?:\\p{Default_Ignorable_Code_Point}|\\p{Control}|\\p{Mark}|\\p{Surrogate})+$/v;\nconst leadingNonPrintingRegex = /^[\\p{Default_Ignorable_Code_Point}\\p{Control}\\p{Format}\\p{Mark}\\p{Surrogate}]+/v;\nconst nonPrintingCharRegex = /^(?:\\p{Default_Ignorable_Code_Point}|\\p{Control}|\\p{Format}|\\p{Mark}|\\p{Surrogate})$/v;\nconst markCharRegex = /^\\p{Mark}$/v;\n// Marks that terminals allocate cells for when attached to a base character.\n// This includes Unicode spacing marks and non-spacing exceptions in legacy wcwidth tables.\nconst terminalSpacingMarkRegex =\n\t/^(?:[\\p{Spacing_Mark}--[\\u1734\\u302E\\u302F]]|[\\u065F\\u0F7F\\u102B\\u102C\\u1031\\u1033-\\u1035\\u1038\\u103A-\\u103E])+$/v;\nconst rgiEmojiRegex = /^\\p{RGI_Emoji}$/v;\n\n// Cache for non-ASCII strings\nconst WIDTH_CACHE_SIZE = 512;\nconst widthCache = new Map<string, number>();\n\nexport const cjkBreakRegex =\n\t/[\\p{Script_Extensions=Han}\\p{Script_Extensions=Hiragana}\\p{Script_Extensions=Katakana}\\p{Script_Extensions=Hangul}\\p{Script_Extensions=Bopomofo}]/u;\n\n// CJK letters remain part of words and paths; only punctuation can separate prose from completions.\nexport const cjkPunctuationRegex = new RegExp(\n\t`(?:(?=\\\\p{Punctuation})${cjkBreakRegex.source}|[,.:;!?()[]{}“”‘’…—])`,\n\t\"u\",\n);\nexport const autocompleteSeparatorRegex = new RegExp(`(?:\\\\s|${cjkPunctuationRegex.source})`, \"u\");\nexport const autocompleteBoundaryRegex = new RegExp(`(?:^|${autocompleteSeparatorRegex.source})`, \"u\");\n\nfunction isPrintableAscii(str: string): boolean {\n\tfor (let i = 0; i < str.length; i++) {\n\t\tconst code = str.charCodeAt(i);\n\t\tif (code < 0x20 || code > 0x7e) {\n\t\t\treturn false;\n\t\t}\n\t}\n\treturn true;\n}\n\nfunction truncateFragmentToWidth(text: string, maxWidth: number): { text: string; width: number } {\n\tif (maxWidth <= 0 || text.length === 0) {\n\t\treturn { text: \"\", width: 0 };\n\t}\n\n\tif (isPrintableAscii(text)) {\n\t\tconst clipped = text.slice(0, maxWidth);\n\t\treturn { text: clipped, width: clipped.length };\n\t}\n\n\tconst hasAnsi = text.includes(\"\\x1b\");\n\tconst hasTabs = text.includes(\"\\t\");\n\tif (!hasAnsi && !hasTabs) {\n\t\tlet result = \"\";\n\t\tlet width = 0;\n\t\tfor (const { segment } of graphemeSegmenter.segment(text)) {\n\t\t\tconst w = graphemeWidth(segment);\n\t\t\tif (width + w > maxWidth) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tresult += segment;\n\t\t\twidth += w;\n\t\t}\n\t\treturn { text: result, width };\n\t}\n\n\tlet result = \"\";\n\tlet width = 0;\n\tlet i = 0;\n\tlet pendingAnsi = \"\";\n\n\twhile (i < text.length) {\n\t\tconst ansi = extractAnsiCode(text, i);\n\t\tif (ansi) {\n\t\t\tpendingAnsi += ansi.code;\n\t\t\ti += ansi.length;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (text[i] === \"\\t\") {\n\t\t\tif (width + 3 > maxWidth) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tif (pendingAnsi) {\n\t\t\t\tresult += pendingAnsi;\n\t\t\t\tpendingAnsi = \"\";\n\t\t\t}\n\t\t\tresult += \"\\t\";\n\t\t\twidth += 3;\n\t\t\ti++;\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet end = i;\n\t\twhile (end < text.length && text[end] !== \"\\t\") {\n\t\t\tconst nextAnsi = extractAnsiCode(text, end);\n\t\t\tif (nextAnsi) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tend++;\n\t\t}\n\n\t\tfor (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) {\n\t\t\tconst w = graphemeWidth(segment);\n\t\t\tif (width + w > maxWidth) {\n\t\t\t\treturn { text: result, width };\n\t\t\t}\n\t\t\tif (pendingAnsi) {\n\t\t\t\tresult += pendingAnsi;\n\t\t\t\tpendingAnsi = \"\";\n\t\t\t}\n\t\t\tresult += segment;\n\t\t\twidth += w;\n\t\t}\n\t\ti = end;\n\t}\n\n\treturn { text: result, width };\n}\n\nfunction finalizeTruncatedResult(\n\tprefix: string,\n\tprefixWidth: number,\n\tellipsis: string,\n\tellipsisWidth: number,\n\tmaxWidth: number,\n\tpad: boolean,\n): string {\n\tconst reset = \"\\x1b[0m\";\n\tconst hyperlinkClose = getActiveOsc8Close(prefix);\n\tconst visibleWidth = prefixWidth + ellipsisWidth;\n\tlet result: string;\n\n\tif (ellipsis.length > 0) {\n\t\tresult = `${prefix}${hyperlinkClose}${reset}${ellipsis}${reset}`;\n\t} else {\n\t\tresult = `${prefix}${hyperlinkClose}${reset}`;\n\t}\n\n\treturn pad ? result + \" \".repeat(Math.max(0, maxWidth - visibleWidth)) : result;\n}\n\n/**\n * Calculate the terminal width of a single grapheme cluster.\n * Based on code from the string-width library, but includes a possible-emoji\n * check to avoid running the RGI_Emoji regex unnecessarily.\n */\nfunction graphemeWidth(segment: string): number {\n\t// Some marks occupy cells even without a base character.\n\tif (terminalSpacingMarkRegex.test(segment)) {\n\t\treturn [...segment].length;\n\t}\n\n\t// Zero-width clusters\n\tif (zeroWidthRegex.test(segment)) {\n\t\treturn 0;\n\t}\n\n\t// Emoji check with pre-filter\n\tif (couldBeEmoji(segment) && rgiEmojiRegex.test(segment)) {\n\t\treturn 2;\n\t}\n\n\t// Get base visible codepoint\n\tconst base = segment.replace(leadingNonPrintingRegex, \"\");\n\tconst cp = base.codePointAt(0);\n\tif (cp === undefined) {\n\t\treturn 0;\n\t}\n\n\t// Regional indicator symbols (U+1F1E6..U+1F1FF) are often rendered as\n\t// full-width emoji in terminals, even when isolated during streaming.\n\t// Keep width conservative (2) to avoid terminal auto-wrap drift artifacts.\n\tif (cp >= 0x1f1e6 && cp <= 0x1f1ff) {\n\t\treturn 2;\n\t}\n\n\tlet width = eastAsianWidth(cp);\n\n\t// Intl.Segmenter can group multiple terminal-spacing code points into one\n\t// grapheme. Count trailing visible code points that terminals may allocate\n\t// cells for: Indic consonants after marks, halfwidth/fullwidth forms, and\n\t// Thai/Lao AM vowels.\n\tlet followsMark = false;\n\tconst chars = [...base];\n\tfor (const char of chars.slice(1)) {\n\t\tif (terminalSpacingMarkRegex.test(char)) {\n\t\t\twidth += 1;\n\t\t\tfollowsMark = false;\n\t\t} else if (markCharRegex.test(char)) {\n\t\t\tfollowsMark = true;\n\t\t} else if (!nonPrintingCharRegex.test(char)) {\n\t\t\tconst c = char.codePointAt(0)!;\n\t\t\tif (followsMark || (c >= 0xff00 && c <= 0xffef)) {\n\t\t\t\t// halfwidth + fullwidth forms\n\t\t\t\twidth += eastAsianWidth(c);\n\t\t\t} else if (c === 0x0e33 || c === 0x0eb3) {\n\t\t\t\twidth += 1;\n\t\t\t}\n\t\t\tfollowsMark = false;\n\t\t}\n\t}\n\n\treturn width;\n}\n\n/**\n * Calculate the visible width of a string in terminal columns.\n */\nexport function visibleWidth(str: string): number {\n\tif (str.length === 0) {\n\t\treturn 0;\n\t}\n\n\t// Fast path: pure ASCII printable\n\tif (isPrintableAscii(str)) {\n\t\treturn str.length;\n\t}\n\n\t// Check cache\n\tconst cached = widthCache.get(str);\n\tif (cached !== undefined) {\n\t\treturn cached;\n\t}\n\n\t// Normalize: tabs to 3 spaces, strip ANSI escape codes\n\tlet clean = str;\n\tif (str.includes(\"\\t\")) {\n\t\tclean = clean.replace(/\\t/g, \" \");\n\t}\n\tif (clean.includes(\"\\x1b\")) {\n\t\t// Strip supported ANSI/OSC/APC escape sequences in one pass.\n\t\t// This covers CSI styling/cursor codes, OSC hyperlinks and prompt markers,\n\t\t// and APC sequences like CURSOR_MARKER.\n\t\tlet stripped = \"\";\n\t\tlet i = 0;\n\t\twhile (i < clean.length) {\n\t\t\tconst ansi = extractAnsiCode(clean, i);\n\t\t\tif (ansi) {\n\t\t\t\ti += ansi.length;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tstripped += clean[i];\n\t\t\ti++;\n\t\t}\n\t\tclean = stripped;\n\t}\n\n\t// Calculate width\n\tlet width = 0;\n\tfor (const { segment } of graphemeSegmenter.segment(clean)) {\n\t\twidth += graphemeWidth(segment);\n\t}\n\n\t// Cache result\n\tif (widthCache.size >= WIDTH_CACHE_SIZE) {\n\t\tconst firstKey = widthCache.keys().next().value;\n\t\tif (firstKey !== undefined) {\n\t\t\twidthCache.delete(firstKey);\n\t\t}\n\t}\n\twidthCache.set(str, width);\n\n\treturn width;\n}\n\n/**\n * Normalize text for terminal output without changing logical editor content.\n * Some terminals render precomposed Thai/Lao AM vowels inconsistently during\n * differential repaint. Their compatibility decompositions have the same cell\n * width but avoid stale-cell artifacts in terminal renderers. Visible tabs are\n * expanded to the fixed width used by layout so terminal tab stops cannot wrap\n * a logical line, while tabs inside terminal string sequences stay untouched.\n */\nconst THAI_LAO_AM_REGEX = /[\\u0e33\\u0eb3]/;\nconst THAI_LAO_AM_GLOBAL_REGEX = /[\\u0e33\\u0eb3]/g;\n\nexport function normalizeTerminalOutput(str: string): string {\n\tlet normalized = str;\n\tif (THAI_LAO_AM_REGEX.test(normalized)) {\n\t\tnormalized = normalized.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) =>\n\t\t\tchar === \"\\u0e33\" ? \"\\u0e4d\\u0e32\" : \"\\u0ecd\\u0eb2\",\n\t\t);\n\t}\n\tif (!normalized.includes(\"\\t\")) return normalized;\n\n\tlet result = \"\";\n\tlet i = 0;\n\twhile (i < normalized.length) {\n\t\tconst ansi = extractAnsiCode(normalized, i);\n\t\tif (ansi) {\n\t\t\tresult += ansi.code;\n\t\t\ti += ansi.length;\n\t\t\tcontinue;\n\t\t}\n\t\tresult += normalized[i] === \"\\t\" ? \" \" : normalized[i];\n\t\ti++;\n\t}\n\treturn result;\n}\n\n/**\n * Extract ANSI escape sequences from a string at the given position.\n */\nexport function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {\n\tif (pos >= str.length || str[pos] !== \"\\x1b\") return null;\n\n\tconst next = str[pos + 1];\n\n\t// CSI sequence: ESC [ ... m/G/K/H/J\n\tif (next === \"[\") {\n\t\tlet j = pos + 2;\n\t\twhile (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++;\n\t\tif (j < str.length) return { code: str.substring(pos, j + 1), length: j + 1 - pos };\n\t\treturn null;\n\t}\n\n\t// OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \\)\n\t// Used for hyperlinks (OSC 8), window titles, etc.\n\tif (next === \"]\") {\n\t\tlet j = pos + 2;\n\t\twhile (j < str.length) {\n\t\t\tif (str[j] === \"\\x07\") return { code: str.substring(pos, j + 1), length: j + 1 - pos };\n\t\t\tif (str[j] === \"\\x1b\" && str[j + 1] === \"\\\\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos };\n\t\t\tj++;\n\t\t}\n\t\treturn null;\n\t}\n\n\t// APC sequence: ESC _ ... BEL or ESC _ ... ST (ESC \\)\n\t// Used for cursor marker and application-specific commands\n\tif (next === \"_\") {\n\t\tlet j = pos + 2;\n\t\twhile (j < str.length) {\n\t\t\tif (str[j] === \"\\x07\") return { code: str.substring(pos, j + 1), length: j + 1 - pos };\n\t\t\tif (str[j] === \"\\x1b\" && str[j + 1] === \"\\\\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos };\n\t\t\tj++;\n\t\t}\n\t\treturn null;\n\t}\n\n\treturn null;\n}\n\ntype Osc8Terminator = \"\\x07\" | \"\\x1b\\\\\";\n\ninterface ActiveHyperlink {\n\tparams: string;\n\turl: string;\n\tterminator: Osc8Terminator;\n}\n\nfunction parseOsc8Hyperlink(ansiCode: string): ActiveHyperlink | null | undefined {\n\tif (!ansiCode.startsWith(\"\\x1b]8;\")) {\n\t\treturn undefined;\n\t}\n\n\tconst terminator: Osc8Terminator = ansiCode.endsWith(\"\\x07\") ? \"\\x07\" : \"\\x1b\\\\\";\n\tconst body = ansiCode.slice(4, terminator === \"\\x07\" ? -1 : -2);\n\tconst separatorIndex = body.indexOf(\";\");\n\tif (separatorIndex === -1) {\n\t\treturn undefined;\n\t}\n\n\tconst params = body.slice(0, separatorIndex);\n\tconst url = body.slice(separatorIndex + 1);\n\tif (!url) {\n\t\treturn null;\n\t}\n\treturn { params, url, terminator };\n}\n\nfunction formatOsc8Hyperlink(hyperlink: ActiveHyperlink): string {\n\treturn `\\x1b]8;${hyperlink.params};${hyperlink.url}${hyperlink.terminator}`;\n}\n\nfunction formatOsc8Close(terminator: Osc8Terminator): string {\n\treturn `\\x1b]8;;${terminator}`;\n}\n\nfunction getActiveOsc8Close(prefix: string): string {\n\tif (!prefix.includes(\"\\x1b]8;\")) {\n\t\treturn \"\";\n\t}\n\n\tlet activeHyperlink: ActiveHyperlink | null = null;\n\tlet i = 0;\n\twhile (i < prefix.length) {\n\t\tconst ansi = extractAnsiCode(prefix, i);\n\t\tif (ansi) {\n\t\t\tconst hyperlink = parseOsc8Hyperlink(ansi.code);\n\t\t\tif (hyperlink !== undefined) {\n\t\t\t\tactiveHyperlink = hyperlink;\n\t\t\t}\n\t\t\ti += ansi.length;\n\t\t} else {\n\t\t\ti++;\n\t\t}\n\t}\n\treturn activeHyperlink ? formatOsc8Close(activeHyperlink.terminator) : \"\";\n}\n\n/**\n * Track active ANSI SGR codes to preserve styling across line breaks.\n */\nclass AnsiCodeTracker {\n\t// Track individual attributes separately so we can reset them specifically\n\tprivate bold = false;\n\tprivate dim = false;\n\tprivate italic = false;\n\tprivate underline = false;\n\tprivate blink = false;\n\tprivate inverse = false;\n\tprivate hidden = false;\n\tprivate strikethrough = false;\n\tprivate fgColor: string | null = null; // Stores the full code like \"31\" or \"38;5;240\"\n\tprivate bgColor: string | null = null; // Stores the full code like \"41\" or \"48;5;240\"\n\tprivate activeHyperlink: ActiveHyperlink | null = null;\n\n\tprocess(ansiCode: string): void {\n\t\t// OSC 8 hyperlink: \\x1b]8;;<url>\\x1b\\\\ (open) or \\x1b]8;;\\x1b\\\\ (close).\n\t\t// Preserve the original terminator because some terminals only make BEL-terminated\n\t\t// links clickable. OAuth login URLs use BEL, so reopening wrapped lines with ST\n\t\t// made only the first physical line clickable in those terminals.\n\t\tconst hyperlink = parseOsc8Hyperlink(ansiCode);\n\t\tif (hyperlink !== undefined) {\n\t\t\tthis.activeHyperlink = hyperlink;\n\t\t\treturn;\n\t\t}\n\n\t\tif (!ansiCode.endsWith(\"m\")) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Extract the parameters between \\x1b[ and m\n\t\tconst match = ansiCode.match(/\\x1b\\[([\\d;]*)m/);\n\t\tif (!match) return;\n\n\t\tconst params = match[1];\n\t\tif (params === \"\" || params === \"0\") {\n\t\t\t// Full reset\n\t\t\tthis.reset();\n\t\t\treturn;\n\t\t}\n\n\t\t// Parse parameters (can be semicolon-separated)\n\t\tconst parts = params.split(\";\");\n\t\tlet i = 0;\n\t\twhile (i < parts.length) {\n\t\t\tconst code = Number.parseInt(parts[i], 10);\n\n\t\t\t// Handle 256-color and RGB codes which consume multiple parameters\n\t\t\tif (code === 38 || code === 48) {\n\t\t\t\t// 38;5;N (256 color fg) or 38;2;R;G;B (RGB fg)\n\t\t\t\t// 48;5;N (256 color bg) or 48;2;R;G;B (RGB bg)\n\t\t\t\tif (parts[i + 1] === \"5\" && parts[i + 2] !== undefined) {\n\t\t\t\t\t// 256 color: 38;5;N or 48;5;N\n\t\t\t\t\tconst colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]}`;\n\t\t\t\t\tif (code === 38) {\n\t\t\t\t\t\tthis.fgColor = colorCode;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.bgColor = colorCode;\n\t\t\t\t\t}\n\t\t\t\t\ti += 3;\n\t\t\t\t\tcontinue;\n\t\t\t\t} else if (parts[i + 1] === \"2\" && parts[i + 4] !== undefined) {\n\t\t\t\t\t// RGB color: 38;2;R;G;B or 48;2;R;G;B\n\t\t\t\t\tconst colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]};${parts[i + 3]};${parts[i + 4]}`;\n\t\t\t\t\tif (code === 38) {\n\t\t\t\t\t\tthis.fgColor = colorCode;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.bgColor = colorCode;\n\t\t\t\t\t}\n\t\t\t\t\ti += 5;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Standard SGR codes\n\t\t\tswitch (code) {\n\t\t\t\tcase 0:\n\t\t\t\t\tthis.reset();\n\t\t\t\t\tbreak;\n\t\t\t\tcase 1:\n\t\t\t\t\tthis.bold = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 2:\n\t\t\t\t\tthis.dim = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 3:\n\t\t\t\t\tthis.italic = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 4:\n\t\t\t\t\tthis.underline = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 5:\n\t\t\t\t\tthis.blink = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 7:\n\t\t\t\t\tthis.inverse = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 8:\n\t\t\t\t\tthis.hidden = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 9:\n\t\t\t\t\tthis.strikethrough = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 21:\n\t\t\t\t\tthis.bold = false;\n\t\t\t\t\tbreak; // Some terminals\n\t\t\t\tcase 22:\n\t\t\t\t\tthis.bold = false;\n\t\t\t\t\tthis.dim = false;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 23:\n\t\t\t\t\tthis.italic = false;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 24:\n\t\t\t\t\tthis.underline = false;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 25:\n\t\t\t\t\tthis.blink = false;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 27:\n\t\t\t\t\tthis.inverse = false;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 28:\n\t\t\t\t\tthis.hidden = false;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 29:\n\t\t\t\t\tthis.strikethrough = false;\n\t\t\t\t\tbreak;\n\t\t\t\tcase 39:\n\t\t\t\t\tthis.fgColor = null;\n\t\t\t\t\tbreak; // Default fg\n\t\t\t\tcase 49:\n\t\t\t\t\tthis.bgColor = null;\n\t\t\t\t\tbreak; // Default bg\n\t\t\t\tdefault:\n\t\t\t\t\t// Standard foreground colors 30-37, 90-97\n\t\t\t\t\tif ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {\n\t\t\t\t\t\tthis.fgColor = String(code);\n\t\t\t\t\t}\n\t\t\t\t\t// Standard background colors 40-47, 100-107\n\t\t\t\t\telse if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {\n\t\t\t\t\t\tthis.bgColor = String(code);\n\t\t\t\t\t}\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t\ti++;\n\t\t}\n\t}\n\n\tprivate reset(): void {\n\t\tthis.bold = false;\n\t\tthis.dim = false;\n\t\tthis.italic = false;\n\t\tthis.underline = false;\n\t\tthis.blink = false;\n\t\tthis.inverse = false;\n\t\tthis.hidden = false;\n\t\tthis.strikethrough = false;\n\t\tthis.fgColor = null;\n\t\tthis.bgColor = null;\n\t\t// SGR reset does not affect OSC 8 hyperlink state\n\t}\n\n\t/** Clear all state for reuse. */\n\tclear(): void {\n\t\tthis.reset();\n\t\tthis.activeHyperlink = null;\n\t}\n\n\tgetActiveCodes(): string {\n\t\tconst codes: string[] = [];\n\t\tif (this.bold) codes.push(\"1\");\n\t\tif (this.dim) codes.push(\"2\");\n\t\tif (this.italic) codes.push(\"3\");\n\t\tif (this.underline) codes.push(\"4\");\n\t\tif (this.blink) codes.push(\"5\");\n\t\tif (this.inverse) codes.push(\"7\");\n\t\tif (this.hidden) codes.push(\"8\");\n\t\tif (this.strikethrough) codes.push(\"9\");\n\t\tif (this.fgColor) codes.push(this.fgColor);\n\t\tif (this.bgColor) codes.push(this.bgColor);\n\n\t\tlet result = codes.length > 0 ? `\\x1b[${codes.join(\";\")}m` : \"\";\n\t\tif (this.activeHyperlink) {\n\t\t\tresult += formatOsc8Hyperlink(this.activeHyperlink);\n\t\t}\n\t\treturn result;\n\t}\n\n\thasActiveCodes(): boolean {\n\t\treturn (\n\t\t\tthis.bold ||\n\t\t\tthis.dim ||\n\t\t\tthis.italic ||\n\t\t\tthis.underline ||\n\t\t\tthis.blink ||\n\t\t\tthis.inverse ||\n\t\t\tthis.hidden ||\n\t\t\tthis.strikethrough ||\n\t\t\tthis.fgColor !== null ||\n\t\t\tthis.bgColor !== null ||\n\t\t\tthis.activeHyperlink !== null\n\t\t);\n\t}\n\n\t/**\n\t * Get reset codes for attributes that need to be turned off at line end.\n\t * Underline must be closed to prevent bleeding into padding.\n\t * Active OSC 8 hyperlinks must be closed and re-opened on the next line.\n\t * Returns empty string if no attributes need closing.\n\t */\n\tgetLineEndReset(): string {\n\t\tlet result = \"\";\n\t\tif (this.underline) {\n\t\t\tresult += \"\\x1b[24m\"; // Underline off only\n\t\t}\n\t\tif (this.activeHyperlink) {\n\t\t\tresult += formatOsc8Close(this.activeHyperlink.terminator); // Re-opened at line start via getActiveCodes()\n\t\t}\n\t\treturn result;\n\t}\n}\n\nfunction updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void {\n\tlet i = 0;\n\twhile (i < text.length) {\n\t\tconst ansiResult = extractAnsiCode(text, i);\n\t\tif (ansiResult) {\n\t\t\ttracker.process(ansiResult.code);\n\t\t\ti += ansiResult.length;\n\t\t} else {\n\t\t\ti++;\n\t\t}\n\t}\n}\n\n/**\n * Split text into words while keeping ANSI codes attached.\n */\nfunction splitIntoTokensWithAnsi(text: string): string[] {\n\tconst tokens: string[] = [];\n\tlet current = \"\";\n\tlet pendingAnsi = \"\"; // ANSI codes waiting to be attached to next visible content\n\tlet currentKind: \"space\" | \"word\" | null = null;\n\tlet i = 0;\n\n\tconst flushCurrent = (): void => {\n\t\tif (!current) return;\n\t\ttokens.push(current);\n\t\tcurrent = \"\";\n\t\tcurrentKind = null;\n\t};\n\n\twhile (i < text.length) {\n\t\tconst ansiResult = extractAnsiCode(text, i);\n\t\tif (ansiResult) {\n\t\t\t// Hold ANSI codes separately - they'll be attached to the next visible char\n\t\t\tpendingAnsi += ansiResult.code;\n\t\t\ti += ansiResult.length;\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet end = i;\n\t\twhile (end < text.length && !extractAnsiCode(text, end)) {\n\t\t\tend++;\n\t\t}\n\n\t\tfor (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) {\n\t\t\tconst segmentIsSpace = segment === \" \";\n\t\t\tif (!segmentIsSpace && cjkBreakRegex.test(segment)) {\n\t\t\t\tflushCurrent();\n\t\t\t\ttokens.push(pendingAnsi + segment);\n\t\t\t\tpendingAnsi = \"\";\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst segmentKind = segmentIsSpace ? \"space\" : \"word\";\n\t\t\tif (current && currentKind !== segmentKind) {\n\t\t\t\tflushCurrent();\n\t\t\t}\n\n\t\t\tif (pendingAnsi) {\n\t\t\t\tcurrent += pendingAnsi;\n\t\t\t\tpendingAnsi = \"\";\n\t\t\t}\n\n\t\t\tcurrentKind = segmentKind;\n\t\t\tcurrent += segment;\n\t\t}\n\n\t\ti = end;\n\t}\n\n\t// Handle any remaining pending ANSI codes (attach to last token)\n\tif (pendingAnsi) {\n\t\tif (current) {\n\t\t\tcurrent += pendingAnsi;\n\t\t} else if (tokens.length > 0) {\n\t\t\ttokens[tokens.length - 1] += pendingAnsi;\n\t\t} else {\n\t\t\tcurrent = pendingAnsi;\n\t\t}\n\t}\n\n\tif (current) {\n\t\ttokens.push(current);\n\t}\n\n\treturn tokens;\n}\n\n/**\n * Flatten cached lines. V8 keeps a string built by concatenation as a tree of its parts until something reads it\n * whole, and a cached line kept as such a tree retains several times its own size. Converting a string to a number\n * reads it whole, so V8 flattens it in place; the strings' values do not change.\n */\nexport function flattenLines(lines: readonly string[]): void {\n\tfor (const line of lines) Number(line);\n}\n\n/**\n * Wrap text with ANSI codes preserved.\n *\n * ONLY does word wrapping - NO padding, NO background colors.\n * Returns lines where each line is <= width visible chars.\n * Active ANSI codes are preserved across line breaks.\n *\n * @param text - Text to wrap (may contain ANSI codes and newlines)\n * @param width - Maximum visible width per line\n * @returns Array of wrapped lines (NOT padded to width)\n */\nexport function wrapTextWithAnsi(text: string, width: number): string[] {\n\tif (!text) {\n\t\treturn [\"\"];\n\t}\n\n\t// Handle newlines by processing each line separately\n\t// Track ANSI state across lines so styles carry over after literal newlines\n\tconst inputLines = text.split(/\\r\\n|\\r|\\n/);\n\tconst result: string[] = [];\n\tconst tracker = new AnsiCodeTracker();\n\n\tfor (const inputLine of inputLines) {\n\t\t// Prepend active ANSI codes from previous lines (except for first line)\n\t\tconst prefix = result.length > 0 ? tracker.getActiveCodes() : \"\";\n\t\tresult.push(...wrapSingleLine(prefix + inputLine, width));\n\t\t// Update tracker with codes from this line for next iteration\n\t\tupdateTrackerFromText(inputLine, tracker);\n\t}\n\n\treturn result.length > 0 ? result : [\"\"];\n}\n\nfunction wrapSingleLine(line: string, width: number): string[] {\n\tif (!line) {\n\t\treturn [\"\"];\n\t}\n\n\tconst visibleLength = visibleWidth(line);\n\tif (visibleLength <= width) {\n\t\treturn [line];\n\t}\n\n\tconst wrapped: string[] = [];\n\tconst tracker = new AnsiCodeTracker();\n\tconst tokens = splitIntoTokensWithAnsi(line);\n\n\tlet currentLine = \"\";\n\tlet currentVisibleLength = 0;\n\n\tfor (const token of tokens) {\n\t\tconst tokenVisibleLength = visibleWidth(token);\n\t\tconst isWhitespace = token.trim() === \"\";\n\n\t\t// Token itself is too long - break it character by character\n\t\tif (tokenVisibleLength > width && !isWhitespace) {\n\t\t\tif (currentLine) {\n\t\t\t\t// Add specific reset for underline only (preserves background)\n\t\t\t\tconst lineEndReset = tracker.getLineEndReset();\n\t\t\t\tif (lineEndReset) {\n\t\t\t\t\tcurrentLine += lineEndReset;\n\t\t\t\t}\n\t\t\t\twrapped.push(currentLine);\n\t\t\t\tcurrentLine = \"\";\n\t\t\t\tcurrentVisibleLength = 0;\n\t\t\t}\n\n\t\t\t// Break long token - breakLongWord handles its own resets\n\t\t\tconst broken = breakLongWord(token, width, tracker);\n\t\t\twrapped.push(...broken.slice(0, -1));\n\t\t\tcurrentLine = broken[broken.length - 1];\n\t\t\tcurrentVisibleLength = visibleWidth(currentLine);\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Check if adding this token would exceed width\n\t\tconst totalNeeded = currentVisibleLength + tokenVisibleLength;\n\n\t\tif (totalNeeded > width && currentVisibleLength > 0) {\n\t\t\t// Trim trailing whitespace, then add underline reset (not full reset, to preserve background)\n\t\t\tlet lineToWrap = currentLine.trimEnd();\n\t\t\tconst lineEndReset = tracker.getLineEndReset();\n\t\t\tif (lineEndReset) {\n\t\t\t\tlineToWrap += lineEndReset;\n\t\t\t}\n\t\t\twrapped.push(lineToWrap);\n\t\t\tif (isWhitespace) {\n\t\t\t\t// Don't start new line with whitespace\n\t\t\t\tcurrentLine = tracker.getActiveCodes();\n\t\t\t\tcurrentVisibleLength = 0;\n\t\t\t} else {\n\t\t\t\tcurrentLine = tracker.getActiveCodes() + token;\n\t\t\t\tcurrentVisibleLength = tokenVisibleLength;\n\t\t\t}\n\t\t} else {\n\t\t\t// Add to current line\n\t\t\tcurrentLine += token;\n\t\t\tcurrentVisibleLength += tokenVisibleLength;\n\t\t}\n\n\t\tupdateTrackerFromText(token, tracker);\n\t}\n\n\tif (currentLine) {\n\t\t// No reset at end of final line - let caller handle it\n\t\twrapped.push(currentLine);\n\t}\n\n\t// Trailing whitespace can cause lines to exceed the requested width\n\treturn wrapped.length > 0 ? wrapped.map((line) => line.trimEnd()) : [\"\"];\n}\n\nexport const PUNCTUATION_REGEX = /[(){}[\\]<>.,;:'\"!?+\\-=*/\\\\|&%^$#@~`]/;\n\n/**\n * Check if a character is whitespace.\n */\nexport function isWhitespaceChar(char: string): boolean {\n\treturn /\\s/.test(char);\n}\n\n/**\n * Check if a character is punctuation.\n */\nexport function isPunctuationChar(char: string): boolean {\n\treturn PUNCTUATION_REGEX.test(char);\n}\n\nfunction breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): string[] {\n\tconst lines: string[] = [];\n\tlet currentLine = tracker.getActiveCodes();\n\tlet currentWidth = 0;\n\n\t// First, separate ANSI codes from visible content\n\t// We need to handle ANSI codes specially since they're not graphemes\n\tlet i = 0;\n\tconst segments: Array<{ type: \"ansi\" | \"grapheme\"; value: string }> = [];\n\n\twhile (i < word.length) {\n\t\tconst ansiResult = extractAnsiCode(word, i);\n\t\tif (ansiResult) {\n\t\t\tsegments.push({ type: \"ansi\", value: ansiResult.code });\n\t\t\ti += ansiResult.length;\n\t\t} else {\n\t\t\t// Find the next ANSI code or end of string\n\t\t\tlet end = i;\n\t\t\twhile (end < word.length) {\n\t\t\t\tconst nextAnsi = extractAnsiCode(word, end);\n\t\t\t\tif (nextAnsi) break;\n\t\t\t\tend++;\n\t\t\t}\n\t\t\t// Segment this non-ANSI portion into graphemes\n\t\t\tconst textPortion = word.slice(i, end);\n\t\t\tfor (const seg of graphemeSegmenter.segment(textPortion)) {\n\t\t\t\tsegments.push({ type: \"grapheme\", value: seg.segment });\n\t\t\t}\n\t\t\ti = end;\n\t\t}\n\t}\n\n\t// Now process segments\n\tfor (const seg of segments) {\n\t\tif (seg.type === \"ansi\") {\n\t\t\tcurrentLine += seg.value;\n\t\t\ttracker.process(seg.value);\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst grapheme = seg.value;\n\t\t// Skip empty graphemes to avoid issues with string-width calculation\n\t\tif (!grapheme) continue;\n\n\t\tconst graphemeWidth = visibleWidth(grapheme);\n\n\t\tif (currentWidth + graphemeWidth > width) {\n\t\t\t// Add specific reset for underline only (preserves background)\n\t\t\tconst lineEndReset = tracker.getLineEndReset();\n\t\t\tif (lineEndReset) {\n\t\t\t\tcurrentLine += lineEndReset;\n\t\t\t}\n\t\t\tlines.push(currentLine);\n\t\t\tcurrentLine = tracker.getActiveCodes();\n\t\t\tcurrentWidth = 0;\n\t\t}\n\n\t\tcurrentLine += grapheme;\n\t\tcurrentWidth += graphemeWidth;\n\t}\n\n\tif (currentLine) {\n\t\t// No reset at end of final segment - caller handles continuation\n\t\tlines.push(currentLine);\n\t}\n\n\treturn lines.length > 0 ? lines : [\"\"];\n}\n\n/**\n * Apply background color to a line, padding to full width.\n *\n * @param line - Line of text (may contain ANSI codes)\n * @param width - Total width to pad to\n * @param bgFn - Background color function\n * @returns Line with background applied and padded to width\n */\nexport function applyBackgroundToLine(line: string, width: number, bgFn: (text: string) => string): string {\n\t// Calculate padding needed\n\tconst visibleLen = visibleWidth(line);\n\tconst paddingNeeded = Math.max(0, width - visibleLen);\n\tconst padding = \" \".repeat(paddingNeeded);\n\n\t// Apply background to content + padding\n\tconst withPadding = line + padding;\n\treturn bgFn(withPadding);\n}\n\n/**\n * Truncate text to fit within a maximum visible width, adding ellipsis if needed.\n * Optionally pad with spaces to reach exactly maxWidth.\n * Properly handles ANSI escape codes (they don't count toward width).\n *\n * @param text - Text to truncate (may contain ANSI codes)\n * @param maxWidth - Maximum visible width\n * @param ellipsis - Ellipsis string to append when truncating (default: \"...\")\n * @param pad - If true, pad result with spaces to exactly maxWidth (default: false)\n * @returns Truncated text, optionally padded to exactly maxWidth\n */\nexport function truncateToWidth(\n\ttext: string,\n\tmaxWidth: number,\n\tellipsis: string = \"...\",\n\tpad: boolean = false,\n): string {\n\tif (maxWidth <= 0) {\n\t\treturn \"\";\n\t}\n\n\tif (text.length === 0) {\n\t\treturn pad ? \" \".repeat(maxWidth) : \"\";\n\t}\n\n\tconst ellipsisWidth = visibleWidth(ellipsis);\n\tif (ellipsisWidth >= maxWidth) {\n\t\tconst textWidth = visibleWidth(text);\n\t\tif (textWidth <= maxWidth) {\n\t\t\treturn pad ? text + \" \".repeat(maxWidth - textWidth) : text;\n\t\t}\n\n\t\tconst clippedEllipsis = truncateFragmentToWidth(ellipsis, maxWidth);\n\t\tif (clippedEllipsis.width === 0) {\n\t\t\treturn pad ? \" \".repeat(maxWidth) : \"\";\n\t\t}\n\t\treturn finalizeTruncatedResult(\"\", 0, clippedEllipsis.text, clippedEllipsis.width, maxWidth, pad);\n\t}\n\n\tif (isPrintableAscii(text)) {\n\t\tif (text.length <= maxWidth) {\n\t\t\treturn pad ? text + \" \".repeat(maxWidth - text.length) : text;\n\t\t}\n\t\tconst targetWidth = maxWidth - ellipsisWidth;\n\t\treturn finalizeTruncatedResult(text.slice(0, targetWidth), targetWidth, ellipsis, ellipsisWidth, maxWidth, pad);\n\t}\n\n\tconst targetWidth = maxWidth - ellipsisWidth;\n\tlet result = \"\";\n\tlet pendingAnsi = \"\";\n\tlet visibleSoFar = 0;\n\tlet keptWidth = 0;\n\tlet keepContiguousPrefix = true;\n\tlet overflowed = false;\n\tlet exhaustedInput = false;\n\tconst hasAnsi = text.includes(\"\\x1b\");\n\tconst hasTabs = text.includes(\"\\t\");\n\n\tif (!hasAnsi && !hasTabs) {\n\t\tfor (const { segment } of graphemeSegmenter.segment(text)) {\n\t\t\tconst width = graphemeWidth(segment);\n\t\t\tif (keepContiguousPrefix && keptWidth + width <= targetWidth) {\n\t\t\t\tresult += segment;\n\t\t\t\tkeptWidth += width;\n\t\t\t} else {\n\t\t\t\tkeepContiguousPrefix = false;\n\t\t\t}\n\t\t\tvisibleSoFar += width;\n\t\t\tif (visibleSoFar > maxWidth) {\n\t\t\t\toverflowed = true;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\texhaustedInput = !overflowed;\n\t} else {\n\t\tlet i = 0;\n\t\twhile (i < text.length) {\n\t\t\tconst ansi = extractAnsiCode(text, i);\n\t\t\tif (ansi) {\n\t\t\t\tpendingAnsi += ansi.code;\n\t\t\t\ti += ansi.length;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (text[i] === \"\\t\") {\n\t\t\t\tif (keepContiguousPrefix && keptWidth + 3 <= targetWidth) {\n\t\t\t\t\tif (pendingAnsi) {\n\t\t\t\t\t\tresult += pendingAnsi;\n\t\t\t\t\t\tpendingAnsi = \"\";\n\t\t\t\t\t}\n\t\t\t\t\tresult += \"\\t\";\n\t\t\t\t\tkeptWidth += 3;\n\t\t\t\t} else {\n\t\t\t\t\tkeepContiguousPrefix = false;\n\t\t\t\t\tpendingAnsi = \"\";\n\t\t\t\t}\n\t\t\t\tvisibleSoFar += 3;\n\t\t\t\tif (visibleSoFar > maxWidth) {\n\t\t\t\t\toverflowed = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\ti++;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tlet end = i;\n\t\t\twhile (end < text.length && text[end] !== \"\\t\") {\n\t\t\t\tconst nextAnsi = extractAnsiCode(text, end);\n\t\t\t\tif (nextAnsi) {\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tend++;\n\t\t\t}\n\n\t\t\tfor (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) {\n\t\t\t\tconst width = graphemeWidth(segment);\n\t\t\t\tif (keepContiguousPrefix && keptWidth + width <= targetWidth) {\n\t\t\t\t\tif (pendingAnsi) {\n\t\t\t\t\t\tresult += pendingAnsi;\n\t\t\t\t\t\tpendingAnsi = \"\";\n\t\t\t\t\t}\n\t\t\t\t\tresult += segment;\n\t\t\t\t\tkeptWidth += width;\n\t\t\t\t} else {\n\t\t\t\t\tkeepContiguousPrefix = false;\n\t\t\t\t\tpendingAnsi = \"\";\n\t\t\t\t}\n\n\t\t\t\tvisibleSoFar += width;\n\t\t\t\tif (visibleSoFar > maxWidth) {\n\t\t\t\t\toverflowed = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (overflowed) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\ti = end;\n\t\t}\n\t\texhaustedInput = i >= text.length;\n\t}\n\n\tif (!overflowed && exhaustedInput) {\n\t\treturn pad ? text + \" \".repeat(Math.max(0, maxWidth - visibleSoFar)) : text;\n\t}\n\n\treturn finalizeTruncatedResult(result, keptWidth, ellipsis, ellipsisWidth, maxWidth, pad);\n}\n\n/**\n * Extract a range of visible columns from a line. Handles ANSI codes and wide chars.\n * @param strict - If true, exclude wide chars at boundary that would extend past the range\n */\nexport function sliceByColumn(line: string, startCol: number, length: number, strict = false): string {\n\treturn sliceWithWidth(line, startCol, length, strict).text;\n}\n\n/** Like sliceByColumn but also returns the actual visible width of the result. */\nexport function sliceWithWidth(\n\tline: string,\n\tstartCol: number,\n\tlength: number,\n\tstrict = false,\n): { text: string; width: number } {\n\tif (length <= 0) return { text: \"\", width: 0 };\n\tconst endCol = startCol + length;\n\tlet result = \"\",\n\t\tresultWidth = 0,\n\t\tcurrentCol = 0,\n\t\ti = 0,\n\t\tpendingAnsi = \"\";\n\n\twhile (i < line.length) {\n\t\tconst ansi = extractAnsiCode(line, i);\n\t\tif (ansi) {\n\t\t\tif (currentCol >= startCol && currentCol < endCol) {\n\t\t\t\t// Keep original order: codes from before the range must precede codes at the boundary\n\t\t\t\tresult += pendingAnsi + ansi.code;\n\t\t\t\tpendingAnsi = \"\";\n\t\t\t} else if (currentCol < startCol) pendingAnsi += ansi.code;\n\t\t\ti += ansi.length;\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet textEnd = i;\n\t\twhile (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;\n\n\t\tfor (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) {\n\t\t\tconst w = graphemeWidth(segment);\n\t\t\tconst inRange = currentCol >= startCol && currentCol < endCol;\n\t\t\tconst fits = !strict || currentCol + w <= endCol;\n\t\t\tif (inRange && fits) {\n\t\t\t\tif (pendingAnsi) {\n\t\t\t\t\tresult += pendingAnsi;\n\t\t\t\t\tpendingAnsi = \"\";\n\t\t\t\t}\n\t\t\t\tresult += segment;\n\t\t\t\tresultWidth += w;\n\t\t\t}\n\t\t\tcurrentCol += w;\n\t\t\tif (currentCol >= endCol) break;\n\t\t}\n\t\ti = textEnd;\n\t\tif (currentCol >= endCol) break;\n\t}\n\treturn { text: result, width: resultWidth };\n}\n\n// Pooled tracker instance for extractSegments (avoids allocation per call)\nconst pooledStyleTracker = new AnsiCodeTracker();\n\n/**\n * Extract \"before\" and \"after\" segments from a line in a single pass.\n * Used for overlay compositing where we need content before and after the overlay region.\n * Preserves styling from before the overlay that should affect content after it.\n */\nexport function extractSegments(\n\tline: string,\n\tbeforeEnd: number,\n\tafterStart: number,\n\tafterLen: number,\n\tstrictAfter = false,\n): { before: string; beforeWidth: number; after: string; afterWidth: number } {\n\tlet before = \"\",\n\t\tbeforeWidth = 0,\n\t\tafter = \"\",\n\t\tafterWidth = 0;\n\tlet currentCol = 0,\n\t\ti = 0;\n\tlet pendingAnsiBefore = \"\";\n\tlet afterStarted = false;\n\tconst afterEnd = afterStart + afterLen;\n\n\t// Track styling state so \"after\" inherits styling from before the overlay\n\tpooledStyleTracker.clear();\n\n\twhile (i < line.length) {\n\t\tconst ansi = extractAnsiCode(line, i);\n\t\tif (ansi) {\n\t\t\t// Track all SGR codes to know styling state at afterStart\n\t\t\tpooledStyleTracker.process(ansi.code);\n\t\t\t// Include ANSI codes in their respective segments\n\t\t\tif (currentCol < beforeEnd) {\n\t\t\t\tpendingAnsiBefore += ansi.code;\n\t\t\t} else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) {\n\t\t\t\t// Only include after we've started \"after\" (styling already prepended)\n\t\t\t\tafter += ansi.code;\n\t\t\t}\n\t\t\ti += ansi.length;\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet textEnd = i;\n\t\twhile (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;\n\n\t\tfor (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) {\n\t\t\tconst w = graphemeWidth(segment);\n\n\t\t\tif (currentCol < beforeEnd && currentCol + w <= beforeEnd) {\n\t\t\t\tif (pendingAnsiBefore) {\n\t\t\t\t\tbefore += pendingAnsiBefore;\n\t\t\t\t\tpendingAnsiBefore = \"\";\n\t\t\t\t}\n\t\t\t\tbefore += segment;\n\t\t\t\tbeforeWidth += w;\n\t\t\t} else if (currentCol >= afterStart && currentCol < afterEnd) {\n\t\t\t\tconst fits = !strictAfter || currentCol + w <= afterEnd;\n\t\t\t\tif (fits) {\n\t\t\t\t\t// On first \"after\" grapheme, prepend inherited styling from before overlay\n\t\t\t\t\tif (!afterStarted) {\n\t\t\t\t\t\tafter += pooledStyleTracker.getActiveCodes();\n\t\t\t\t\t\tafterStarted = true;\n\t\t\t\t\t}\n\t\t\t\t\tafter += segment;\n\t\t\t\t\tafterWidth += w;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tcurrentCol += w;\n\t\t\t// Early exit: done with \"before\" only, or done with both segments\n\t\t\tif (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break;\n\t\t}\n\t\ti = textEnd;\n\t\tif (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break;\n\t}\n\n\treturn { before, beforeWidth, after, afterWidth };\n}\n"]}
package/dist/utils.js CHANGED
@@ -1,11 +1,18 @@
1
1
  import { eastAsianWidth } from "get-east-asian-width";
2
- // Grapheme segmenter (shared instance)
3
- const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
2
+ // segmenters (shared instance)
3
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
4
+ const wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" });
4
5
  /**
5
6
  * Get the shared grapheme segmenter instance.
6
7
  */
7
- export function getSegmenter() {
8
- return segmenter;
8
+ export function getGraphemeSegmenter() {
9
+ return graphemeSegmenter;
10
+ }
11
+ /**
12
+ * Get the shared word segmenter instance.
13
+ */
14
+ export function getWordSegmenter() {
15
+ return wordSegmenter;
9
16
  }
10
17
  /**
11
18
  * Check if a grapheme cluster (after segmentation) could possibly be an RGI emoji.
@@ -26,11 +33,20 @@ function couldBeEmoji(segment) {
26
33
  // Regexes for character classification (same as string-width library)
27
34
  const zeroWidthRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
28
35
  const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
36
+ const nonPrintingCharRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Format}|\p{Mark}|\p{Surrogate})$/v;
37
+ const markCharRegex = /^\p{Mark}$/v;
38
+ // Marks that terminals allocate cells for when attached to a base character.
39
+ // This includes Unicode spacing marks and non-spacing exceptions in legacy wcwidth tables.
40
+ const terminalSpacingMarkRegex = /^(?:[\p{Spacing_Mark}--[\u1734\u302E\u302F]]|[\u065F\u0F7F\u102B\u102C\u1031\u1033-\u1035\u1038\u103A-\u103E])+$/v;
29
41
  const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
30
42
  // Cache for non-ASCII strings
31
43
  const WIDTH_CACHE_SIZE = 512;
32
44
  const widthCache = new Map();
33
- const cjkBreakRegex = /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo}]/u;
45
+ export const cjkBreakRegex = /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo}]/u;
46
+ // CJK letters remain part of words and paths; only punctuation can separate prose from completions.
47
+ export const cjkPunctuationRegex = new RegExp(`(?:(?=\\p{Punctuation})${cjkBreakRegex.source}|[,.:;!?()[]{}“”‘’…—])`, "u");
48
+ export const autocompleteSeparatorRegex = new RegExp(`(?:\\s|${cjkPunctuationRegex.source})`, "u");
49
+ export const autocompleteBoundaryRegex = new RegExp(`(?:^|${autocompleteSeparatorRegex.source})`, "u");
34
50
  function isPrintableAscii(str) {
35
51
  for (let i = 0; i < str.length; i++) {
36
52
  const code = str.charCodeAt(i);
@@ -53,7 +69,7 @@ function truncateFragmentToWidth(text, maxWidth) {
53
69
  if (!hasAnsi && !hasTabs) {
54
70
  let result = "";
55
71
  let width = 0;
56
- for (const { segment } of segmenter.segment(text)) {
72
+ for (const { segment } of graphemeSegmenter.segment(text)) {
57
73
  const w = graphemeWidth(segment);
58
74
  if (width + w > maxWidth) {
59
75
  break;
@@ -95,7 +111,7 @@ function truncateFragmentToWidth(text, maxWidth) {
95
111
  }
96
112
  end++;
97
113
  }
98
- for (const { segment } of segmenter.segment(text.slice(i, end))) {
114
+ for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) {
99
115
  const w = graphemeWidth(segment);
100
116
  if (width + w > maxWidth) {
101
117
  return { text: result, width };
@@ -113,13 +129,14 @@ function truncateFragmentToWidth(text, maxWidth) {
113
129
  }
114
130
  function finalizeTruncatedResult(prefix, prefixWidth, ellipsis, ellipsisWidth, maxWidth, pad) {
115
131
  const reset = "\x1b[0m";
132
+ const hyperlinkClose = getActiveOsc8Close(prefix);
116
133
  const visibleWidth = prefixWidth + ellipsisWidth;
117
134
  let result;
118
135
  if (ellipsis.length > 0) {
119
- result = `${prefix}${reset}${ellipsis}${reset}`;
136
+ result = `${prefix}${hyperlinkClose}${reset}${ellipsis}${reset}`;
120
137
  }
121
138
  else {
122
- result = `${prefix}${reset}`;
139
+ result = `${prefix}${hyperlinkClose}${reset}`;
123
140
  }
124
141
  return pad ? result + " ".repeat(Math.max(0, maxWidth - visibleWidth)) : result;
125
142
  }
@@ -129,6 +146,10 @@ function finalizeTruncatedResult(prefix, prefixWidth, ellipsis, ellipsisWidth, m
129
146
  * check to avoid running the RGI_Emoji regex unnecessarily.
130
147
  */
131
148
  function graphemeWidth(segment) {
149
+ // Some marks occupy cells even without a base character.
150
+ if (terminalSpacingMarkRegex.test(segment)) {
151
+ return [...segment].length;
152
+ }
132
153
  // Zero-width clusters
133
154
  if (zeroWidthRegex.test(segment)) {
134
155
  return 0;
@@ -150,16 +171,30 @@ function graphemeWidth(segment) {
150
171
  return 2;
151
172
  }
152
173
  let width = eastAsianWidth(cp);
153
- // Trailing halfwidth/fullwidth forms and AM vowels that segment with a base.
154
- if (segment.length > 1) {
155
- for (const char of segment.slice(1)) {
174
+ // Intl.Segmenter can group multiple terminal-spacing code points into one
175
+ // grapheme. Count trailing visible code points that terminals may allocate
176
+ // cells for: Indic consonants after marks, halfwidth/fullwidth forms, and
177
+ // Thai/Lao AM vowels.
178
+ let followsMark = false;
179
+ const chars = [...base];
180
+ for (const char of chars.slice(1)) {
181
+ if (terminalSpacingMarkRegex.test(char)) {
182
+ width += 1;
183
+ followsMark = false;
184
+ }
185
+ else if (markCharRegex.test(char)) {
186
+ followsMark = true;
187
+ }
188
+ else if (!nonPrintingCharRegex.test(char)) {
156
189
  const c = char.codePointAt(0);
157
- if (c >= 0xff00 && c <= 0xffef) {
190
+ if (followsMark || (c >= 0xff00 && c <= 0xffef)) {
191
+ // halfwidth + fullwidth forms
158
192
  width += eastAsianWidth(c);
159
193
  }
160
194
  else if (c === 0x0e33 || c === 0x0eb3) {
161
195
  width += 1;
162
196
  }
197
+ followsMark = false;
163
198
  }
164
199
  }
165
200
  return width;
@@ -204,7 +239,7 @@ export function visibleWidth(str) {
204
239
  }
205
240
  // Calculate width
206
241
  let width = 0;
207
- for (const { segment } of segmenter.segment(clean)) {
242
+ for (const { segment } of graphemeSegmenter.segment(clean)) {
208
243
  width += graphemeWidth(segment);
209
244
  }
210
245
  // Cache result
@@ -221,14 +256,32 @@ export function visibleWidth(str) {
221
256
  * Normalize text for terminal output without changing logical editor content.
222
257
  * Some terminals render precomposed Thai/Lao AM vowels inconsistently during
223
258
  * differential repaint. Their compatibility decompositions have the same cell
224
- * width but avoid stale-cell artifacts in terminal renderers.
259
+ * width but avoid stale-cell artifacts in terminal renderers. Visible tabs are
260
+ * expanded to the fixed width used by layout so terminal tab stops cannot wrap
261
+ * a logical line, while tabs inside terminal string sequences stay untouched.
225
262
  */
226
263
  const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/;
227
264
  const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g;
228
265
  export function normalizeTerminalOutput(str) {
229
- if (!THAI_LAO_AM_REGEX.test(str))
230
- return str;
231
- return str.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) => (char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2"));
266
+ let normalized = str;
267
+ if (THAI_LAO_AM_REGEX.test(normalized)) {
268
+ normalized = normalized.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) => char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2");
269
+ }
270
+ if (!normalized.includes("\t"))
271
+ return normalized;
272
+ let result = "";
273
+ let i = 0;
274
+ while (i < normalized.length) {
275
+ const ansi = extractAnsiCode(normalized, i);
276
+ if (ansi) {
277
+ result += ansi.code;
278
+ i += ansi.length;
279
+ continue;
280
+ }
281
+ result += normalized[i] === "\t" ? " " : normalized[i];
282
+ i++;
283
+ }
284
+ return result;
232
285
  }
233
286
  /**
234
287
  * Extract ANSI escape sequences from a string at the given position.
@@ -297,6 +350,27 @@ function formatOsc8Hyperlink(hyperlink) {
297
350
  function formatOsc8Close(terminator) {
298
351
  return `\x1b]8;;${terminator}`;
299
352
  }
353
+ function getActiveOsc8Close(prefix) {
354
+ if (!prefix.includes("\x1b]8;")) {
355
+ return "";
356
+ }
357
+ let activeHyperlink = null;
358
+ let i = 0;
359
+ while (i < prefix.length) {
360
+ const ansi = extractAnsiCode(prefix, i);
361
+ if (ansi) {
362
+ const hyperlink = parseOsc8Hyperlink(ansi.code);
363
+ if (hyperlink !== undefined) {
364
+ activeHyperlink = hyperlink;
365
+ }
366
+ i += ansi.length;
367
+ }
368
+ else {
369
+ i++;
370
+ }
371
+ }
372
+ return activeHyperlink ? formatOsc8Close(activeHyperlink.terminator) : "";
373
+ }
300
374
  /**
301
375
  * Track active ANSI SGR codes to preserve styling across line breaks.
302
376
  */
@@ -561,7 +635,7 @@ function splitIntoTokensWithAnsi(text) {
561
635
  while (end < text.length && !extractAnsiCode(text, end)) {
562
636
  end++;
563
637
  }
564
- for (const { segment } of segmenter.segment(text.slice(i, end))) {
638
+ for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) {
565
639
  const segmentIsSpace = segment === " ";
566
640
  if (!segmentIsSpace && cjkBreakRegex.test(segment)) {
567
641
  flushCurrent();
@@ -599,6 +673,15 @@ function splitIntoTokensWithAnsi(text) {
599
673
  }
600
674
  return tokens;
601
675
  }
676
+ /**
677
+ * Flatten cached lines. V8 keeps a string built by concatenation as a tree of its parts until something reads it
678
+ * whole, and a cached line kept as such a tree retains several times its own size. Converting a string to a number
679
+ * reads it whole, so V8 flattens it in place; the strings' values do not change.
680
+ */
681
+ export function flattenLines(lines) {
682
+ for (const line of lines)
683
+ Number(line);
684
+ }
602
685
  /**
603
686
  * Wrap text with ANSI codes preserved.
604
687
  *
@@ -616,7 +699,7 @@ export function wrapTextWithAnsi(text, width) {
616
699
  }
617
700
  // Handle newlines by processing each line separately
618
701
  // Track ANSI state across lines so styles carry over after literal newlines
619
- const inputLines = text.split("\n");
702
+ const inputLines = text.split(/\r\n|\r|\n/);
620
703
  const result = [];
621
704
  const tracker = new AnsiCodeTracker();
622
705
  for (const inputLine of inputLines) {
@@ -697,7 +780,7 @@ function wrapSingleLine(line, width) {
697
780
  // Trailing whitespace can cause lines to exceed the requested width
698
781
  return wrapped.length > 0 ? wrapped.map((line) => line.trimEnd()) : [""];
699
782
  }
700
- const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
783
+ export const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
701
784
  /**
702
785
  * Check if a character is whitespace.
703
786
  */
@@ -735,7 +818,7 @@ function breakLongWord(word, width, tracker) {
735
818
  }
736
819
  // Segment this non-ANSI portion into graphemes
737
820
  const textPortion = word.slice(i, end);
738
- for (const seg of segmenter.segment(textPortion)) {
821
+ for (const seg of graphemeSegmenter.segment(textPortion)) {
739
822
  segments.push({ type: "grapheme", value: seg.segment });
740
823
  }
741
824
  i = end;
@@ -837,7 +920,7 @@ export function truncateToWidth(text, maxWidth, ellipsis = "...", pad = false) {
837
920
  const hasAnsi = text.includes("\x1b");
838
921
  const hasTabs = text.includes("\t");
839
922
  if (!hasAnsi && !hasTabs) {
840
- for (const { segment } of segmenter.segment(text)) {
923
+ for (const { segment } of graphemeSegmenter.segment(text)) {
841
924
  const width = graphemeWidth(segment);
842
925
  if (keepContiguousPrefix && keptWidth + width <= targetWidth) {
843
926
  result += segment;
@@ -892,7 +975,7 @@ export function truncateToWidth(text, maxWidth, ellipsis = "...", pad = false) {
892
975
  }
893
976
  end++;
894
977
  }
895
- for (const { segment } of segmenter.segment(text.slice(i, end))) {
978
+ for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) {
896
979
  const width = graphemeWidth(segment);
897
980
  if (keepContiguousPrefix && keptWidth + width <= targetWidth) {
898
981
  if (pendingAnsi) {
@@ -940,8 +1023,11 @@ export function sliceWithWidth(line, startCol, length, strict = false) {
940
1023
  while (i < line.length) {
941
1024
  const ansi = extractAnsiCode(line, i);
942
1025
  if (ansi) {
943
- if (currentCol >= startCol && currentCol < endCol)
944
- result += ansi.code;
1026
+ if (currentCol >= startCol && currentCol < endCol) {
1027
+ // Keep original order: codes from before the range must precede codes at the boundary
1028
+ result += pendingAnsi + ansi.code;
1029
+ pendingAnsi = "";
1030
+ }
945
1031
  else if (currentCol < startCol)
946
1032
  pendingAnsi += ansi.code;
947
1033
  i += ansi.length;
@@ -950,7 +1036,7 @@ export function sliceWithWidth(line, startCol, length, strict = false) {
950
1036
  let textEnd = i;
951
1037
  while (textEnd < line.length && !extractAnsiCode(line, textEnd))
952
1038
  textEnd++;
953
- for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
1039
+ for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) {
954
1040
  const w = graphemeWidth(segment);
955
1041
  const inRange = currentCol >= startCol && currentCol < endCol;
956
1042
  const fits = !strict || currentCol + w <= endCol;
@@ -1006,9 +1092,9 @@ export function extractSegments(line, beforeEnd, afterStart, afterLen, strictAft
1006
1092
  let textEnd = i;
1007
1093
  while (textEnd < line.length && !extractAnsiCode(line, textEnd))
1008
1094
  textEnd++;
1009
- for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
1095
+ for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) {
1010
1096
  const w = graphemeWidth(segment);
1011
- if (currentCol < beforeEnd) {
1097
+ if (currentCol < beforeEnd && currentCol + w <= beforeEnd) {
1012
1098
  if (pendingAnsiBefore) {
1013
1099
  before += pendingAnsiBefore;
1014
1100
  pendingAnsiBefore = "";