diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 66b432e..be28fb1 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -72,8 +72,8 @@ Only update the `Status` field — do not modify any other frontmatter or prompt ## Current State -Branch: `main` -In-progress: Nothing. PR #136 (fix #133, sticky zone ANSI width math) open, auto-merge enabled. +Branch: `refactor/adopt-string-width` +In-progress: Nothing. PR #139 (refactor #137, adopt string-width) open, auto-merge enabled. diff --git a/.claude/sessions/2026-03-28.md b/.claude/sessions/2026-03-28.md index 0683745..2e03939 100644 --- a/.claude/sessions/2026-03-28.md +++ b/.claude/sessions/2026-03-28.md @@ -7,3 +7,11 @@ - Decisions: Bundled leftover staged session-end files from the March 27 release session into the code commit since they had not been committed. CI and CodeQL checks in progress; auto-merge will complete when checks pass. - Next: Await PR #136 auto-merge. - Violations: None + +### 21:30 - refactor/adopt-string-width (#137) Stage 2 + +- Did: Staged and committed string-width adoption changes, pushed branch, created PR #139 with Closes #137, auto-merge enabled. +- Files: `package.json`, `pnpm-lock.yaml`, `src/StatusLineBuilder.ts`, `src/ansi.ts` (deleted), `src/renderer.ts`, `src/terminal.ts` +- Decisions: Label `enhancement` chosen as adoption of a library is an improvement, not a pure bug fix. +- Next: Await PR #139 auto-merge. +- Violations: None diff --git a/package.json b/package.json index fbc0bc8..164bd00 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@js-joda/core": "^5.7.0", "@shellicar/mcp-exec": "1.0.0-preview.6", "sharp": "^0.34.5", + "string-width": "^8.2.0", "zod": "^4.3.6" }, "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1226898..f1fcbac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: sharp: specifier: ^0.34.5 version: 0.34.5 + string-width: + specifier: ^8.2.0 + version: 8.2.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -1081,6 +1084,10 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1278,6 +1285,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1660,6 +1671,14 @@ packages: std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -2500,6 +2519,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@6.2.2: {} + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.0: @@ -2753,6 +2774,8 @@ snapshots: function-bind@1.1.2: {} + get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3189,6 +3212,15 @@ snapshots: std-env@4.0.0: {} + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-json-comments@5.0.3: {} supports-color@7.2.0: diff --git a/src/StatusLineBuilder.ts b/src/StatusLineBuilder.ts index b630ab5..9f4ef3a 100644 --- a/src/StatusLineBuilder.ts +++ b/src/StatusLineBuilder.ts @@ -1,18 +1,17 @@ +import stringWidth from 'string-width'; + export class StatusLineBuilder { public output = ''; - public visibleLength = 0; - /** Append text that is visible on screen (counts toward line width). */ + /** Append text that is visible on screen. */ public text(s: string): this { this.output += s; - this.visibleLength += s.length; return this; } - /** Append an emoji (2 terminal columns wide). */ + /** Append an emoji. */ public emoji(s: string): this { this.output += s; - this.visibleLength += 2; return this; } @@ -23,6 +22,6 @@ export class StatusLineBuilder { } public screenLines(columns: number): number { - return Math.max(1, Math.ceil(this.visibleLength / columns)); + return Math.max(1, Math.ceil(stringWidth(this.output) / columns)); } } diff --git a/src/ansi.ts b/src/ansi.ts deleted file mode 100644 index 1b54fe7..0000000 --- a/src/ansi.ts +++ /dev/null @@ -1,11 +0,0 @@ -// biome-ignore lint/suspicious/noControlCharactersInRegex: matching terminal escape sequences requires \x1b -const ANSI_PATTERN = /\x1b\[[0-9;]*m/g; - -/** - * Strips ANSI SGR escape codes and returns the visible character length. - * Handles colour, bold, inverse, and reset sequences only. - * Does not account for multi-width characters (emoji, CJK). - */ -export function stripAnsiLength(str: string): number { - return str.replace(ANSI_PATTERN, '').length; -} diff --git a/src/renderer.ts b/src/renderer.ts index f51ea63..18d18d9 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -3,7 +3,7 @@ * Computes what to display without writing to stdout. */ -import { stripAnsiLength } from './ansi.js'; +import stringWidth from 'string-width'; import type { EditorState } from './editor.js'; const CONTINUATION = ' '; @@ -24,11 +24,11 @@ export function prepareEditor(editor: EditorState, prompt: string): EditorRender } const cursorPrefix = editor.cursor.row === 0 ? prompt : CONTINUATION; - const cursorCol = stripAnsiLength(cursorPrefix) + editor.cursor.col; + const cursorCol = stringWidth(cursorPrefix) + editor.cursor.col; let cursorRow = 0; for (let i = 0; i < editor.cursor.row; i++) { - cursorRow += Math.max(1, Math.ceil(stripAnsiLength(lines[i]) / columns)); + cursorRow += Math.max(1, Math.ceil(stringWidth(lines[i]) / columns)); } cursorRow += Math.floor(cursorCol / columns); diff --git a/src/terminal.ts b/src/terminal.ts index 128c035..0493412 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -1,8 +1,8 @@ import { inspect } from 'node:util'; import { DateTimeFormatter, LocalTime } from '@js-joda/core'; +import stringWidth from 'string-width'; import type { AppState } from './AppState.js'; import type { AttachmentStore } from './AttachmentStore.js'; -import { stripAnsiLength } from './ansi.js'; import type { CommandMode } from './CommandMode.js'; import type { EditorState } from './editor.js'; import { type EditorRender, prepareEditor } from './renderer.js'; @@ -270,9 +270,7 @@ export class Terminal { output += '\n'; } output += clearLine + line; - // biome-ignore lint/suspicious/noControlCharactersInRegex: matching terminal escape sequences requires \x1b - const visibleLength = line.replace(/\x1b\[[0-9;]*m/g, '').length; - questionScreenLines += Math.max(1, Math.ceil(visibleLength / columns)); + questionScreenLines += Math.max(1, Math.ceil(stringWidth(line) / columns)); hasOutput = true; } @@ -313,7 +311,7 @@ export class Terminal { for (let i = 0; i < this.editorContent.lines.length; i++) { output += '\n'; output += clearLine + this.editorContent.lines[i]; - editorScreenLines += Math.max(1, Math.ceil(stripAnsiLength(this.editorContent.lines[i]) / columns)); + editorScreenLines += Math.max(1, Math.ceil(stringWidth(this.editorContent.lines[i]) / columns)); } // Clear any leftover lines from previous render @@ -322,7 +320,7 @@ export class Terminal { // Position cursor within editor this.cursorLinesFromBottom = 0; for (let i = this.editorContent.lines.length - 1; i > this.editorContent.cursorRow; i--) { - this.cursorLinesFromBottom += Math.max(1, Math.ceil(stripAnsiLength(this.editorContent.lines[i]) / columns)); + this.cursorLinesFromBottom += Math.max(1, Math.ceil(stringWidth(this.editorContent.lines[i]) / columns)); } if (this.cursorLinesFromBottom > 0) { output += cursorUp(this.cursorLinesFromBottom);