feat(tui): archive todos at turn end with incomplete hint

This commit is contained in:
Brooklyn Nicholson
2026-04-26 16:14:58 -05:00
parent 319c1c1691
commit c78b528125
12 changed files with 948 additions and 70 deletions

View File

@@ -0,0 +1,32 @@
// React Compiler runs as a post-pass over tsc's `dist/` output.
//
// tsc emits JSX as _jsx() calls (jsx: "react-jsx"). babel-plugin-react-compiler
// accepts that shape and auto-memoizes every component it recognizes via the
// default `infer` compilation mode (PascalCase components + use-prefixed
// hooks). The `sources` filter keeps it from walking node_modules files that
// end up in source maps.
//
// target=19 matches our react ^19.2.4 dependency.
module.exports = {
assumptions: {
setPublicClassFields: true
},
plugins: [
[
'babel-plugin-react-compiler',
{
target: '19',
sources: (filename) => {
if (!filename) return false
if (filename.includes('node_modules')) return false
return true
}
}
]
],
// We feed already-compiled JS into babel; don't re-parse as TS/JSX.
// @babel/preset-env etc. would over-transform — the compiler is our only
// transform here.
babelrc: false,
configFile: false
}

601
ui-tui/package-lock.json generated
View File

@@ -16,14 +16,19 @@
"unicode-animations": "^1.0.3"
},
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/core": "^7.29.0",
"@babel/plugin-syntax-jsx": "^7.28.6",
"@eslint/js": "^9",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@typescript-eslint/eslint-plugin": "^8",
"@typescript-eslint/parser": "^8",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9",
"eslint-plugin-perfectionist": "^5",
"eslint-plugin-react": "^7",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7",
"eslint-plugin-unused-imports": "^4",
"globals": "^16",
@@ -58,6 +63,36 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@babel/cli": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.28.6.tgz",
"integrity": "sha512-6EUNcuBbNkj08Oj4gAZ+BUU8yLCgKzgVX4gaTh09Ya2C8ICM4P+G30g4m3akRxSYAp3A/gnWchrNst7px4/nUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.28",
"commander": "^6.2.0",
"convert-source-map": "^2.0.0",
"fs-readdir-recursive": "^1.1.0",
"glob": "^7.2.0",
"make-dir": "^2.1.0",
"slash": "^2.0.0"
},
"bin": {
"babel": "bin/babel.js",
"babel-external-helpers": "bin/babel-external-helpers.js"
},
"engines": {
"node": ">=6.9.0"
},
"optionalDependencies": {
"@nicolo-ribaudo/chokidar-2": "2.1.8-no-fsevents.3",
"chokidar": "^3.6.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -141,6 +176,19 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-annotate-as-pure": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
"integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.3"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
@@ -168,6 +216,38 @@
"semver": "bin/semver.js"
}
},
"node_modules/@babel/helper-create-class-features-plugin": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz",
"integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-member-expression-to-functions": "^7.28.5",
"@babel/helper-optimise-call-expression": "^7.27.1",
"@babel/helper-replace-supers": "^7.28.6",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
"@babel/traverse": "^7.28.6",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
@@ -178,6 +258,20 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-member-expression-to-functions": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz",
"integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.5",
"@babel/types": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
@@ -210,6 +304,61 @@
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-optimise-call-expression": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz",
"integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-plugin-utils": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-replace-supers": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz",
"integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-member-expression-to-functions": "^7.28.5",
"@babel/helper-optimise-call-expression": "^7.27.1",
"@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-skip-transparent-expression-wrappers": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz",
"integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@@ -270,6 +419,40 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/plugin-proposal-private-methods": {
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz",
"integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==",
"deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead.",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-create-class-features-plugin": "^7.18.6",
"@babel/helper-plugin-utils": "^7.18.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-syntax-jsx": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
"integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -1156,6 +1339,14 @@
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@nicolo-ribaudo/chokidar-2": {
"version": "2.1.8-no-fsevents.3",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz",
"integrity": "sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/@oxc-project/types": {
"version": "0.124.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
@@ -1952,6 +2143,35 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"optional": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -2145,6 +2365,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/babel-plugin-react-compiler": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.26.0"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -2177,6 +2407,20 @@
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
@@ -2190,6 +2434,20 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
@@ -2332,6 +2590,46 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"optional": true,
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/cli-boxes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
@@ -2407,6 +2705,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
"integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2999,6 +3307,50 @@
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
}
},
"node_modules/eslint-plugin-react-compiler": {
"version": "19.1.0-rc.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.1.0-rc.2.tgz",
"integrity": "sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.24.4",
"@babel/parser": "^7.24.4",
"@babel/plugin-proposal-private-methods": "^7.18.6",
"hermes-parser": "^0.25.1",
"zod": "^3.22.4",
"zod-validation-error": "^3.0.3"
},
"engines": {
"node": "^14.17.0 || ^16.0.0 || >= 18.0.0"
},
"peerDependencies": {
"eslint": ">=7"
}
},
"node_modules/eslint-plugin-react-compiler/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/eslint-plugin-react-compiler/node_modules/zod-validation-error": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.5.4.tgz",
"integrity": "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"zod": "^3.24.4"
}
},
"node_modules/eslint-plugin-react-hooks": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
@@ -3309,6 +3661,20 @@
"node": ">=16.0.0"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -3363,6 +3729,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fs-readdir-recursive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz",
"integrity": "sha512-GNanXlVr2pf02+sPN40XN8HG+ePaNcvM0q5mZBd668Obwb0yD5GiUbZOFgwn8kGMY6I3mdyDJzieUy3PTYyTRA==",
"dev": true,
"license": "MIT"
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true,
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3521,6 +3901,28 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -3534,6 +3936,37 @@
"node": ">=10.13.0"
}
},
"node_modules/glob/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/globals": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz",
@@ -3736,6 +4169,25 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"dev": true,
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ink": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/ink/-/ink-6.8.0.tgz",
@@ -3919,6 +4371,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-boolean-object": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
@@ -4115,6 +4581,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-number-object": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
@@ -4745,6 +5222,30 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/make-dir": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
"integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^4.0.1",
"semver": "^5.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -4875,6 +5376,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -4994,6 +5506,16 @@
],
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
@@ -5109,6 +5631,16 @@
"node": ">=8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -5153,6 +5685,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -5271,6 +5813,34 @@
"react": "^19.2.0"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5652,6 +6222,16 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/slice-ansi": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz",
@@ -5990,6 +6570,20 @@
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -6607,6 +7201,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",

View File

@@ -24,14 +24,19 @@
"unicode-animations": "^1.0.3"
},
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/core": "^7.29.0",
"@babel/plugin-syntax-jsx": "^7.28.6",
"@eslint/js": "^9",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@typescript-eslint/eslint-plugin": "^8",
"@typescript-eslint/parser": "^8",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9",
"eslint-plugin-perfectionist": "^5",
"eslint-plugin-react": "^7",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7",
"eslint-plugin-unused-imports": "^4",
"globals": "^16",

View File

@@ -59,7 +59,7 @@ describe('createGatewayEventHandler', () => {
patchUiState({ showReasoning: true })
})
it('keeps todo list visible after final assistant text completes', () => {
it('archives incomplete todos into transcript flow at end of turn so they scroll up', () => {
const appended: Msg[] = []
const todos = [
@@ -76,8 +76,12 @@ describe('createGatewayEventHandler', () => {
onEvent({ payload: { text: 'Started a todo list.' }, type: 'message.complete' } as any)
expect(appended[appended.length - 1]).toMatchObject({ role: 'assistant', text: 'Started a todo list.' })
expect(getTurnState().todos).toEqual(todos)
const trail = appended.find(msg => msg.kind === 'trail' && msg.todos?.length)
const finalText = appended.find(msg => msg.role === 'assistant' && msg.text === 'Started a todo list.')
expect(finalText).toBeDefined()
expect(trail).toMatchObject({ kind: 'trail', role: 'system', todos, todoIncomplete: true })
expect(getTurnState().todos).toEqual([])
})
it('archives completed todos into transcript flow at end of turn', () => {

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import {
appendTurnSegment,
archiveDoneTodos,
archiveTodosAtTurnEnd,
getTurnState,
patchTurnState,
resetTurnState,
@@ -20,7 +21,7 @@ describe('turnStore live progress helpers', () => {
]
})
expect(archiveDoneTodos()).toEqual([
expect(archiveTodosAtTurnEnd()).toEqual([
{
kind: 'trail',
role: 'system',
@@ -34,11 +35,25 @@ describe('turnStore live progress helpers', () => {
expect(getTurnState().todos).toEqual([])
})
it('does not archive active todos', () => {
patchTurnState({ todos: [{ content: 'cook', id: 'cook', status: 'in_progress' }] })
it('archives incomplete todos with an incomplete flag so the hint renders', () => {
patchTurnState({
todos: [
{ content: 'cook', id: 'cook', status: 'completed' },
{ content: 'serve', id: 'serve', status: 'in_progress' },
{ content: 'eat', id: 'eat', status: 'pending' }
]
})
const archived = archiveTodosAtTurnEnd()
expect(archived).toHaveLength(1)
expect(archived[0]!.todoIncomplete).toBe(true)
expect(archived[0]!.todos?.map(t => t.id)).toEqual(['cook', 'serve', 'eat'])
expect(getTurnState().todos).toEqual([])
})
it('returns nothing when there are no todos at turn end', () => {
expect(archiveTodosAtTurnEnd()).toEqual([])
expect(archiveDoneTodos()).toEqual([])
expect(getTurnState().todos).toHaveLength(1)
})
it('tracks collapsed state independently of todo content', () => {

View File

@@ -11,7 +11,7 @@ import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
import type { GatewayEventHandlerContext } from './interfaces.js'
import { patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
import { archiveDoneTodos } from './turnStore.js'
import { archiveTodosAtTurnEnd } from './turnStore.js'
import { getUiState, patchUiState } from './uiStore.js'
const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i
@@ -539,7 +539,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
if (!wasInterrupted) {
const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }]
msgs.forEach(appendMessage)
archiveDoneTodos().forEach(appendMessage)
archiveTodosAtTurnEnd().forEach(appendMessage)
if (bellOnComplete && stdout?.isTTY) {
stdout.write('\x07')

View File

@@ -40,14 +40,22 @@ export const patchTurnState = (next: Partial<TurnState> | ((state: TurnState) =>
export const toggleTodoCollapsed = () => patchTurnState(state => ({ ...state, todoCollapsed: !state.todoCollapsed }))
export const archiveDoneTodos = () => {
export const archiveDoneTodos = () => archiveTodosAtTurnEnd()
export const archiveTodosAtTurnEnd = () => {
const state = $turnState.get()
if (!isTodoDone(state.todos)) {
if (!state.todos.length) {
return []
}
const msg: Msg = { kind: 'trail', role: 'system', text: '', todos: state.todos }
const msg: Msg = {
kind: 'trail',
role: 'system',
text: '',
todos: state.todos,
...(isTodoDone(state.todos) ? {} : { todoIncomplete: true })
}
patchTurnState({ todoCollapsed: false, todos: [] })

View File

@@ -37,7 +37,7 @@ export const MessageLine = memo(function MessageLine({
const thinking = msg.thinking?.trim() ?? ''
if (msg.kind === 'trail' && msg.todos?.length) {
return <TodoPanel t={t} todos={msg.todos} />
return <TodoPanel incomplete={msg.todoIncomplete} t={t} todos={msg.todos} />
}
if (msg.kind === 'trail' && (msg.tools?.length || tools.length || thinking)) {

View File

@@ -1,6 +1,7 @@
import { Box, Text } from '@hermes/ink'
import { memo } from 'react'
import { countPendingTodos } from '../lib/liveProgress.js'
import { todoGlyph, todoTone } from '../lib/todo.js'
import type { Theme } from '../theme.js'
import type { TodoItem } from '../types.js'
@@ -13,11 +14,13 @@ const rowColor = (t: Theme, status: TodoItem['status']) => {
export const TodoPanel = memo(function TodoPanel({
collapsed = false,
incomplete = false,
onToggle,
t,
todos
}: {
collapsed?: boolean
incomplete?: boolean
onToggle?: () => void
t: Theme
todos: TodoItem[]
@@ -27,6 +30,7 @@ export const TodoPanel = memo(function TodoPanel({
}
const done = todos.filter(todo => todo.status === 'completed').length
const pending = countPendingTodos(todos)
return (
<Box flexDirection="column" marginBottom={1}>
@@ -39,6 +43,12 @@ export const TodoPanel = memo(function TodoPanel({
<Text color={t.color.statusFg} dim>
({done}/{todos.length})
</Text>
{incomplete && pending > 0 && (
<Text color={t.color.dim} dim>
{' '}
· incomplete · {pending} still {pending === 1 ? 'pending' : 'pending/in_progress'}
</Text>
)}
</Text>
</Box>

View File

@@ -2,9 +2,9 @@ import type { ScrollBoxHandle } from '@hermes/ink'
import {
type RefObject,
useCallback,
useDeferredValue,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore
@@ -14,8 +14,43 @@ const ESTIMATE = 4
const OVERSCAN = 40
const MAX_MOUNTED = 260
const COLD_START = 40
// Floor on unmeasured row height used when computing coverage — guarantees
// the mounted span physically reaches the viewport bottom regardless of how
// small items actually are (at the cost of over-mounting when items are
// larger; overscan absorbs that).
const PESSIMISTIC = 1
// Tightest safe scrollTop bin for the useSyncExternalStore snapshot. Small
// wheel ticks that don't cross a bin short-circuit React's commit entirely;
// Ink keeps painting via ScrollBox.forceRender + direct scrollTop reads.
// Half of OVERSCAN keeps ≥20 rows of cushion before the mounted range
// would actually need to shift.
const QUANTUM = OVERSCAN >> 1
// Renders to keep the mount range frozen after width change (heights scaled
// but not yet re-measured). Render #1 skips measurement so pre-resize Yoga
// doesn't poison the scaled cache; render #2's useLayoutEffect captures
// post-resize heights; render #3 recomputes range with accurate data.
const FREEZE_RENDERS = 2
// Cap on NEW items mounted per commit when scrolling fast. Without this,
// a single PageUp into unmeasured territory mounts ~190 rows with
// PESSIMISTIC=1 coverage — each row running marked lexer + syntax
// highlighting for ~3ms = ~600ms sync block. Sliding toward the target
// over several commits keeps per-commit mount cost bounded.
const SLIDE_STEP = 25
const NOOP = () => {}
const upperBound = (arr: ArrayLike<number>, target: number) => {
let lo = 0
let hi = arr.length
while (lo < hi) {
const mid = (lo + hi) >> 1
arr[mid]! <= target ? (lo = mid + 1) : (hi = mid)
}
return lo
}
export const shouldSetVirtualClamp = ({
itemCount,
@@ -29,19 +64,6 @@ export const shouldSetVirtualClamp = ({
viewportHeight: number
}) => itemCount > 0 && viewportHeight > 0 && !sticky && !liveTailActive
const upperBound = (arr: number[], target: number) => {
let lo = 0
let hi = arr.length
while (lo < hi) {
const mid = (lo + hi) >> 1
arr[mid]! <= target ? (lo = mid + 1) : (hi = mid)
}
return lo
}
export function useVirtualHistory(
scrollRef: RefObject<ScrollBoxHandle | null>,
items: readonly { key: string }[],
@@ -57,15 +79,28 @@ export function useVirtualHistory(
const nodes = useRef(new Map<string, unknown>())
const heights = useRef(new Map<string, number>())
const refs = useRef(new Map<string, (el: unknown) => void>())
const [ver, setVer] = useState(0)
// Bump whenever heightCache mutates so offsets rebuild on next read.
// Ref (not state) — checked during render phase, zero extra commits.
const offsetVersion = useRef(0)
// Cached offsets: reused Float64Array keyed on (itemCount, version) so we
// only rebuild when something actually changed. Previous approach allocated
// a fresh Array(n+1) every render — at n=10k that's ~80KB/render of GC
// pressure during streaming.
const offsetsCache = useRef<{ arr: Float64Array; n: number; version: number }>({
arr: new Float64Array(0),
n: -1,
version: -1
})
const [hasScrollRef, setHasScrollRef] = useState(false)
const metrics = useRef({ sticky: true, top: 0, vp: 0 })
const lastScrollTopRef = useRef(0)
// Width change: scale cached heights (not clear — clearing forces a
// pessimistic back-walk mounting ~190 rows at once, each a fresh
// marked.lexer + syntax highlight ≈ 3ms). Freeze mount range for 2
// renders so warm memos survive; skip one measurement so useLayoutEffect
// doesn't poison the scaled cache with pre-resize Yoga heights.
// Width change: scale cached heights by oldCols/newCols instead of clearing
// (clearing forces a pessimistic back-walk mounting ~190 rows at once, each
// a fresh marked.lexer + syntax highlight ≈ 3ms). Freeze the mount range
// for 2 renders so warm memos survive; skip one measurement pass so
// useLayoutEffect doesn't poison the scaled cache with pre-resize Yoga
// heights.
const prevColumns = useRef(columns)
const skipMeasurement = useRef(false)
const prevRange = useRef<null | readonly [number, number]>(null)
@@ -80,6 +115,7 @@ export function useVirtualHistory(
heights.current.set(k, Math.max(1, Math.round(h * ratio)))
}
offsetVersion.current++
skipMeasurement.current = true
freezeRenders.current = FREEZE_RENDERS
}
@@ -88,11 +124,18 @@ export function useVirtualHistory(
setHasScrollRef(Boolean(scrollRef.current))
}, [scrollRef])
// Quantized snapshot: same-bin scrolls (most wheel ticks) produce the same
// number → React.Object.is short-circuits the commit entirely. sticky state
// is folded in via the sign bit so sticky→broken transitions also trigger.
// Uses the TARGET (committed + pendingDelta), not committed scrollTop, so
// scrollBy notifications immediately remount for the destination before
// Ink's drain frames need the children.
const subscribe = useCallback(
(cb: () => void) => (hasScrollRef ? scrollRef.current?.subscribe(cb) : null) ?? NOOP,
[hasScrollRef, scrollRef]
)
useSyncExternalStore(
useCallback(
(cb: () => void) => (hasScrollRef ? scrollRef.current?.subscribe(cb) : null) ?? (() => () => {}),
[hasScrollRef, scrollRef]
),
subscribe,
() => {
const s = scrollRef.current
@@ -100,9 +143,10 @@ export function useVirtualHistory(
return NaN
}
const b = Math.floor((s.getScrollTop() + s.getPendingDelta()) / QUANTUM)
const target = s.getScrollTop() + s.getPendingDelta()
const bin = Math.floor(target / QUANTUM)
return s.isSticky() ? -b - 1 : b
return s.isSticky() ? ~bin : bin
},
() => NaN
)
@@ -121,26 +165,33 @@ export function useVirtualHistory(
}
if (dirty) {
setVer(v => v + 1)
offsetVersion.current++
}
}, [items])
const offsets = useMemo(() => {
void ver
const out = new Array<number>(items.length + 1).fill(0)
// Offsets: Float64Array reused across renders, invalidated by offsetVersion
// bumps from heightCache writers (measureRef, resize-scale, GC). Binary
// search tolerates either monotone source, so no need to rebuild unless
// something changed.
const n = items.length
for (let i = 0; i < items.length; i++) {
out[i + 1] = out[i]! + Math.max(1, Math.floor(heights.current.get(items[i]!.key) ?? estimate))
if (offsetsCache.current.version !== offsetVersion.current || offsetsCache.current.n !== n) {
const arr = offsetsCache.current.arr.length >= n + 1 ? offsetsCache.current.arr : new Float64Array(n + 1)
arr[0] = 0
for (let i = 0; i < n; i++) {
arr[i + 1] = arr[i]! + Math.max(1, Math.floor(heights.current.get(items[i]!.key) ?? estimate))
}
return out
}, [estimate, items, ver])
offsetsCache.current = { arr, n, version: offsetVersion.current }
}
const n = items.length
const offsets = offsetsCache.current.arr
const total = offsets[n] ?? 0
const top = Math.max(0, scrollRef.current?.getScrollTop() ?? 0)
const pending = scrollRef.current?.getPendingDelta() ?? 0
const target = Math.max(0, top + pending)
const pendingDelta = scrollRef.current?.getPendingDelta() ?? 0
const target = Math.max(0, top + pendingDelta)
const vp = Math.max(0, scrollRef.current?.getViewportHeight() ?? 0)
const sticky = scrollRef.current?.isSticky() ?? true
const recentManual = Date.now() - (scrollRef.current?.getLastManualScrollAt() ?? 0) < 1200
@@ -168,9 +219,22 @@ export function useVirtualHistory(
start--
}
} else {
const lo = Math.max(0, Math.min(top, target) - overscan)
const hi = Math.max(top, target) + vp + overscan
// User scrolled up. Span [committed..target] so every drain frame is
// covered. Claude-code caps the span at 3×viewport so pendingDelta
// growing unbounded (MX Master free-spin) doesn't blow the mount
// budget; the clamp (setClampBounds) shows edge-of-mounted content
// during catch-up.
const MAX_SPAN = vp * 3
const rawLo = Math.min(top, target)
const rawHi = Math.max(top, target)
const span = rawHi - rawLo
const clampedLo = span > MAX_SPAN ? (pendingDelta < 0 ? rawHi - MAX_SPAN : rawLo) : rawLo
const clampedHi = clampedLo + Math.min(span, MAX_SPAN)
const lo = Math.max(0, clampedLo - overscan)
const hi = clampedHi + vp + overscan
// Binary search — offsets is monotone. Linear walk was O(n) at n=10k+,
// ~2ms per render during scroll.
start = Math.max(0, Math.min(n - 1, upperBound(offsets, lo) - 1))
end = Math.max(start + 1, Math.min(n, upperBound(offsets, hi)))
}
@@ -180,17 +244,144 @@ export function useVirtualHistory(
sticky ? (start = Math.max(0, end - maxMounted)) : (end = Math.min(n, start + maxMounted))
}
// Coverage guarantee: ensure sum(real or pessimistic heights) ≥
// viewportH + 2*overscan so the viewport is physically covered even when
// items are tiny. Pessimistic because uncached items use a floor of 1 —
// over-mounts when items are large, never leaves blank spacer showing.
if (n > 0 && vp > 0 && !frozenRange) {
const needed = vp + 2 * overscan
let coverage = 0
for (let i = start; i < end; i++) {
coverage += heights.current.get(items[i]!.key) ?? PESSIMISTIC
}
if (sticky) {
const minStart = Math.max(0, end - maxMounted)
while (start > minStart && coverage < needed) {
start--
coverage += heights.current.get(items[start]!.key) ?? PESSIMISTIC
}
} else {
const maxEnd = Math.min(n, start + maxMounted)
while (end < maxEnd && coverage < needed) {
coverage += heights.current.get(items[end]!.key) ?? PESSIMISTIC
end++
}
}
}
// Slide cap: limit how many NEW items mount this commit. Gates on scroll
// VELOCITY (|scrollTop delta since last commit| + |pendingDelta| >
// 2×viewport — key-repeat PageUp moves ~viewport/2 per press). Covers
// both scrollBy (pendingDelta) and scrollTo (direct write). Normal single
// PageUp skips this; the clamp holds the viewport at the mounted edge
// during catch-up so there's no blank screen. Only caps range GROWTH;
// shrinking is unbounded.
if (!frozenRange && prevRange.current && vp > 0) {
const velocity = Math.abs(top - lastScrollTopRef.current) + Math.abs(pendingDelta)
if (velocity > vp * 2) {
const [pS, pE] = prevRange.current
if (start < pS - SLIDE_STEP) {
start = pS - SLIDE_STEP
}
if (end > pE + SLIDE_STEP) {
end = pE + SLIDE_STEP
}
// A large jump past the capped end can invert (start > end); mount
// SLIDE_STEP items from the new start so the viewport isn't blank
// during catch-up.
if (start > end) {
end = Math.min(start + SLIDE_STEP, n)
}
}
}
lastScrollTopRef.current = top
if (freezeRenders.current > 0) {
freezeRenders.current--
} else {
prevRange.current = [start, end]
}
// Time-slice range growth via useDeferredValue. Urgent render keeps Ink
// painting with the OLD range (all memo hits, fast); deferred render
// transitions to the NEW range (fresh mounts: Md, syntax highlight) in a
// non-blocking background commit. The clamp (setClampBounds) pins the
// viewport to the mounted edge so there's no visual artifact from the
// deferred range lagging briefly. Only deferral range GROWTH — shrinking
// is cheap (unmount = remove fiber, no parse).
const dStart = useDeferredValue(start)
const dEnd = useDeferredValue(end)
let effStart = start < dStart ? dStart : start
let effEnd = end > dEnd ? dEnd : end
// Inverted range (large jump with deferred value lagging) or sticky snap
// (scrollToBottom needs the tail mounted NOW so maxScroll lands on content,
// not bottomSpacer) — skip deferral.
if (effStart > effEnd || sticky) {
effStart = start
effEnd = end
}
// Scrolling DOWN — bypass effEnd deferral so the tail mounts immediately.
// Without this, the clamp holds scrollTop short of the real bottom and
// the user feels "stuck before bottom". effStart stays deferred so scroll-
// UP keeps time-slicing (older messages parse on mount).
if (pendingDelta > 0) {
effEnd = end
}
// Final O(viewport) enforcement. Deferred+bypass combinations above can
// leak: during sustained PageUp, concurrent mode interleaves dStart updates
// with effEnd=end bypasses across commits and the effective window drifts
// wider than either bound alone. Trim the far edge by viewport position
// (not pendingDelta direction — that flips mid-settle under concurrent
// scheduling and yanks scrollTop).
if (effEnd - effStart > maxMounted && vp > 0) {
const mid = (offsets[effStart]! + offsets[effEnd]!) / 2
if (top < mid) {
effEnd = effStart + maxMounted
} else {
effStart = effEnd - maxMounted
}
}
const measureRef = useCallback((key: string) => {
let fn = refs.current.get(key)
if (!fn) {
fn = (el: unknown) => (el ? nodes.current.set(key, el) : nodes.current.delete(key))
fn = (el: unknown) => {
if (el) {
nodes.current.set(key, el)
return
}
// Measure-at-unmount: the yogaNode is still valid here (reconciler
// calls ref(null) before removeChild → freeRecursive), so we grab
// the final height before WASM release. Without this, items
// scrolled out during fast pan keep a stale estimate in heightCache
// and offset math drifts until the next mount/remount cycle.
const existing = nodes.current.get(key) as MeasuredNode | undefined
const h = Math.ceil(existing?.yogaNode?.getComputedHeight?.() ?? 0)
if (h > 0 && heights.current.get(key) !== h) {
heights.current.set(key, h)
offsetVersion.current++
}
nodes.current.delete(key)
}
refs.current.set(key, fn)
}
@@ -202,25 +393,33 @@ export function useVirtualHistory(
let dirty = false
// Give the renderer the mounted-row coverage for passive scroll clamping.
// Without this, burst wheel/page scroll can race past the React commit that
// updates the virtual range and paint spacer-only frames.
// Clamp MUST use the EFFECTIVE (deferred) range, not the immediate one.
// During fast scroll, immediate [start,end] may already cover the new
// scrollTop position, but children still render at the deferred range.
// If clamp used immediate bounds, render-node-to-output's drain-gate
// would drain past the deferred children's span → viewport lands in
// spacer → white flash.
if (s && shouldSetVirtualClamp({ itemCount: n, liveTailActive, sticky, viewportHeight: vp })) {
const min = offsets[start] ?? 0
const max = Math.max(min, (offsets[end] ?? total) - vp)
s.setClampBounds(min, max)
const effTopSpacer = offsets[effStart] ?? 0
const effBottom = offsets[effEnd] ?? total
// At effEnd=n there's no bottomSpacer — use Infinity so render-node-
// to-output's own Math.min(cur, maxScroll) governs. Using offsets[n]
// here would bake in heightCache (one render behind Yoga), and during
// streaming the tail item's cached height lags its real height —
// sticky-break would then clamp below the real max and push
// streaming text off-viewport.
const clampMin = effStart === 0 ? 0 : effTopSpacer
const clampMax = effEnd === n ? Infinity : Math.max(effTopSpacer, effBottom - vp)
s.setClampBounds(clampMin, clampMax)
} else {
// Sticky bottom often has live, non-virtualized tail content after the
// virtual transcript (streaming answer / thinking / tools). A clamp based
// only on virtual history would cap rendering before that tail and make
// live thinking appear to vanish. No burst-scroll clamp is needed while
// sticky anyway.
s?.setClampBounds(undefined, undefined)
}
if (skipMeasurement.current) {
skipMeasurement.current = false
} else {
for (let i = start; i < end; i++) {
for (let i = effStart; i < effEnd; i++) {
const k = items[i]?.key
if (!k) {
@@ -254,17 +453,17 @@ export function useVirtualHistory(
}
if (dirty) {
setVer(v => v + 1)
offsetVersion.current++
}
}, [end, hasScrollRef, items, liveTailActive, n, offsets, recentManual, scrollRef, start, sticky, total, vp])
})
return {
bottomSpacer: Math.max(0, total - (offsets[end] ?? total)),
end,
bottomSpacer: Math.max(0, total - (offsets[effEnd] ?? total)),
end: effEnd,
measureRef,
offsets,
start,
topSpacer: offsets[start] ?? 0
start: effStart,
topSpacer: offsets[effStart] ?? 0
}
}

View File

@@ -1,5 +1,8 @@
import type { Msg, TodoItem } from '../types.js'
export const countPendingTodos = (todos: readonly TodoItem[]) =>
todos.filter(todo => todo.status === 'in_progress' || todo.status === 'pending').length
export const isTodoDone = (todos: readonly TodoItem[]) =>
todos.length > 0 && todos.every(todo => todo.status === 'completed' || todo.status === 'cancelled')

View File

@@ -117,6 +117,7 @@ export interface Msg {
toolTokens?: number
tools?: string[]
todos?: TodoItem[]
todoIncomplete?: boolean
}
export type Role = 'assistant' | 'system' | 'tool' | 'user'