diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json new file mode 100644 index 000000000..56ec79d27 --- /dev/null +++ b/ui-tui/package-lock.json @@ -0,0 +1,879 @@ +{ + "name": "clawcodex-tui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "clawcodex-tui", + "version": "0.1.0", + "dependencies": { + "cli-highlight": "^2.1.11", + "ink": "^5.0.1", + "ink-text-input": "^6.0.0", + "react": "^18.3.1", + "wrap-ansi": "^9.0.2", + "ws": "^8.18.0" + }, + "bin": { + "clawcodex-tui": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.5.0", + "@types/react": "^18.3.5", + "@types/ws": "^8.5.12", + "typescript": "^5.6.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.1.3", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/@types/node": { + "version": "22.20.0", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "license": "MIT" + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "license": "ISC", + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-highlight/node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/cliui/node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "devOptional": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "license": "MIT" + }, + "node_modules/environment": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-toolkit": { + "version": "1.48.1", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "5.2.1", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.3", + "ansi-escapes": "^7.0.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^4.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.22.0", + "indent-string": "^5.0.0", + "is-in-ci": "^1.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.29.0", + "scheduler": "^0.23.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^7.1.0", + "stack-utils": "^2.0.6", + "string-width": "^7.2.0", + "type-fest": "^4.27.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0", + "react-devtools-core": "^4.19.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-text-input": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5", + "react": ">=18" + } + }, + "node_modules/ink-text-input/node_modules/chalk": { + "version": "5.6.2", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ink/node_modules/chalk": { + "version": "5.6.2", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ci": { + "version": "1.0.0", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "5.1.1", + "license": "MIT" + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "license": "MIT" + }, + "node_modules/patch-console": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "7.1.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/widest-line": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "16.2.2", + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width/node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "license": "MIT" + } + } +} diff --git a/ui-tui/package.json b/ui-tui/package.json index 6d704798d..e194a9a62 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -18,6 +18,7 @@ "ink": "^5.0.1", "ink-text-input": "^6.0.0", "react": "^18.3.1", + "wrap-ansi": "^9.0.2", "ws": "^8.18.0" }, "devDependencies": { diff --git a/ui-tui/src/components/DiffView.tsx b/ui-tui/src/components/DiffView.tsx index fcab15940..163aeeb83 100644 --- a/ui-tui/src/components/DiffView.tsx +++ b/ui-tui/src/components/DiffView.tsx @@ -1,37 +1,81 @@ /** - * Renders a line diff (from diff.ts) the way the original StructuredDiff does: - * a NON-colored line-number gutter column + a colored content column (green - * added / red removed / dim context). Long lines WRAP — each wrapped row keeps - * the marker and the background color, and the line number shows only on the - * first row — instead of being truncated off the right edge. Topped with an - * "⎿ Added N, removed M lines" summary. + * Renders a line diff (from diff.ts) the way the original Claude Code does: * - * Line numbers are relative to the edited region: the tool input gives us - * old_string/new_string, not absolute file positions. + * - a NON-colored, right-aligned line-number gutter; + * - the code is SYNTAX-HIGHLIGHTED (via cli-highlight) on top of the add/del + * tint — the background is baked into the ANSI and re-applied after every + * syntax reset, so a `\x1b[0m` mid-line doesn't punch a hole in the tint; + * - the tint extends only to the LONGEST line in the hunk (capped at the + * terminal width), not the full terminal — short lines get a tight block, + * not a full-width bar; + * - long lines wrap (ANSI-aware) keeping the marker + tint on each row; + * - context lines (unchanged) render highlighted with no tint. + * + * Topped with an "⎿ Added N, removed M lines" summary. */ +import { highlight } from 'cli-highlight' import { Box, Text } from 'ink' import React from 'react' +import wrapAnsi from 'wrap-ansi' import { theme } from '../theme.js' import type { DiffLine } from '../diff.js' const MAX_LINES = 80 +const RESET = '\x1b[0m' +const ADD_BG: [number, number, number] = [34, 92, 43] +const DEL_BG: [number, number, number] = [122, 41, 54] + +/** Visible width (ANSI codes don't count). */ +const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '') + +const LANG: Record = { + ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript', + js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript', + py: 'python', rs: 'rust', go: 'go', rb: 'ruby', java: 'java', kt: 'kotlin', + c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', cs: 'csharp', swift: 'swift', + php: 'php', json: 'json', yml: 'yaml', yaml: 'yaml', toml: 'ini', ini: 'ini', + sh: 'bash', bash: 'bash', zsh: 'bash', md: 'markdown', css: 'css', scss: 'scss', + html: 'xml', xml: 'xml', sql: 'sql', lua: 'lua', dockerfile: 'dockerfile', +} +function langOf(path?: string): string | undefined { + const ext = (path || '').split(/[./\\]/).pop()?.toLowerCase() + return ext ? LANG[ext] : undefined +} +function hl(code: string, lang?: string): string { + if (!code) return '' + try { + return highlight(code, { language: lang, ignoreIllegals: true }) + } catch { + return code + } +} -/** Hard-wrap into <=width segments (code wraps mid-token, like the original). */ -function wrapSegs(s: string, width: number): string[] { - if (width < 1 || s.length <= width) return [s || ' '] - const out: string[] = [] - for (let i = 0; i < s.length; i += width) out.push(s.slice(i, i + width)) - return out +/** + * Build one rendered row: marker + highlighted code, padded to `width`, with the + * background re-applied after every syntax reset so the tint spans the full row. + */ +function bgRow(ansi: string, bg: [number, number, number] | null, marker: string, width: number): string { + const pad = ' '.repeat(Math.max(0, width - 1 - stripAnsi(ansi).length)) // -1 for the marker + if (!bg) return `${marker}${ansi}${pad}` + const BG = `\x1b[48;2;${bg[0]};${bg[1]};${bg[2]}m` + const body = ansi.replace(/\x1b\[0m/g, RESET + BG) // keep the tint past each reset + return `${BG}${marker}${body}${pad}${RESET}` } -export function DiffView({ lines }: { lines: DiffLine[] }): React.ReactElement { +export function DiffView({ lines, filePath }: { lines: DiffLine[]; filePath?: string }): React.ReactElement { const shown = lines.slice(0, MAX_LINES) const extra = lines.length - shown.length + const lang = langOf(filePath) const numW = Math.max(1, ...lines.map((l) => String(l.oldNo ?? l.newNo ?? 0).length)) const cols = process.stdout.columns ?? 80 - const gutterW = numW + 1 // right-aligned line number + a single space - const contentW = Math.max(8, cols - gutterW - 1) // content column fills the rest of the row - const segW = Math.max(4, contentW - 1) // room for the 1-char marker (no space — it hugs the code) + const indent = 2 // sit under the "⎿" summary + const gutterW = indent + numW + 1 // indent + right-aligned number + a space + const contentMax = Math.max(8, cols - gutterW - 1) + // Tint width = the longest line in the hunk (marker + code), capped at the + // terminal — a tight block for short hunks instead of a full-width bar. + const longest = Math.max(2, ...shown.map((l) => 1 + (l.text?.length ?? 0))) + const blockW = Math.min(contentMax, longest) + const segW = Math.max(4, blockW) // wrap target (marker is counted in bgRow) const added = lines.filter((l) => l.type === 'add').length const removed = lines.filter((l) => l.type === 'del').length @@ -43,19 +87,20 @@ export function DiffView({ lines }: { lines: DiffLine[] }): React.ReactElement { {shown.map((l, i) => { const no = l.type === 'del' ? l.oldNo : l.newNo const marker = l.type === 'add' ? '+' : l.type === 'del' ? '-' : ' ' - const bg = l.type === 'add' ? theme.diffAddBg : l.type === 'del' ? theme.diffDelBg : undefined - const fg = l.type === 'ctx' ? theme.dim : undefined - const segs = wrapSegs(l.text, segW) + const bg = l.type === 'add' ? ADD_BG : l.type === 'del' ? DEL_BG : null + const ansi = hl(l.text ?? '', lang) + const rows = + stripAnsi(ansi).length <= segW - 1 + ? [ansi] + : wrapAnsi(ansi, segW - 1, { hard: true, trim: false }).split('\n') return ( - {String(no ?? '').padStart(numW)} + {` ${String(no ?? '').padStart(numW)} `} - {segs.map((seg, j) => ( - - {`${marker}${seg}`.padEnd(contentW)} - + {rows.map((row, j) => ( + {bgRow(row, bg, j === 0 ? marker : ' ', blockW)} ))} diff --git a/ui-tui/src/components/Message.tsx b/ui-tui/src/components/Message.tsx index d4cbdb16b..c16471e52 100644 --- a/ui-tui/src/components/Message.tsx +++ b/ui-tui/src/components/Message.tsx @@ -92,7 +92,14 @@ export function Message({ entry }: { entry: TranscriptEntry }): React.ReactEleme {entry.argsText} ) - {diff ? : null} + {diff ? ( + + ) : null} ) } diff --git a/ui-tui/src/diff.ts b/ui-tui/src/diff.ts index a272f0332..2ff6627b1 100644 --- a/ui-tui/src/diff.ts +++ b/ui-tui/src/diff.ts @@ -47,6 +47,38 @@ function startLineOf(fileContent: string | undefined, oldS: string, newS: string return line } +/** Unchanged file lines shown above/below the change, for orientation. */ +const CONTEXT_LINES = 3 + +/** + * Surround the changed region with a few unchanged file lines (the original + * shows ~3 above/below). Uses the on-disk file and the located start line; if + * the file isn't available or the region can't be found, returns the diff as-is. + */ +function withFileContext( + core: DiffLine[], + fileContent: string | undefined, + oldS: string, + newS: string, + start: number, +): DiffLine[] { + if (!fileContent) return core + const fileLines = fileContent.split('\n') + const probe = oldS && fileContent.includes(oldS) ? oldS : newS && fileContent.includes(newS) ? newS : null + if (!probe) return core + const span = probe.split('\n').length + const before: DiffLine[] = [] + for (let i = Math.max(0, start - 1 - CONTEXT_LINES); i < start - 1; i++) { + before.push({ type: 'ctx', text: fileLines[i] ?? '', oldNo: i + 1, newNo: i + 1 }) + } + const afterStart = start - 1 + span // 0-based index just past the changed region + const after: DiffLine[] = [] + for (let i = afterStart; i < Math.min(fileLines.length, afterStart + CONTEXT_LINES); i++) { + after.push({ type: 'ctx', text: fileLines[i] ?? '', oldNo: i + 1, newNo: i + 1 }) + } + return [...before, ...core, ...after] +} + /** * Build a diff for an Edit/Write/MultiEdit tool call from its raw input. * `fileContent` (the on-disk file) lets us emit true file line numbers; without @@ -66,7 +98,8 @@ export function toolDiff( const oldS = typeof input['old_string'] === 'string' ? (input['old_string'] as string) : '' const newS = typeof input['new_string'] === 'string' ? (input['new_string'] as string) : '' if (!oldS && !newS) return null - return lineDiff(oldS, newS, startLineOf(fileContent, oldS, newS)) + const start = startLineOf(fileContent, oldS, newS) + return withFileContext(lineDiff(oldS, newS, start), fileContent, oldS, newS, start) } if (toolName === 'MultiEdit') { const edits = Array.isArray(input['edits']) ? (input['edits'] as Record[]) : []