From e7d809d5899db9f192ec48b9ed1f555558dfb6bf Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:39:25 +0800 Subject: [PATCH 01/15] fix(windows): clarify source checkout requirements (#303) * fix(windows): clarify source checkout requirements Skip only WSL conversion fixtures on native Windows and add an explicit local-NTFS preflight to the source-build instructions. Cover localized fsutil output and fail-closed diagnostics without changing production path validation. * ci: add focused Windows source contract Run a Windows Node 24 contract on non-documentation pull requests while keeping the full capability suite on Linux and macOS. Share the gate through the verifier and Vitest suite manifest, keep the compatibility matrix macOS/Linux, and document the Windows-only boundary. --- .github/workflows/ci.yml | 7 +- CONTRIBUTING.md | 4 +- README.md | 4 +- README_ZH.md | 4 +- docs/installation.md | 3 + docs/releasing.md | 2 +- package.json | 1 + .../test/unit/wsl-attachment-paths.test.ts | 14 ++- release/public-source.json | 2 + scripts/check-windows-source-location.mjs | 77 ++++++++++++ scripts/verify.mjs | 19 +-- test/source-sync.test.mjs | 115 +++++++++++++++++- test/vitest-suites.json | 3 + test/windows-contract.test.mjs | 19 +++ 14 files changed, 248 insertions(+), 26 deletions(-) create mode 100644 scripts/check-windows-source-location.mjs create mode 100644 test/windows-contract.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7b38390..9ac79b8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,13 +55,16 @@ jobs: strategy: fail-fast: false matrix: - # Windows validation is temporarily paused until its checks are reliable. - os: [ubuntu-latest, macos-latest] + # Windows runs a focused contract; full capability coverage remains on Linux/macOS. + os: [ubuntu-latest, macos-latest, windows-latest] node: ["24"] include: - os: ubuntu-latest node: "24" profile: full + - os: windows-latest + node: "24" + profile: windows runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2505d6e4..90e07f37 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,13 +37,13 @@ pnpm install --frozen-lockfile pnpm verify ``` -`pnpm verify` runs the complete gate list in the same order as GitHub CI. Normal PR and main-branch checks use Node.js 24 on Linux and macOS. Windows CI is temporarily paused while its checks are made reliable. The Linux job runs this full profile; the macOS job uses `pnpm verify --profile platform`, which omits only the duplicate TypeScript compiler check. Both jobs still export source, build, and test their own platform artifacts. Gates that depend on platform behaviour are selected by platform rather than skipped silently; run `pnpm verify --list` or `pnpm verify --profile platform --list` to inspect either plan. Individual gates remain available as their own scripts, such as `pnpm typecheck` or `pnpm test:byok`, while you iterate. +`pnpm verify` runs the complete gate list in the same order as GitHub CI. Normal PR and main-branch checks use Node.js 24 on Linux and macOS. The Linux job runs the full profile; the macOS job uses `pnpm verify --profile platform`, which omits only the duplicate TypeScript compiler check. Windows runs the focused `pnpm verify --profile windows` contract on PRs; the profile is Windows-only and fails closed elsewhere. It checks source inventory, release tooling, build boundaries, artifacts, and Windows-specific tests without running the full capability suite. Gates that depend on platform behaviour are selected by platform rather than skipped silently; run `pnpm verify --list`, `pnpm verify --profile platform --list`, or `pnpm verify --profile windows --list` to inspect each plan. Individual gates remain available as their own scripts, such as `pnpm typecheck` or `pnpm test:byok`, while you iterate. CI writes per-gate timing and exit metadata to the Job Summary and a seven-day `verification--node--` artifact. For a local report, set `MCODE_VERIFY_REPORT_DIR` to a directory outside the repository. Reports distinguish `PASS`, `FAIL`, intentional `SKIP`, and `NOT_RUN` after a failure. JSON is checkpointed before and after each gate; a cancelled run may leave `RUNNING`, which is not a pass. If installation fails before verification starts, no verification report is available. Reports do not collect command output, environment variables, or runtime data; read the corresponding gate's job log for failure details, including the existing bounded BYOK timeout diagnostics. CI jobs have a 15-minute verification limit and a 10-minute release-audit limit. Existing README files, `CONTRIBUTING.md`, `.github/PULL_REQUEST_TEMPLATE.md`, Markdown under `docs/`, and media directly under `docs/assets/` use the `docs` profile when they are the only changed paths. That profile checks the source inventory and generated paths, exports the committed source, and tests release tooling. History and source-snapshot secret scans still run; platform builds and distribution scans are skipped. Mixed changes, unknown paths, missing comparisons, and any `release/` inventory change get full CI. Documentation-only changes skip the platform matrix entirely. The `verification` aggregate check always runs and rejects failed, cancelled, or unexpectedly skipped jobs. Use it together with `source-history-artifact` as required checks when configuring branch protection; this repository's automation does not change administrative settings. -`Node compatibility` runs weekly and on demand against macOS and Linux with Node 22.19.0, 24.2.0, 25 and 26. Windows is also temporarily excluded from this matrix. It does not run automatically on PRs. Dispatch it on the selected branch for changes to supported Node versions, native dependencies or compatibility-sensitive verification tooling, and before a source release. This covers the minimum versions of the two supported ranges and the additional supported majors. Deferring those versions from ordinary PR checks can delay regression discovery; a known failure in a supported version still needs resolution before release. Dependabot proposes weekly Actions and npm updates, grouping Actions and development-tool minor/patch updates. External Actions use reviewed full commit SHAs, while local actions and reusable workflows come from the same checked-out revision. +`Node compatibility` runs weekly and on demand against macOS and Linux with Node 22.19.0, 24.2.0, 25 and 26. Windows remains excluded from this matrix; the PR Windows contract is a focused Node 24 check, not a substitute for the full compatibility matrix. It does not run automatically on PRs. Dispatch it on the selected branch for changes to supported Node versions, native dependencies or compatibility-sensitive verification tooling, and before a source release. This covers the minimum versions of the two supported ranges and the additional supported majors. Deferring those versions from ordinary PR checks can delay regression discovery; a known failure in a supported version still needs resolution before release. Dependabot proposes weekly Actions and npm updates, grouping Actions and development-tool minor/patch updates. External Actions use reviewed full commit SHAs, while local actions and reusable workflows come from the same checked-out revision. Source candidates are requested independently through the `Source candidate` workflow; ordinary PRs and main pushes do not produce them. Its Linux/macOS archive validation is described in [Releasing](docs/releasing.md). `CLI release` builds npm-installable tar.gz packages from version tags and validates the same archive before attaching it to a GitHub Release. Its `package` verification profile tests installation of an existing archive; it does not replace full source verification. npm registry and official installer publication remain separate release processes. diff --git a/README.md b/README.md index c32c5021..df7353d5 100644 --- a/README.md +++ b/README.md @@ -221,11 +221,11 @@ The [small, reproducible project](examples/clamp) is the same task used in the d ## Build from source -To develop MCode or run this source checkout, you need Git, **Node.js 22.19+ (22.x), 24.2+ (24.x), 25, or 26**, and **pnpm 9.12.0**. - +To develop MCode or run this source checkout, you need Git, **Node.js 22.19+ (22.x), 24.2+ (24.x), 25, or 26**, and **pnpm 9.12.0**. On Windows, keep the checkout on a local NTFS volume and outside cloud-synced folders; the preflight command below checks the volume before pnpm creates workspace links. ```bash git clone https://github.com/MiniMax-AI/minimax-code.git cd minimax-code +node scripts/check-windows-source-location.mjs pnpm install --frozen-lockfile pnpm build pnpm mcode diff --git a/README_ZH.md b/README_ZH.md index 64459aa6..5800a879 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -221,11 +221,11 @@ profile 使用 `~/.minimax-`;`MINIMAX_DATA_DIR` 或 `MAVIS_DATA_DIR` ## 从源码构建 -开发 MCode 或运行本仓库源码需要 Git、Node.js **22.19+(22 系列)、24.2+(24 系列)、25 或 26**,以及 **pnpm 9.12.0**。 - +开发 MCode 或运行本仓库源码需要 Git、Node.js **22.19+(22 系列)、24.2+(24 系列)、25 或 26**,以及 **pnpm 9.12.0**。在 Windows 上,请将源码放在本地 NTFS 卷上,并避开云同步目录;下面的预检命令会在 pnpm 创建 workspace link 前检查卷类型。 ```bash git clone https://github.com/MiniMax-AI/minimax-code.git cd minimax-code +node scripts/check-windows-source-location.mjs pnpm install --frozen-lockfile pnpm build pnpm mcode diff --git a/docs/installation.md b/docs/installation.md index 9939ccca..0b8c5267 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -41,6 +41,8 @@ This guide builds the 0.4.12 source preview. Workspace/local build manifests rem For a source build, you need Git, Node.js 22.19+ (22.x), 24.2+ (24.x), 25, or 26, and pnpm 9.12.0. Regular CI uses Node.js 24 across Linux and macOS. The weekly and manual compatibility matrix covers Node.js 22.19.0, 24.2.0, 25, and 26 on both platforms. Windows CI and source-candidate validation are temporarily paused while their checks are made reliable. Initial installation and build require access to public npm. +On Windows, check out this repository on a local NTFS volume before running `pnpm install`. The repository uses pnpm workspace links for vendored packages, and those links require NTFS junctions. FAT32/exFAT volumes, network shares, and other non-local Windows volumes cannot create the required junctions. The preflight command below verifies the volume and stops with a clear message before pnpm creates workspace links; run it immediately before `pnpm install`. A local NTFS volume can still contain a cloud-synced folder, which the preflight cannot identify reliably; keep the checkout outside OneDrive, Google Drive, Dropbox, and similar synced folders. + Node 24.0 and 24.1 are unsupported: their bundled libuv can return inconsistent Windows file identity metadata, causing safe configuration reads to fail. [Node 24.2.0](https://nodejs.org/en/blog/release/v24.2.0) includes libuv 1.51.0 with the [upstream fix](https://github.com/libuv/libuv/commit/82cdfb75f). Use a current patch release of a supported Node line. ```bash @@ -48,6 +50,7 @@ git clone https://github.com/MiniMax-AI/minimax-code.git cd minimax-code corepack enable corepack prepare pnpm@9.12.0 --activate +node scripts/check-windows-source-location.mjs pnpm install --frozen-lockfile pnpm build pnpm mcode --help diff --git a/docs/releasing.md b/docs/releasing.md index 79f274aa..0d95eaed 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -87,7 +87,7 @@ Before npm or installer distribution, separately validate the published package The `Source candidate` workflow exports the selected commit without Git history, verifies its receipt, and scans both repository history and the extracted source. Linux and macOS runners authenticate the same archive, install from public npm into fresh stores, and run the archive verification profile. -Windows validation is temporarily paused across source verification, Node compatibility, and source candidates. Candidate reports cover only Linux and macOS; a successful candidate does not establish Windows acceptance. Restore the Windows workflow matrices and the required report set in `scripts/source-candidate.mjs` together when Windows checks are reliable again. +Windows full validation is temporarily paused for Node compatibility and source candidates. Ordinary pull requests run a focused Windows source-verification contract, but candidate reports still cover only Linux and macOS; a successful candidate does not establish Windows acceptance. Restore the Windows compatibility/source-candidate matrices and the required report set in `scripts/source-candidate.mjs` together when those checks are reliable again. After both platform jobs pass, the workflow creates a `source-candidate-` artifact containing: diff --git a/package.json b/package.json index 1950b96c..b231e65f 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test:status-contract": "node scripts/run-vitest-suite.mjs status-contract", "check:source": "node scripts/source-inventory.mjs", "test:capabilities": "node scripts/run-vitest-suite.mjs capability", + "test:windows": "node scripts/run-vitest-suite.mjs windows", "test:artifact": "node --test test/public-artifact.test.mjs", "test:release-tools": "node --test test/source-sync.test.mjs" }, diff --git a/packages/tui/test/unit/wsl-attachment-paths.test.ts b/packages/tui/test/unit/wsl-attachment-paths.test.ts index 48d2d20c..315b7d49 100644 --- a/packages/tui/test/unit/wsl-attachment-paths.test.ts +++ b/packages/tui/test/unit/wsl-attachment-paths.test.ts @@ -29,6 +29,7 @@ vi.mock("node:child_process", async (importOriginal) => ({ let workspaceDir: string; let imagePath: string; const windowsPath = String.raw`D:\Users\demo\Documents\Screen shots\截图.png`; +const skipWslConversion = process.platform === "win32"; beforeEach(async () => { host.platform.mockReturnValue("linux"); @@ -48,8 +49,11 @@ afterEach(async () => { await rm(workspaceDir, { recursive: true, force: true }); }); +// The mocked wslpath output is the host temp path. Native Windows correctly +// rejects that as a Linux absolute path, so only WSL conversion cases skip there; +// native path handling remains covered. describe("WSL attachment paths", () => { - it.each([ + it.skipIf(process.platform === "win32").each([ windowsPath, `"${windowsPath}"`, `'${windowsPath}'`, @@ -91,7 +95,7 @@ describe("WSL attachment paths", () => { }, ); - it("resolves headless --file through the same conversion before realpath", async () => { + it.skipIf(skipWslConversion)("resolves headless --file through the same conversion before realpath", async () => { const invocation = await resolveTuiExecInvocation( "describe", { cwd: workspaceDir, file: [windowsPath] }, @@ -109,7 +113,7 @@ describe("WSL attachment paths", () => { expect(host.executeFile).toHaveBeenCalledOnce(); }); - it.each(["WSL_DISTRO_NAME", "WSL_INTEROP", "WSLENV"])( + it.skipIf(skipWslConversion).each(["WSL_DISTRO_NAME", "WSL_INTEROP", "WSLENV"])( "detects WSL via %s even without a Microsoft kernel name", async (name) => { host.release.mockReturnValue("custom-kernel"); @@ -156,7 +160,7 @@ describe("WSL attachment paths", () => { expect(host.executeFile).not.toHaveBeenCalled(); }); - it.each([ + it.skipIf(skipWslConversion).each([ String.raw`d:\Screen shots\$(touch marker);'截图'.png`, String.raw`\\server\share\截图.png`, ])("passes Windows paths as a literal argument: %s", async (reference) => { @@ -221,7 +225,7 @@ describe("WSL attachment paths", () => { }, ); - it("still rejects missing files and directories after conversion", async () => { + it.skipIf(skipWslConversion)("still rejects missing files and directories after conversion", async () => { host.executeFile.mockResolvedValue({ stdout: `${join(workspaceDir, "missing.png")}\n`, }); diff --git a/release/public-source.json b/release/public-source.json index cd0385e0..addd3d8c 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3298,6 +3298,7 @@ "release/public-source.json", "scripts/build.mjs", "scripts/check-standalone-boundary.mjs", + "scripts/check-windows-source-location.mjs", "scripts/ci-changes.mjs", "scripts/export-source-preview.mjs", "scripts/gen-tsconfig-paths.mjs", @@ -3335,6 +3336,7 @@ "test/source-sync.test.mjs", "test/sqlite-message-contention.test.ts", "test/vitest-suites.json", + "test/windows-contract.test.mjs", "third_party/pi-mono/.minimax-vendor.json", "third_party/pi-mono/LICENSE", "third_party/pi-mono/MINIMAX_CHANGES.md", diff --git a/scripts/check-windows-source-location.mjs b/scripts/check-windows-source-location.mjs new file mode 100644 index 00000000..964f31b3 --- /dev/null +++ b/scripts/check-windows-source-location.mjs @@ -0,0 +1,77 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const LOCAL_NTFS_REQUIREMENT = + "Windows source checkouts must be on a local NTFS volume. pnpm workspace links require NTFS junctions; FAT32/exFAT volumes and network shares are not supported. Keep cloud-synced folders outside the checkout."; + +function fail(reason) { + return { ok: false, reason: `${LOCAL_NTFS_REQUIREMENT} ${reason}` }; +} + +/** + * Validate the Windows volume before pnpm attempts to create workspace links. + * + * The function is exported so the platform-specific policy can be tested without + * requiring a Windows host. Non-Windows platforms are intentionally a no-op. + */ +export function checkWindowsSourceLocation({ + platform = process.platform, + cwd = process.cwd(), + execFile = execFileSync, + allowNonFixed = process.env.GITHUB_ACTIONS === "true", +} = {}) { + if (platform !== "win32") return { ok: true, skipped: true }; + + const pathApi = platform === "win32" ? path.win32 : path; + const root = pathApi.parse(pathApi.resolve(cwd)).root; + if (!/^[a-z]:\\$/iu.test(root) || root.startsWith("\\\\")) { + return fail("The checkout root is not a local drive-letter path."); + } + // `fsutil` accepts a drive letter more reliably than a root path with a + // trailing backslash across Windows runner images. + const volume = root.slice(0, 2); + + let driveType; + let volumeInfo; + try { + driveType = execFile("fsutil", ["fsinfo", "drivetype", volume], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + volumeInfo = execFile("fsutil", ["fsinfo", "volumeinfo", volume], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + const detail = error instanceof Error ? ` (${error.message})` : ""; + return fail(`Windows could not verify the checkout volume${detail}.`); + } + + if (!allowNonFixed && !/:\s*DRIVE_FIXED(?:\r?\n|$)/iu.test(driveType)) { + return fail("The checkout volume is not a local fixed drive."); + } + if (!/:\s*NTFS(?:\r?\n|$)/iu.test(volumeInfo)) { + return fail("The checkout volume is not formatted as NTFS."); + } + + return { ok: true, skipped: false }; +} + +export function runWindowsSourceLocationCheck({ + platform = process.platform, + cwd = process.cwd(), + execFile = execFileSync, + allowNonFixed = process.env.GITHUB_ACTIONS === "true", + report = (message) => console.error(`[source-check] ${message}`), +} = {}) { + const result = checkWindowsSourceLocation({ platform, cwd, execFile, allowNonFixed }); + if (!result.ok) report(result.reason); + return result; +} + +const scriptPath = process.argv[1] ? path.resolve(process.argv[1]) : ""; +if (scriptPath === fileURLToPath(import.meta.url)) { + const result = runWindowsSourceLocationCheck(); + if (!result.ok) process.exitCode = 1; +} diff --git a/scripts/verify.mjs b/scripts/verify.mjs index d47f4c2f..18dbd74d 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -25,8 +25,10 @@ const { values } = parseArgs({ }, }); const profile = values.profile; -if (!["full", "platform", "docs", "archive", "package"].includes(profile)) +if (!["full", "platform", "windows", "docs", "archive", "package"].includes(profile)) throw new Error(`Unknown verification profile: ${profile}`); +if (profile === "windows" && process.platform !== "win32") + throw new Error("Windows verification profile requires a Windows host"); if (profile === 'package' && !['darwin', 'linux'].includes(process.platform)) throw new Error('Package verification currently supports Linux and macOS only.'); // Listing must not leave a temporary export directory behind. @@ -36,22 +38,24 @@ const temporary = values.list const preview = path.join(temporary ?? tmpdir(), "minimax-code-source.tar.gz"); const steps = [ - { name: "check:source", script: "check:source", docs: true }, - { name: "check:tsconfig", script: "check:tsconfig", docs: true }, + { name: "check:source", script: "check:source", docs: true, windows: true }, + { name: "check:tsconfig", script: "check:tsconfig", docs: true, windows: true }, { name: "export source preview", docs: true, + windows: true, requiresGit: true, command: ["scripts/export-source-preview.mjs", "--out", preview], }, - { name: "test:release-tools", script: "test:release-tools", docs: true }, + { name: "test:release-tools", script: "test:release-tools", docs: true, windows: true }, // Compiler inputs are identical across the matrix. One Linux job runs this; // all platforms still build and validate native artifacts on their own platform. { name: "typecheck", script: "typecheck", fullOnly: true }, - { name: "build", script: "build" }, - { name: "check:standalone", script: "check:standalone" }, - { name: "test:artifact", script: "test:artifact" }, + { name: "build", script: "build", windows: true }, + { name: "check:standalone", script: "check:standalone", windows: true }, + { name: "test:artifact", script: "test:artifact", windows: true }, { name: "test:capabilities", script: "test:capabilities" }, + { name: "test:windows", script: "test:windows", platforms: ["win32"], windows: true }, { name: "test:status-contract", script: "test:status-contract" }, { name: "test:smoke", script: "test:smoke" }, { name: "test:byok", script: "test:byok" }, @@ -74,6 +78,7 @@ const steps = [ function skipReason(step) { if (profile === 'package' && !step.packageOnly) return 'validating an npm release archive'; if (profile !== 'package' && step.packageOnly) return 'requires an npm release archive'; + if (profile === "windows" && !step.windows) return "not part of Windows contract"; if (profile === "docs" && !step.docs) return "documentation-only change"; if (profile === "archive" && step.requiresGit) return "validating an already exported archive"; diff --git a/test/source-sync.test.mjs b/test/source-sync.test.mjs index cc72ebbb..a6a39d01 100644 --- a/test/source-sync.test.mjs +++ b/test/source-sync.test.mjs @@ -20,6 +20,68 @@ import { validateReleaseReports } from '../scripts/publish-cli-release.mjs'; import { compareVersions, releaseCli } from '../scripts/release-cli.mjs'; import { compareRuns, exitCodeForStatus, renderReport, spread, validateRun, validateRequest, validateToolOutput, median, selectScenarios } from '../scripts/perf/report.mjs'; import { copyMcodeToolsArtifact, downloadMcodeToolsArtifact, MCODE_TOOLS_ARTIFACT } from '../scripts/lib/mcode-tools-artifact.mjs'; +import { checkWindowsSourceLocation, runWindowsSourceLocationCheck } from '../scripts/check-windows-source-location.mjs'; + +test('Windows source preflight accepts localized fsutil labels', () => { + const result = checkWindowsSourceLocation({ + platform: 'win32', + cwd: 'C:\\repo', + execFile: (_command, args) => args[1] === 'drivetype' + ? 'Laufwerkstyp: DRIVE_FIXED\n' + : 'Dateisystemname: NTFS\n', + }); + assert.deepEqual(result, { ok: true, skipped: false }); +}); + +test('Windows source preflight requires a local NTFS checkout', () => { + const calls = []; + const execFile = (command, args) => { + calls.push([command, args]); + if (args[1] === 'drivetype') return 'Drive type is : DRIVE_FIXED\n'; + return 'File System Name : NTFS\n'; + }; + assert.deepEqual( + checkWindowsSourceLocation({ platform: 'win32', cwd: 'C:\\repo', execFile }), + { ok: true, skipped: false }, + ); + assert.deepEqual(calls.map(([command, args]) => [command, args[1], args[2]]), [ + ['fsutil', 'drivetype', 'C:'], + ['fsutil', 'volumeinfo', 'C:'], + ]); +}); + +test('Windows source preflight rejects unsupported volumes clearly', () => { + const run = (driveType, volumeInfo, cwd = 'C:\\repo', allowNonFixed = false) => checkWindowsSourceLocation({ + platform: 'win32', cwd, allowNonFixed, + execFile: (_command, args) => args[1] === 'drivetype' ? driveType : volumeInfo, + }); + assert.match(run('Drive type is : DRIVE_REMOTE\n', 'File System Name : NTFS\n').reason, /not a local fixed drive/); + assert.deepEqual( + run('Drive type is : DRIVE_REMOTE\n', 'File System Name : NTFS\n', 'C:\\repo', true), + { ok: true, skipped: false }, + ); + assert.match(run('Drive type is : DRIVE_FIXED\n', 'File System Name : NTFS\n', '\\\\server\\share\\repo').reason, /not a local drive-letter path/); +}); + +test('Windows source preflight is a no-op on non-Windows platforms', () => { + assert.deepEqual( + checkWindowsSourceLocation({ platform: 'linux', execFile: () => assert.fail('must not run fsutil') }), + { ok: true, skipped: true }, + ); +}); + +test('Windows source preflight propagates a failed check to the CLI', () => { + const messages = []; + const result = runWindowsSourceLocationCheck({ + platform: 'win32', + cwd: 'C:\\repo', + execFile: () => { throw new Error('fsutil unavailable'); }, + report: (message) => messages.push(message), + }); + assert.equal(result.ok, false); + assert.equal(messages.length, 1); + assert.match(messages[0], /fsutil unavailable/); +}); test('artifact download recovers from TLS reset and interrupted response bodies', async () => { const reset = new TypeError('fetch failed', { cause: Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }) }); @@ -681,15 +743,18 @@ test('CI aggregate rejects failed, cancelled, missing and unexpectedly skipped c assert.notEqual(run({ ...full, DOCS_ONLY: scope }), 0); }); -test('ordinary CI pauses Windows without invoking release-only matrices', () => { +test('ordinary CI runs a focused Windows contract while compatibility remains macOS/Linux', () => { const readWorkflow = name => parseYaml(readFileSync(new URL(`../.github/workflows/${name}.yml`, import.meta.url), 'utf8')); const ci = readWorkflow('ci'); assert.ok(Object.hasOwn(ci.on, 'pull_request')); assert.deepEqual(ci.on.push.branches, ['main']); assert.deepEqual(Object.keys(ci.jobs).sort(), ['changes', 'docs', 'verification', 'verify']); - assert.deepEqual(ci.jobs.verify.strategy.matrix.os, ['ubuntu-latest', 'macos-latest']); + assert.deepEqual(ci.jobs.verify.strategy.matrix.os, ['ubuntu-latest', 'macos-latest', 'windows-latest']); assert.deepEqual(ci.jobs.verify.strategy.matrix.node, ['24']); - assert.deepEqual(ci.jobs.verify.strategy.matrix.include, [{ os: 'ubuntu-latest', node: '24', profile: 'full' }]); + assert.deepEqual(ci.jobs.verify.strategy.matrix.include, [ + { os: 'ubuntu-latest', node: '24', profile: 'full' }, + { os: 'windows-latest', node: '24', profile: 'windows' }, + ]); assert.equal(ci.jobs.verify.needs, 'changes'); assert.equal(ci.jobs.verify.if, "needs.changes.outputs.docs_only == 'false'"); assert.equal(ci.jobs.docs.if, "needs.changes.outputs.docs_only == 'true'"); @@ -699,12 +764,11 @@ test('ordinary CI pauses Windows without invoking release-only matrices', () => const compatibility = readWorkflow('compatibility'); assert.deepEqual(Object.keys(compatibility.on).sort(), ['schedule', 'workflow_dispatch']); assert.deepEqual(compatibility.jobs.compatibility.strategy.matrix.node, ['22.19.0', '24.2.0', '25', '26']); - assert.deepEqual(compatibility.jobs.compatibility.strategy.matrix.os, ci.jobs.verify.strategy.matrix.os); + assert.deepEqual(compatibility.jobs.compatibility.strategy.matrix.os, ['ubuntu-latest', 'macos-latest']); const audit = readWorkflow('security'); assert.ok(Object.hasOwn(audit.on, 'pull_request')); assert.ok(audit.jobs['source-history-artifact'].steps.some(step => step.run?.includes('gitleaks dir dist'))); }); - test('manual source candidates pin every checkout and receipt to the selected revision', () => { const workflow = parseYaml(readFileSync(new URL('../.github/workflows/source-candidate.yml', import.meta.url), 'utf8')); assert.deepEqual(Object.keys(workflow.on).sort(), ['workflow_call', 'workflow_dispatch']); @@ -832,6 +896,47 @@ test('candidate rejects mismatched receipts and requires successful same-revisio }); +test('Windows contract profile fails closed off Windows', () => { + const result = spawnSync(process.execPath, ['scripts/verify.mjs', '--profile', 'windows', '--list'], { + cwd: path.resolve('.'), + encoding: 'utf8', + }); + if (process.platform === 'win32') { + assert.equal(result.status, 0, result.stderr); + } else { + assert.notEqual(result.status, 0); + assert.match(result.stderr, /requires a Windows host/); + } +}); + +test('Windows contract profile selects focused gates', () => { + const root = mkdtempSync(path.join(tmpdir(), 'windows-profile-')); + try { + const fixture = path.join(root, 'verify.mjs'); + copyFileSync(new URL('../scripts/verify.mjs', import.meta.url), fixture); + const preload = path.join(root, 'platform.cjs'); + writeFileSync(preload, "Object.defineProperty(process, 'platform', { value: 'win32' });\n"); + const result = spawnSync(process.execPath, ['--require', preload, fixture, '--profile', 'windows', '--list'], { + cwd: root, + encoding: 'utf8', + env: { ...process.env }, + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.stdout.trim().split('\n'), [ + 'check:source', + 'check:tsconfig', + 'export source preview', + 'test:release-tools', + 'build', + 'check:standalone', + 'test:artifact', + 'test:windows', + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('source inventory rejects unregistered, missing and duplicate first-party test gates', () => { const existing = 'packages/example/src/existing.test.ts'; const omitted = 'packages/example/test/omitted.spec.tsx'; diff --git a/test/vitest-suites.json b/test/vitest-suites.json index a36a1b75..0601656f 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -1,6 +1,9 @@ { "comment": "Vitest suites for the standalone distribution, grouped by the gate that runs them. `vitest.oss.config.mjs` includes every group; `scripts/run-vitest-suite.mjs ` runs one. Add a test file here rather than in package.json or the Vitest config.", "suites": { + "windows": [ + "test/windows-contract.test.mjs" + ], "capability": [ "packages/agent-core/test/unit/bash-subprocess-env.test.ts", "packages/agent-modules/permission/test/unit/permission/bash-policy-regressions.test.ts", diff --git a/test/windows-contract.test.mjs b/test/windows-contract.test.mjs new file mode 100644 index 00000000..f2005805 --- /dev/null +++ b/test/windows-contract.test.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; +import { checkWindowsSourceLocation } from "../scripts/check-windows-source-location.mjs"; +import { resolveWslPath } from "../packages/tui/src/host/wsl-path.js"; + +const windowsPath = String.raw`D:\Users\demo\Documents\Screen shots\截图.png`; + +describe.skipIf(process.platform !== "win32")("Windows source contract", () => { + it("accepts the Windows checkout on a local NTFS volume", () => { + assert.deepEqual(checkWindowsSourceLocation(), { + ok: true, + skipped: false, + }); + }); + + it("preserves Windows path syntax on the native host", async () => { + assert.equal(await resolveWslPath(windowsPath), windowsPath); + }); +}); From ed1f927144135f9d4c4f8a26c1d319e031d7b899 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:20:30 +0800 Subject: [PATCH 02/15] fix(tui): preserve native scrolling when visible content shrinks (#306) --- docs/tui-capabilities.md | 16 ++- .../tui/src/tui/engine/LOCAL_CHANGES.json | 6 +- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 12 +- .../tui/src/tui/engine/tui-main-screen.ts | 26 ++++ .../test/pi-084-upstream/virtual-terminal.ts | 10 ++ packages/tui/test/unit/tui-app.test.ts | 45 +++++++ .../unit/tui-background-work-panel.test.ts | 7 +- .../test/unit/tui-engine-local-deltas.test.ts | 111 ++++++++++++++++-- 8 files changed, 215 insertions(+), 18 deletions(-) diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 3aadfa7c..8fd5dbb6 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -85,10 +85,18 @@ and initial prompt are not applied. In regular mode, independent feature panels occupy the complete visible terminal area, including short Rewind previews and scope pickers. Closing a panel restores -the current conversation. When running content shrinks across the native scrolling -boundary, or history refresh changes text already in scrollback, the renderer -reconstructs the current session to fill the viewport and keep its history unique. This reconstruction clears earlier shell scrollback; -ordinary updates keep native scrolling and selection behavior. +the current conversation. When running content shrinks entirely within the current +screen, the renderer keeps native scrollback and the Composer position stable. +Freed rows temporarily remain blank at the top of the active screen and subsequent +output reuses them. This avoids resetting the host's scroll position when a turn +finishes. Redundant resize notifications with unchanged dimensions do not rebuild +history. + +When a change removes or replaces text already in scrollback, the renderer still +reconstructs the current session to avoid stale or duplicate history. Real resizes +and image layout changes also retain the existing reconstruction behavior. A +reconstruction clears earlier shell scrollback and can reset the host's scroll +position; ordinary updates keep native scrolling and selection behavior. Rewind and Fork history-loading hints disappear as soon as their lists are ready. Returning from a cancelled operation must not leave a stale loading message in the diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index f4b4e102..231cb753 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -163,11 +163,11 @@ }, { "path": "tui-main-screen.ts", - "currentSha256": "f22abd4d0e88d818c9c923721047948c2a6d56df2d57f5bb633cccd5efd3c56d", - "changeIds": ["L005", "L017", "L027", "L033", "L034", "L037"], + "currentSha256": "73789fb955252262ad03907446e4f4a6052bfcb86bf2a4fdc77ddaa1dd7ee9ee", + "changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038"], "upstreamCommit": "6c4f360264397c59801f6da2bdac13e3b1fcbe91", "reason": "Keep strict TypeScript fixes and stream full and differential renders through Pi's bounded terminal writer. 缩放期间仅重绘可见尾部 常规模式在差分比较前剥离行首 OSC 133 zone 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。", - "behaviorImpact": "Regular updates preserve native scrollback. When document shrink reveals previously scrolled rows or visible text in scrollback changes, replay the complete current session to fill the viewport and avoid stale or duplicate history. Style-only changes preserve native scrollback. Rebuilding clears pre-launch shell scrollback. Resize retains the existing delayed history replay." + "behaviorImpact": "Regular updates preserve native scrollback. Visible text-only shrink with unchanged historical text temporarily pads the active screen to preserve host scrolling and the input position; later output reuses this space. Historical text replacement or removal still reconstructs the session to avoid stale or duplicate history. Rebuilding clears pre-launch shell scrollback. Genuine resize retains delayed history replay; redundant same-size notifications are ignored." }, { "path": "tui.ts", diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index e440c9c4..d920a633 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -119,8 +119,8 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct ## L034: Regular viewport reconstruction after document changes - Product contract: after running content or a feature panel closes, show the complete current chat viewport with its Composer and status line. Every current-session row must occur once in native history. -- Minimal difference: when a shorter document would move the viewport origin backwards, or changed visible text is already in scrollback, clear and replay the complete current projection. Compare changed historical rows without terminal sequences so style-only updates preserve scrollback. Other updates retain differential rendering and resize retains the existing delayed history replay. -- Tradeoff: structural reconstruction clears native scrollback, including shell history from before TUI startup. Initial short chat documents retain natural document placement. +- Minimal difference: when a shorter document would move the viewport origin backwards, or changed visible text is already in scrollback, clear and replay the complete current projection, except for addressable text-only shrink covered by L038. Compare changed historical rows without terminal sequences so style-only updates preserve scrollback. Other updates retain differential rendering and genuine resize retains the existing delayed history replay. +- Tradeoff: structural reconstruction clears native scrollback, including shell history from before TUI startup. Initial short chat documents retain natural document placement. L038 keeps freed visible rows temporarily blank instead of reconstructing unchanged history. - Evidence: local-delta tests assert every visible row and the complete history, while real Tasks and feature lifecycle tests cover short/long content, background growth, paging, resize, nested panels and return to chat. Queue lifecycle tests replay bracketed CJK paste, Alt+Enter, auto-drain, and history refresh through Ghostty; equal-height and growing historical edits are also covered by xterm. Virtual terminals do not establish native Windows Terminal or iTerm2 touchpad acceptance. - Removal condition: the selected Pi baseline provides equivalent complete viewport and unique-history behavior. @@ -138,3 +138,11 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Minimal difference: append cursor restoration to the bounded frame writer before ending synchronized output. Cursor-only updates retain the existing path. The product renderer separately defaults to a visible hardware cursor on Windows, where older ConPTY renderers can omit hidden cursor positions; explicit options and `PI_HARDWARE_CURSOR` remain authoritative. - Evidence: `test/unit/tui-ime-cursor.test.ts` replays terminal sequences at each synchronized-output boundary and exercises the product renderer, Composer, Editor, focus, mode switches, CJK wrapping, resize and shrink. Native Windows IME and ConPTY transport require separate acceptance. - Removal condition: the selected Pi baseline commits cursor restoration within the same synchronized frame. + +## L038: Preserve native scrolling during visible content shrink + +- Product contract: settling visible activity rows must not clear native scrollback or pin a scrolled host viewport to the top. The Composer and status remain at the bottom, and historical content remains unique. +- Minimal difference: when terminal geometry and the text already in scrollback are unchanged, absorb visible text-only shrink with blank rows at the current screen boundary before cursor extraction and differential rendering. Subsequent output consumes the space before advancing native history. Ignore redundant same-size resize notifications without cancelling a genuine pending resize replay. +- Boundary: padding is confined to the active screen. Historical text replacement/removal, real resize, overlays and image reflow retain the structural reconstruction path. Blank rows can temporarily separate native history from the visible tail; this is preferable to clearing and replaying the terminal's scrollback during ordinary completion. No mouse capture is enabled in regular mode. +- Evidence: local-delta tests use xterm's host scroll API independently of the hardware cursor, reproduce the pre-fix jump to line zero, and verify stable scrolling, Composer position, unique history, reclaimed space, corrected-history reconstruction and resize behavior. The product queue/feature tests continue to cover canonical history replacement. Native Windows Terminal and UU Remote acceptance remain separate. +- Removal condition: the selected Pi baseline preserves host scrolling and unique history through visible shrink. diff --git a/packages/tui/src/tui/engine/tui-main-screen.ts b/packages/tui/src/tui/engine/tui-main-screen.ts index 03cd3902..a5239708 100644 --- a/packages/tui/src/tui/engine/tui-main-screen.ts +++ b/packages/tui/src/tui/engine/tui-main-screen.ts @@ -131,6 +131,9 @@ export class TuiMainScreen extends TuiBase implements TUI { private historyReplayPending = false; protected override onTerminalResize(): void { + // Some hosts repeat resize notifications while scrolling or reconnecting. + // An unchanged geometry must not clear and replay native scrollback. + if (this.previousWidth === this.terminal.columns && this.previousHeight === this.terminal.rows) return; // Render the visible tail now; replay native scrollback only after the drag settles. if (this.previousLines.length > 0 && !isTermuxSession()) { if (this.resizeTimer) clearTimeout(this.resizeTimer); @@ -296,6 +299,29 @@ export class TuiMainScreen extends TuiBase implements TUI { newLines = this.compositeOverlays(newLines, width, height); } + // A native scrollback viewport cannot move backwards without clearing history. + // When only addressable rows shrink, absorb the freed rows at the top of the + // screen instead. The composer stays at the bottom, historical rows stay unique, + // and later output consumes this temporary space before scrolling again. + if ( + !widthChanged && !heightChanged && !this.historyReplayPending && !this.hasOverlayEntries && + prevViewportTop > 0 && newLines.length > prevViewportTop && + newLines.length < prevViewportTop + height && + this.previousKittyImageIds.size === 0 && !newLines.some(isImageLine) + ) { + let unchangedHistory = true; + for (let i = 0; i < prevViewportTop; i++) { + if (stripTerminalSequences(this.previousLines[i] ?? "") !== stripTerminalSequences(newLines[i] ?? "")) { + unchangedHistory = false; + break; + } + } + if (unchangedHistory) { + const padding = Array(prevViewportTop + height - newLines.length).fill(""); + newLines = [...newLines.slice(0, prevViewportTop), ...padding, ...newLines.slice(prevViewportTop)]; + } + } + // Extract cursor position before applying line resets (marker must be found first) const cursorPos = this.extractCursorPosition(newLines, height); diff --git a/packages/tui/test/pi-084-upstream/virtual-terminal.ts b/packages/tui/test/pi-084-upstream/virtual-terminal.ts index d6d0abc4..cf9276d5 100644 --- a/packages/tui/test/pi-084-upstream/virtual-terminal.ts +++ b/packages/tui/test/pi-084-upstream/virtual-terminal.ts @@ -209,6 +209,16 @@ export class VirtualTerminal implements Terminal { }; } + /** Scroll the host viewport independently of the application's hardware cursor. */ + scrollLines(amount: number): void { + this.xterm.scrollLines(amount); + } + + getScrollPosition(): { viewport: number; bottom: number } { + const buffer = this.xterm.buffer.active; + return { viewport: buffer.viewportY, bottom: buffer.baseY }; + } + /** Wait for TUI's throttled render pipeline to settle. */ async waitForRender(): Promise { await new Promise((resolve) => process.nextTick(resolve)); diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 5496d660..0dd3b9cd 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -42,6 +42,7 @@ import type { McodeBusinessTelemetry, } from "../../src/analytics/business-telemetry.js"; import { VirtualTerminalScreen } from "../helpers/virtual-terminal.js"; +import { VirtualTerminal } from "../pi-084-upstream/virtual-terminal.js"; import { TuiFailure } from "../../src/failure.js"; const runtimeEvent = (event: RawTuiRuntimeEvent): TuiRuntimeEvent => @@ -12741,6 +12742,50 @@ describe("createTuiApp", () => { } }); + it("keeps a scrolled host viewport in place when a long streamed answer finishes", async () => { + const terminal = new FakeTerminal(); + const screen = new VirtualTerminal(terminal.columns, terminal.rows); + const runtime = createRuntime(); + let finish: (() => void) | undefined; + const answer = Array.from({ length: 80 }, (_, index) => `Answer ${index}`).join("\n\n"); + vi.mocked(runtime.sendMessage).mockImplementation(async function* () { + yield { type: "delta", content: answer }; + await new Promise((resolve) => { finish = resolve; }); + yield { type: "done" }; + }); + const app = createTuiApp({ runtime, terminal, version: "0.1.0", workspaceDir: "/workspace" }); + let writeIndex = 0; + const flush = async () => { + app.tui.renderNow(); + for (; writeIndex < terminal.writes.length; writeIndex++) screen.write(terminal.writes[writeIndex]!); + await screen.flush(); + }; + app.start(); + try { + await app.ready; + const sending = app.submit("Write a long answer"); + await vi.waitFor(() => expect(app.tui.render(80).join("\n")).toContain("Answer 79")); + await flush(); + screen.scrollLines(-10); + const before = screen.getScrollPosition(); + expect(before.viewport).toBeGreaterThan(0); + const start = writeIndex; + finish?.(); + await sending; + await flush(); + expect(screen.getScrollPosition().viewport).toBe(before.viewport); + expect(terminal.writes.slice(start).join("")).not.toContain("\x1b[3J"); + for (let index = 0; index < 80; index++) { + expect(screen.getScrollBuffer().filter((line) => line.match(/Answer (\d+)/u)?.[1] === String(index))).toHaveLength(1); + } + screen.scrollLines(10000); + expect(screen.getViewport().join("\n")).toContain("Ask Mcode to do anything"); + } finally { + finish?.(); + await app.stop(); + } + }); + it.each([1, 100])( "settles an auto-drained follow-up of %i lines with unique terminal history", async (lineCount) => { diff --git a/packages/tui/test/unit/tui-background-work-panel.test.ts b/packages/tui/test/unit/tui-background-work-panel.test.ts index 2f289355..2ccd563a 100644 --- a/packages/tui/test/unit/tui-background-work-panel.test.ts +++ b/packages/tui/test/unit/tui-background-work-panel.test.ts @@ -362,8 +362,13 @@ describe('Tasks in the regular terminal viewport', () => { chatLines.splice(-shrinkRows); tui.renderNow(); await terminal.flush(); + // Visible shrink preserves native history with temporary screen space. + // The 15-row case also removes historical text and still reconstructs. + const afterShrink = shrinkRows === 15 + ? [...chatLines, 'COMPOSER', 'STATUS'].slice(-terminal.rows) + : [...Array(shrinkRows).fill(''), ...chatLines.slice(26), 'COMPOSER', 'STATUS']; expect(terminal.getViewport()).toEqual( - [...chatLines, 'COMPOSER', 'STATUS'].slice(-terminal.rows), + afterShrink, ); const presenter = new TuiOverlayRegularFeaturePresenter(terminal, tui, () => tui.requestRender(), diff --git a/packages/tui/test/unit/tui-engine-local-deltas.test.ts b/packages/tui/test/unit/tui-engine-local-deltas.test.ts index 54f8c0ed..1d8f8ff1 100644 --- a/packages/tui/test/unit/tui-engine-local-deltas.test.ts +++ b/packages/tui/test/unit/tui-engine-local-deltas.test.ts @@ -48,6 +48,94 @@ class MutableLines implements Component { } describe('MCode Pi Engine local deltas', () => { + it.each([1, 8, 30])('preserves a scrolled host viewport when %i visible activity rows settle', async (activityRows) => { + const terminal = new RecordingVirtualTerminal(60, 44); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + const answer = Array.from({ length: 80 }, (_, index) => `Answer ${index}`); + component.lines = [ + ...answer, + ...Array.from({ length: activityRows }, (_, index) => `Activity ${index}`), + `composer${CURSOR_MARKER}`, + 'running', + ]; + tui.addChild(component); + tui.renderNow(); + await terminal.flush(); + terminal.scrollLines(-10); + const before = terminal.getScrollPosition(); + expect(before.viewport).toBeGreaterThan(0); + expect(before.viewport).toBeLessThan(before.bottom); + terminal.takeWrites(); + + component.lines = [...answer, `composer${CURSOR_MARKER}`, 'idle']; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollPosition()).toEqual(before); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(terminal.getScrollBuffer().filter(Boolean)).toEqual([...answer, 'composer', 'idle']); + expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 42 }); + + // Repeated idle redraws must not reset the view or duplicate the transcript. + tui.renderNow(); + await terminal.flush(); + expect(terminal.getScrollPosition()).toEqual(before); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + + terminal.scrollLines(1000); + expect(terminal.getViewport().slice(-2)).toEqual(['composer', 'idle']); + }); + + it('ignores same-size resize notifications while the host is scrolled up', async () => { + const terminal = new RecordingVirtualTerminal(60, 12); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + component.lines = Array.from({ length: 80 }, (_, index) => `Answer ${index}`); + tui.addChild(component); + try { + tui.start(); + tui.renderNow(); + await terminal.flush(); + terminal.scrollLines(-10); + const before = terminal.getScrollPosition(); + const redraws = tui.fullRedraws; + terminal.takeWrites(); + terminal.resize(60, 12); + await new Promise((resolve) => setTimeout(resolve, 200)); + await terminal.flush(); + expect(terminal.getScrollPosition()).toEqual(before); + expect(tui.fullRedraws).toBe(redraws); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + } finally { + tui.stop(); + } + }); + + it('still reconstructs corrected history after a viewport shrink was absorbed', async () => { + const terminal = new RecordingVirtualTerminal(60, 12); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + const answer = Array.from({ length: 80 }, (_, index) => `Answer ${index}`); + component.lines = [...answer, 'activity', `composer${CURSOR_MARKER}`, 'status']; + tui.addChild(component); + tui.renderNow(); + await terminal.flush(); + terminal.takeWrites(); + + component.lines = [...answer, `composer${CURSOR_MARKER}`, 'status']; + tui.renderNow(); + await terminal.flush(); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + + component.lines[0] = 'Corrected answer'; + tui.renderNow(); + await terminal.flush(); + expect(terminal.takeWrites()).toContain('\x1b[3J'); + expect(terminal.getScrollBuffer()).toEqual(['Corrected answer', ...answer.slice(1), 'composer', 'status']); + expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 10 }); + }); + it('fits Text padding within narrow terminal widths', () => { const text = new Text('content', 2, 0); @@ -104,13 +192,15 @@ describe('MCode Pi Engine local deltas', () => { component.lines[0] = '\x1b[1mAnswer line 0\x1b[0m'; tui.renderNow(); await terminal.flush(); - expect(terminal.takeWrites()).toContain('\x1b[3J'); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); component.lines[0] = '\x1b[1mAnswer line 0\x1b[0m'; tui.renderNow(); await terminal.flush(); expect(terminal.takeWrites()).not.toContain('\x1b[3J'); - expect(terminal.getScrollBuffer()).toEqual([...answer, 'composer', 'status']); + expect(terminal.getScrollBuffer()).toEqual([ + ...answer.slice(0, 39), '', ...answer.slice(39), 'composer', 'status', + ]); expect(terminal.getViewport().at(-1)).toBe('status'); expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 42 }); @@ -148,15 +238,17 @@ describe('MCode Pi Engine local deltas', () => { for (let added = 0; added <= activityRows + 1; added++) { tui.renderNow(); await terminal.flush(); + // Native history keeps its original boundary. Freed rows remain visible + // until new output consumes them, rather than replaying historical text. + const expected = component.lines.map((line) => line.replace(CURSOR_MARKER, '')); + const remainingSpace = Math.max(0, activityRows - added); + if (remainingSpace > 0) expected.splice(38 + activityRows, 0, ...Array(remainingSpace).fill('')); expect(terminal.getViewport()).toEqual( - component.lines.slice(-terminal.rows).map((line) => line.replace(CURSOR_MARKER, '')), + expected.slice(-terminal.rows), ); expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 42 }); - if (added === 0) expect(terminal.takeWrites()).toContain('\x1b[3J'); - else expect(terminal.takeWrites()).not.toContain('\x1b[3J'); - expect(terminal.getScrollBuffer()).toEqual( - component.lines.map((line) => line.replace(CURSOR_MARKER, '')), - ); + expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + expect(terminal.getScrollBuffer()).toEqual(expected); if (added <= activityRows) component.lines.splice(-2, 0, `New answer ${added}`); } expect(terminal.getScrollBuffer()).not.toContain(''); @@ -262,6 +354,9 @@ describe('MCode Pi Engine local deltas', () => { 'composer', ]); + // A redundant notification must not discard the pending genuine resize replay. + terminal.resize(60, 30); + await new Promise((resolve) => setTimeout(resolve, 200)); tui.renderNow(); await terminal.flush(); From bcb1d96553e06bf2a0d44d06f40ac79350696465 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:35:51 +0800 Subject: [PATCH 03/15] fix(tui): erase viewport redraws without saving stale history (#309) --- docs/tui-capabilities.md | 4 +- .../tui/src/tui/engine/LOCAL_CHANGES.json | 6 +- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 8 ++ .../tui/src/tui/engine/tui-main-screen.ts | 13 ++- .../test/unit/tui-engine-local-deltas.test.ts | 85 ++++++++++++++++++- 5 files changed, 108 insertions(+), 8 deletions(-) diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 8fd5dbb6..92baff9e 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -90,7 +90,9 @@ screen, the renderer keeps native scrollback and the Composer position stable. Freed rows temporarily remain blank at the top of the active screen and subsequent output reuses them. This avoids resetting the host's scroll position when a turn finishes. Redundant resize notifications with unchanged dimensions do not rebuild -history. +history. Viewport-only redraws erase rows in place so terminals that save a cleared +screen to scrollback, including Apple Terminal, do not retain the old Composer, +status line or duplicate transcript rows. When a change removes or replaces text already in scrollback, the renderer still reconstructs the current session to avoid stale or duplicate history. Real resizes diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index 231cb753..cfae2635 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -163,11 +163,11 @@ }, { "path": "tui-main-screen.ts", - "currentSha256": "73789fb955252262ad03907446e4f4a6052bfcb86bf2a4fdc77ddaa1dd7ee9ee", - "changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038"], + "currentSha256": "5d499306f4b92d443c350dd944fbdc11b6ed7c74a030eb1e7e020890bc30fdb7", + "changeIds": ["L005", "L017", "L027", "L033", "L034", "L037", "L038", "L039"], "upstreamCommit": "6c4f360264397c59801f6da2bdac13e3b1fcbe91", "reason": "Keep strict TypeScript fixes and stream full and differential renders through Pi's bounded terminal writer. 缩放期间仅重绘可见尾部 常规模式在差分比较前剥离行首 OSC 133 zone 标记。 内容收缩或历史内容变化触发回退重绘时仅更新可见区域。", - "behaviorImpact": "Regular updates preserve native scrollback. Visible text-only shrink with unchanged historical text temporarily pads the active screen to preserve host scrolling and the input position; later output reuses this space. Historical text replacement or removal still reconstructs the session to avoid stale or duplicate history. Rebuilding clears pre-launch shell scrollback. Genuine resize retains delayed history replay; redundant same-size notifications are ignored." + "behaviorImpact": "Regular viewport redraws erase rows in place so hosts that save an erased screen to scrollback do not retain stale transcript or footer rows. Visible text-only shrink with unchanged historical text temporarily pads the active screen to preserve host scrolling and the input position; later output reuses this space. Historical text replacement or removal still reconstructs the session to avoid stale or duplicate history. Rebuilding clears pre-launch shell scrollback. Genuine resize retains delayed history replay; redundant same-size notifications are ignored." }, { "path": "tui.ts", diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index d920a633..88f5ab2f 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -146,3 +146,11 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Boundary: padding is confined to the active screen. Historical text replacement/removal, real resize, overlays and image reflow retain the structural reconstruction path. Blank rows can temporarily separate native history from the visible tail; this is preferable to clearing and replaying the terminal's scrollback during ordinary completion. No mouse capture is enabled in regular mode. - Evidence: local-delta tests use xterm's host scroll API independently of the hardware cursor, reproduce the pre-fix jump to line zero, and verify stable scrolling, Composer position, unique history, reclaimed space, corrected-history reconstruction and resize behavior. The product queue/feature tests continue to cover canonical history replacement. Native Windows Terminal and UU Remote acceptance remain separate. - Removal condition: the selected Pi baseline preserves host scrolling and unique history through visible shrink. + +## L039: Erase regular viewport redraws in place + +- Product contract: repainting the visible regular-mode screen must not append the previous transcript, Composer or status line to native history. +- Minimal difference: viewport-only full redraws home the cursor, erase each screen row with EL 2 using cursor-down movement, and return home before painting. This avoids ED 2, which saves the old screen to scrollback in Apple Terminal. Full structural reconstruction still clears and rebuilds history. +- Evidence: local-delta tests exercise xterm and a clear-to-scrollback host model, covering historical style changes, simultaneous growth, short-document shrink, subsequent differential output, host scrolling and resize preview/replay. Native Apple Terminal replay of synthetic renderer output reproduces duplicate rows before the fix and preserves the exact document afterward. +- Boundary: native replay covers synthetic output, not every live-model interaction or other terminal emulator. +- Removal condition: the selected Pi baseline supplies equivalent in-place viewport erasure without retaining stale rows in native history. diff --git a/packages/tui/src/tui/engine/tui-main-screen.ts b/packages/tui/src/tui/engine/tui-main-screen.ts index a5239708..44901c93 100644 --- a/packages/tui/src/tui/engine/tui-main-screen.ts +++ b/packages/tui/src/tui/engine/tui-main-screen.ts @@ -348,7 +348,18 @@ export class TuiMainScreen extends TuiBase implements TUI { output.append("\x1b[?2026h"); // Begin synchronized output if (clear) { output.append(this.deleteKittyImages(this.previousKittyImageIds)); - output.append(viewportOnly ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J"); + if (viewportOnly) { + // ED 2 saves the old screen to scrollback in Apple Terminal. Erase + // each row in place so old transcript/footer rows cannot survive there. + output.append("\x1b[H"); + for (let row = 0; row < height; row++) { + if (row > 0) output.append("\x1b[1B"); + output.append("\x1b[2K"); + } + output.append("\x1b[H"); + } else { + output.append("\x1b[2J\x1b[H\x1b[3J"); + } } for (let i = start; i < newLines.length; i++) { if (i > start) output.append("\r\n"); diff --git a/packages/tui/test/unit/tui-engine-local-deltas.test.ts b/packages/tui/test/unit/tui-engine-local-deltas.test.ts index 1d8f8ff1..d61cf233 100644 --- a/packages/tui/test/unit/tui-engine-local-deltas.test.ts +++ b/packages/tui/test/unit/tui-engine-local-deltas.test.ts @@ -37,6 +37,14 @@ class RecordingVirtualTerminal extends VirtualTerminal { } } +// Apple Terminal preserves the old screen in scrollback on ED 2. Model that +// behavior explicitly: xterm's default erase implementation does not expose it. +class ClearToScrollbackTerminal extends RecordingVirtualTerminal { + override write(data: string): void { + super.write(data.replaceAll('\x1b[2J', `\x1b[${this.rows};1H${'\r\n'.repeat(this.rows)}\x1b[2J`)); + } +} + class MutableLines implements Component { lines: string[] = []; @@ -48,6 +56,68 @@ class MutableLines implements Component { } describe('MCode Pi Engine local deltas', () => { + describe.each([ + ['xterm', RecordingVirtualTerminal], + ['clear-to-scrollback host', ClearToScrollbackTerminal], + ] as const)('%s viewport repaint', (_name, Terminal) => { + it.each([0, 30])('keeps unique history after a style update and %i new rows', async (growth) => { + const terminal = new Terminal(60, 12); + const tui = new TuiMainScreen(terminal); + const component = new MutableLines(); + const answer = Array.from({ length: 40 }, (_, index) => `Answer ${index}`); + component.lines = [...answer, `old composer${CURSOR_MARKER}`, 'running']; + tui.addChild(component); + tui.renderNow(); + await terminal.flush(); + terminal.scrollLines(-10); + const before = terminal.getScrollPosition(); + terminal.takeWrites(); + + const more = Array.from({ length: growth }, (_, index) => `More ${index}`); + component.lines = [...answer, ...more, `composer${CURSOR_MARKER}`, 'idle']; + component.lines[0] = '\x1b[1mAnswer 0\x1b[0m'; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer()).toEqual([...answer, ...more, 'composer', 'idle']); + expect(terminal.getScrollPosition().viewport).toBe(before.viewport); + expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 10 }); + const writes = terminal.takeWrites(); + expect(writes).not.toContain('\x1b[2J'); + expect(writes).not.toContain('\x1b[3J'); + + // Subsequent differential output must still overwrite the current footer. + component.lines.splice(-2, 0, 'Next response'); + tui.renderNow(); + await terminal.flush(); + expect(terminal.getScrollBuffer()).toEqual([...answer, ...more, 'Next response', 'composer', 'idle']); + terminal.scrollLines(1000); + expect(terminal.getViewport().slice(-3)).toEqual(['Next response', 'composer', 'idle']); + }); + + it('erases stale rows when a short document shrinks', async () => { + const terminal = new Terminal(60, 12); + const tui = new TuiMainScreen(terminal); + tui.setClearOnShrink(true); + const component = new MutableLines(); + component.lines = ['answer', 'activity 1', 'activity 2', `old composer${CURSOR_MARKER}`, 'running']; + tui.addChild(component); + tui.renderNow(); + await terminal.flush(); + terminal.takeWrites(); + + component.lines = ['answer', `composer${CURSOR_MARKER}`, 'idle']; + tui.renderNow(); + await terminal.flush(); + + expect(terminal.getScrollBuffer()).toEqual(['answer', 'composer', 'idle', ...Array(9).fill('')]); + expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 1 }); + const writes = terminal.takeWrites(); + expect(writes).not.toContain('\x1b[2J'); + expect(writes).not.toContain('\x1b[3J'); + }); + }); + it.each([1, 8, 30])('preserves a scrolled host viewport when %i visible activity rows settle', async (activityRows) => { const terminal = new RecordingVirtualTerminal(60, 44); const tui = new TuiMainScreen(terminal); @@ -330,8 +400,11 @@ describe('MCode Pi Engine local deltas', () => { expect(terminal.getCursorPosition()).toEqual({ x: 8, y: 1 }); }); - it('still previews the resized tail and restores ordered scrollback after resize settles', async () => { - const terminal = new RecordingVirtualTerminal(67, 44); + it.each([ + ['xterm', RecordingVirtualTerminal], + ['clear-to-scrollback host', ClearToScrollbackTerminal], + ] as const)('previews the resized tail and restores ordered scrollback on %s', async (_name, Terminal) => { + const terminal = new Terminal(67, 44); const tui = new TuiMainScreen(terminal); const component = new MutableLines(); component.lines = [ @@ -348,7 +421,13 @@ describe('MCode Pi Engine local deltas', () => { terminal.resize(60, 30); tui.renderNow(); await terminal.flush(); - expect(terminal.takeWrites()).not.toContain('\x1b[3J'); + const previewWrites = terminal.takeWrites(); + expect(previewWrites).not.toContain('\x1b[2J'); + expect(previewWrites).not.toContain('\x1b[3J'); + expect(terminal.getScrollBuffer()).toEqual([ + ...Array.from({ length: 80 }, (_, index) => `Answer line ${index}`), + 'composer', + ]); expect(terminal.getViewport()).toEqual([ ...Array.from({ length: 29 }, (_, index) => `Answer line ${index + 51}`), 'composer', From 00a7e7df1a1596cf1da63a6334b0c9f6d1f32618 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:49:20 +0800 Subject: [PATCH 04/15] feat(tui): add selectable themes and custom theme files (#311) * feat(tui): add selectable themes and custom theme files MCode TUI colors are now driven by named themes, each shipping a dark and a light palette. `/theme` opens a panel with live preview so users can switch themes and lock light/dark on top of terminal detection. Custom theme files under `tui/themes/*.json` support partial overrides, color aliases and syntax palettes, and hot reload so editing a theme in use takes effect without restarting. The selected theme persists in `tui/tui-settings.json` alongside `tuiMode`; unrecognized values fall back to the default theme instead of blocking startup. Assisted-by: mavis reason:port-theme-system * test: register theme system tests and source inventory The public source gate requires every first-party test to be listed in test/vitest-suites.json and every shipped source path to be recorded in release/public-source.json. Register the new theme system tests and the 11 files this change adds. Assisted-by: mavis reason:register-theme-tests --- packages/tui/docs/theme-config.md | 141 ++++ packages/tui/src/host/tui-settings.ts | 74 +- packages/tui/src/tui/app-composition.ts | 2 + packages/tui/src/tui/app.ts | 7 + packages/tui/src/tui/commands/catalog.ts | 9 + .../tui/controller/product/command-flow.ts | 2 + .../src/tui/controller/product/theme-setup.ts | 44 ++ .../src/tui/features/settings/theme-picker.ts | 248 +++++++ packages/tui/src/tui/launcher.ts | 14 +- packages/tui/src/tui/theme/contracts.ts | 85 +++ packages/tui/src/tui/theme/controller.ts | 160 +++- packages/tui/src/tui/theme/custom-themes.ts | 300 ++++++++ packages/tui/src/tui/theme/palettes.ts | 358 ++++++++- packages/tui/src/tui/theme/registry.ts | 92 +++ packages/tui/src/tui/theme/runtime.ts | 38 +- packages/tui/src/tui/theme/syntax.ts | 105 ++- packages/tui/src/types/tui-app.ts | 3 + packages/tui/test/helpers/theme-contrast.ts | 57 ++ .../tui/test/unit/host-tui-settings.test.ts | 157 ++++ .../features/settings/theme-picker.test.ts | 161 ++++ .../test/unit/tui/theme/custom-themes.test.ts | 275 +++++++ .../tui/test/unit/tui/theme/palettes.test.ts | 113 +++ .../tui/test/unit/tui/theme/runtime.test.ts | 695 ++++++++++++++++++ release/public-source.json | 11 + test/vitest-suites.json | 7 +- 25 files changed, 3051 insertions(+), 107 deletions(-) create mode 100644 packages/tui/docs/theme-config.md create mode 100644 packages/tui/src/tui/controller/product/theme-setup.ts create mode 100644 packages/tui/src/tui/features/settings/theme-picker.ts create mode 100644 packages/tui/src/tui/theme/custom-themes.ts create mode 100644 packages/tui/src/tui/theme/registry.ts create mode 100644 packages/tui/test/helpers/theme-contrast.ts create mode 100644 packages/tui/test/unit/host-tui-settings.test.ts create mode 100644 packages/tui/test/unit/tui/features/settings/theme-picker.test.ts create mode 100644 packages/tui/test/unit/tui/theme/custom-themes.test.ts create mode 100644 packages/tui/test/unit/tui/theme/palettes.test.ts create mode 100644 packages/tui/test/unit/tui/theme/runtime.test.ts diff --git a/packages/tui/docs/theme-config.md b/packages/tui/docs/theme-config.md new file mode 100644 index 00000000..3ce15c59 --- /dev/null +++ b/packages/tui/docs/theme-config.md @@ -0,0 +1,141 @@ +# 主题配置指南 + +mcode TUI 的配色由**具名主题**决定,每个主题同时提供深色和浅色两套调色板。终端深浅背景默认由 +终端证据自动探测,`/theme` 在此之上让用户切换主题、锁定明暗,并支持自己编写主题文件。 + +## TL;DR + +输入 `/theme` 打开主题面板: + +- `↑` / `↓` 移动光标,**实时预览**对应配色;`Enter` 保存并立即生效,`Esc` / `Ctrl+C` 取消并还原打开面板时的主题。 +- `a` 在「自动 / 浅色 / 深色」之间循环。自动模式跟随终端探测结果,锁定后不再被终端证据覆盖。 +- 每行右侧是配色条,面板底部显示当前外观、主题来源和说明。 +- 主题选择写入运行时数据目录下的 `tui/tui-settings.json`;写入失败时面板保持打开并提示,不会丢失当前选择。 +- `/theme` 是 `search-only` 命令:可以直接输入 `/theme` 使用,也能在命令搜索中找到,但不会出现在默认 slash 列表里。 + +## 内置主题 + +| 主题 ID | 名称 | 特点 | +| --- | --- | --- | +| `minimax` | MCode | 默认主题。MiniMax 蓝 + Catppuccin 语法高亮 | +| `midnight` | Midnight | 更深的蓝黑背景,抬高了前景对比度 | +| `graphite` | Graphite | 中性低彩度表面,长输出更安静 | +| `aurora` | Aurora | 偏青绿的次级色阶 | + +每个主题都定义了完整的深色和浅色版本,因此终端切换到浅色时不会出现缺色或错配。`minimax` +的取值与主题系统引入前完全一致,现有用户不会看到任何视觉变化。 + +内置主题的正文色与 `line` 非文本色需要满足 +[WCAG AA 对比度](./tui-foundation.md#主题与终端能力) 基线(正文 4.5:1、非文本 3:1),由 +`packages/tui/test/unit/tui/theme/palettes.test.ts` 对全部主题、全部外观做回归校验。 + +## 配置落点 + +- 文件:`~/.minimax/tui/tui-settings.json`(即运行时数据目录下的 `tui/tui-settings.json`)。 +- 键:`theme`,值是主题 ID,或 `主题ID/light`、`主题ID/dark` 锁定外观。 + +```json +{ + "tuiMode": "regular", + "theme": "midnight" +} +``` + +`theme` 与 `tuiMode` 写在同一份文件里,写入其中一个不会覆盖另一个;文件中的未知键也会原样保留。 +无法识别的 `theme` 值会回退到默认主题,不会阻断启动。 + +## 自定义主题文件 + +MCode 从运行时数据目录下的 `tui/themes/*.json` 读取用户主题。 + +- 一个文件提供一种外观。`aurora.json` 提供深色,`aurora-light.json` 提供浅色;两个文件的 + `name` 相同即组成一个可选主题。 +- 没有提供的外观会回退到默认主题对应外观的调色板,因此只写深色文件也能正常使用。 +- 文件名可以是任意 `.json`,主题 ID 取文件里的 `name`;`name` 不能包含 `/`,也不能与内置主题 + ID 相同(内置主题优先,冲突文件会被忽略并在加载问题里报告)。 +- **热重载**:编辑正在使用的自定义主题文件后,保存即生效,无需重启。 + +### 文件格式 + +```json +{ + "name": "my-theme", + "label": "My Theme", + "description": "A custom MCode palette", + "appearance": "dark", + "vars": { + "brand": "#68c0ff", + "gray": "#949494" + }, + "colors": { + "brand": "brand", + "signal": "brand", + "accent": "brand", + "text": "#d6d6d6", + "muted": "gray", + "line": "gray" + }, + "syntax": { + "text": "#cdd6f4", + "mauve": "#cba6f7", + "overlay2": "#9399b2" + } +} +``` + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `name` | 是 | 主题 ID,`[a-z0-9][a-z0-9._-]{0,63}` | +| `appearance` | 是 | `dark` 或 `light` | +| `label` / `description` | 否 | 面板中显示的名称与说明 | +| `vars` | 否 | 可复用的颜色别名 | +| `colors` | 是 | UI 颜色对象;可以只写要覆盖的字段 | +| `syntax` | 否 | 语法高亮色板;未写的色调沿用默认主题 | + +`colors` 和 `syntax` 的值可以是: + +- **hex 字面量**:`"#68c0ff"` 或三位简写 `"#6cf"`。 +- **`vars` 引用**:在 `colors` / `syntax` 里写 `vars` 中定义的别名名。 +- **空字符串** `""`:使用终端默认色。 + +`vars` 的值只能是 hex 字面量或空字符串——**不支持嵌套引用**(`vars.a` 不能再指向另一个 `vars` 键), +这类写法会在加载时被拒绝并给出定位到具体路径的错误。 + +只写部分 `colors` 字段即可,其余沿用默认主题对应外观的值——这样新增主题只需描述差异。 + +### 可用字段 + +`colors` 支持以下 21 个键: + +`brand`、`wordmarkHighlight`、`wordmarkShadow`、`signal`、`orbit`、`accent`、`markdownHeading`、 +`markdownCode`、`markdownLink`、`userMessageBg`、`diffAddedBg`、`diffRemovedBg`、`text`、`muted`、 +`dim`、`border`、`line`、`success`、`warning`、`error`。 + +`syntax` 支持以下 13 个色调: + +`blue`、`flamingo`、`green`、`mauve`、`overlay2`、`peach`、`pink`、`red`、`sapphire`、`subtext0`、 +`teal`、`text`、`yellow`。 + +自定义主题不参与内置主题的对比度回归校验。终端只支持 16 色时,UI 颜色会映射到终端语义色, +语法色使用固定的 ANSI16 映射(与内置主题一致)。 + +## 终端能力与降级 + +主题只消费探测到的 terminal capability,不改变任何业务语义: + +- 深浅背景优先采用 OSC 11 查询结果,其次是终端的 DEC 2031 上报,最后回退到 `COLORFGBG`; +- 锁定外观后(`/theme` 按 `a`,或配置里写 `主题ID/dark`),终端证据不再改变外观; +- ANSI16 / 256 / truecolor 逐级降级,不支持颜色时仍保持文本层级。 + +## 相关文件 + +| 路径 | 职责 | +| --- | --- | +| `src/tui/theme/contracts.ts` | 主题、调色板、语法色板的类型契约 | +| `src/tui/theme/palettes.ts` | 内置主题定义与默认主题 | +| `src/tui/theme/custom-themes.ts` | 自定义主题文件的发现、校验、加载与热重载 | +| `src/tui/theme/registry.ts` | 内置与自定义主题的合并、解析与回退 | +| `src/tui/theme/controller.ts` | 主题选择、外观锁定、终端明暗探测 | +| `src/tui/theme/runtime.ts` | 颜色与语法色板的活绑定 | +| `src/tui/features/settings/theme-picker.ts` | `/theme` 面板 | +| `src/host/tui-settings.ts` | `tui-settings.json` 读写 | diff --git a/packages/tui/src/host/tui-settings.ts b/packages/tui/src/host/tui-settings.ts index ae91b3b8..57582e73 100644 --- a/packages/tui/src/host/tui-settings.ts +++ b/packages/tui/src/host/tui-settings.ts @@ -1,32 +1,82 @@ -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import type { TuiMode } from '../tui/engine/public.js'; const TUI_SETTINGS_FILE = path.join('tui', 'tui-settings.json'); const LEGACY_TUI_SETTINGS_FILE = 'tui-settings.json'; +const THEME_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/iu; interface TuiSettingsDocument { readonly tuiMode?: unknown; + readonly theme?: unknown; } -export function readTuiModeSetting(dataDir: string): TuiMode { +function settingsPath(dataDir: string): string { + return path.join(dataDir, TUI_SETTINGS_FILE); +} + +function readDocument(dataDir: string): TuiSettingsDocument { for (const file of [TUI_SETTINGS_FILE, LEGACY_TUI_SETTINGS_FILE]) { try { const content = readFileSync(path.join(dataDir, file), 'utf8').replace(/^\uFEFF/u, ''); - const document = JSON.parse(content) as TuiSettingsDocument; - return document.tuiMode === 'fullscreen' ? 'fullscreen' : 'regular'; + const parsed = JSON.parse(content) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as TuiSettingsDocument; + } } catch { - // Try the legacy root location before falling back to the default mode. + // Try the legacy root location before falling back to defaults. } } - return 'regular'; + return {}; +} + +function writeDocument(dataDir: string, patch: TuiSettingsDocument): void { + const filePath = settingsPath(dataDir); + const directory = path.dirname(filePath); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + // Merge instead of replacing so writing one preference never drops the other. + const next: Record = { ...readDocument(dataDir), ...patch }; + if (next.tuiMode === undefined) delete next.tuiMode; + if (next.theme === undefined) delete next.theme; + const temporaryPath = path.join(directory, `.tui-settings.json.${process.pid}.${Date.now()}.tmp`); + writeFileSync(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + renameSync(temporaryPath, filePath); +} + +export function readTuiModeSetting(dataDir: string): TuiMode { + return readDocument(dataDir).tuiMode === 'fullscreen' ? 'fullscreen' : 'regular'; } export function writeTuiModeSetting(dataDir: string, mode: TuiMode): void { - mkdirSync(path.dirname(path.join(dataDir, TUI_SETTINGS_FILE)), { recursive: true }); - writeFileSync( - path.join(dataDir, TUI_SETTINGS_FILE), - `${JSON.stringify({ tuiMode: mode }, null, 2)}\n`, - { encoding: 'utf8', mode: 0o600 }, - ); + writeDocument(dataDir, { tuiMode: mode }); +} + +/** + * Saved theme selection. Accepts `id` or `id/light|dark` so a user can pin an + * appearance for terminals that report their background unreliably. Unknown or + * malformed values resolve to the default theme in the controller, never here. + */ +export function readTuiThemeSetting(dataDir: string): string | undefined { + const value = readDocument(dataDir).theme; + if (typeof value !== 'string') return undefined; + const raw = value.trim(); + if (!raw) return undefined; + const pair = /^([a-z0-9][a-z0-9._-]{0,63})\/(light|dark)$/iu.exec(raw); + const pinnedId = pair?.[1]; + const pinnedAppearance = pair?.[2]; + if (pinnedId && pinnedAppearance) { + return `${pinnedId}/${pinnedAppearance.toLowerCase()}`; + } + return THEME_ID.test(raw) ? raw : undefined; +} + +export function writeTuiThemeSetting(dataDir: string, theme: string): void { + const raw = theme.trim(); + if (!THEME_ID.test(raw) && !/^[a-z0-9][a-z0-9._-]{0,63}\/(light|dark)$/iu.test(raw)) { + throw new Error(`"${theme}" is not a valid theme id`); + } + writeDocument(dataDir, { theme: raw }); } diff --git a/packages/tui/src/tui/app-composition.ts b/packages/tui/src/tui/app-composition.ts index 81491cb6..6273fc7d 100644 --- a/packages/tui/src/tui/app-composition.ts +++ b/packages/tui/src/tui/app-composition.ts @@ -174,6 +174,8 @@ export function createTuiApplicationRenderer(options: CreateTuiAppOptions) { themeController = new TuiThemeController({ ui: tui, colorLevel: capabilities.colorLevel, + ...(options.dataDir ? { dataDir: options.dataDir } : {}), + ...(options.theme ? { theme: options.theme } : {}), onDetection: (snapshot) => options.observability?.recordTheme?.(snapshot), }); diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index 38c665b2..c70b0cee 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -48,6 +48,7 @@ import type { TuiGoalFlow } from './controller/product/goal-flow.js'; import { createTuiSessionLifecycleBridge } from './controller/session-lifecycle-bridge.js'; import { parseTuiStatusLineItems as parseStatusItems } from './shell/status-line-items.js'; import { showTuiStatusLineSetup } from './controller/product/status-line-setup.js'; +import { showTuiThemeSetup } from './controller/product/theme-setup.js'; import { TuiCodexHandoffFlow } from './controller/product/codex-handoff-flow.js'; export type { CreateTuiAppOptions, TuiApp, TuiStopOptions }; @@ -511,6 +512,12 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { surface: interactionSurface, persist: options.persistStatusLineItems, }), + showTheme: () => + showTuiThemeSetup({ + controller: themeController, + surface: interactionSurface, + persist: options.persistTheme, + }), keybindings: options.keybindings, getTuiKeybindingOverrides: options.getTuiKeybindingOverrides, saveTuiKeybindingOverrides: options.saveTuiKeybindingOverrides, diff --git a/packages/tui/src/tui/commands/catalog.ts b/packages/tui/src/tui/commands/catalog.ts index 233c7fba..3da70ea0 100644 --- a/packages/tui/src/tui/commands/catalog.ts +++ b/packages/tui/src/tui/commands/catalog.ts @@ -370,6 +370,15 @@ const COMMAND_SOURCES: readonly TuiCommandSource[] = [ visibleWhen: (context) => !context.hasPendingInteraction, unavailableReason: 'Finish the pending interaction before configuring the status line.', }, + { + name: 'theme', + description: 'Choose the MCode color theme and terminal appearance', + category: 'Application', + // Reachable by typing, but kept out of the default slash list: theme + // selection is a preference, not a primary verb. + discoverability: 'search-only', + readiness: 'immediate', + }, { name: 'hotkeys', description: 'View and customize TUI keyboard shortcuts', diff --git a/packages/tui/src/tui/controller/product/command-flow.ts b/packages/tui/src/tui/controller/product/command-flow.ts index 4bdb3af9..695d9bcf 100644 --- a/packages/tui/src/tui/controller/product/command-flow.ts +++ b/packages/tui/src/tui/controller/product/command-flow.ts @@ -89,6 +89,7 @@ export interface TuiCommandFlowOptions { readonly surfaceHost: TuiSurfaceHost; readonly showTasks?: () => void | Promise; readonly showStatusLine?: () => void; + readonly showTheme?: () => void; readonly persistTuiMode?: (mode: TuiMode) => void; readonly queueEnabled: boolean; readonly liveRunId: () => string | undefined; @@ -1192,6 +1193,7 @@ export class TuiCommandFlow { checkin: async () => this.runDailyCheckinCommand(), settings: () => this.showSettingsPicker(), statusline: () => this.options.showStatusLine?.(), + theme: () => this.options.showTheme?.(), hotkeys: () => this.showHotkeysPicker(), reload: async () => { if (!this.options.reloadTui) { diff --git a/packages/tui/src/tui/controller/product/theme-setup.ts b/packages/tui/src/tui/controller/product/theme-setup.ts new file mode 100644 index 00000000..168e9238 --- /dev/null +++ b/packages/tui/src/tui/controller/product/theme-setup.ts @@ -0,0 +1,44 @@ +import { + TuiThemePicker, + type TuiThemeAppearanceChoice, +} from '../../features/settings/theme-picker.js'; +import type { TuiInteractionSurface } from '../../shell/interaction-surface.js'; +import type { TuiThemeController } from '../../theme/controller.js'; + +/** Persist an appearance pin as `id/light` so the next launch restores it. */ +export function themeSettingValue(themeId: string, appearance: TuiThemeAppearanceChoice): string { + return appearance === 'auto' ? themeId : `${themeId}/${appearance}`; +} + +export function showTuiThemeSetup(options: { + readonly controller: TuiThemeController; + readonly surface: Pick; + readonly persist?: (theme: string) => void; +}): void { + const { controller, surface } = options; + const picker = new TuiThemePicker({ + themes: controller.listThemes(), + currentThemeId: controller.selectedThemeId(), + currentAppearance: controller.snapshot().appearance, + appearanceOverride: controller.appearanceOverrideValue(), + preview: (themeId) => { + controller.previewTheme(themeId); + }, + setAppearance: (choice) => { + controller.setAppearanceOverride(choice === 'auto' ? undefined : choice); + }, + save: (themeId, appearance) => { + // Commit before persisting so a write failure leaves the previous theme + // active and the picker can show the error without losing the selection. + if (!controller.setTheme(themeId)) { + throw new Error(`Theme "${themeId}" is no longer available`); + } + options.persist?.(themeSettingValue(themeId, appearance)); + }, + onClose: () => { + surface.close(picker); + }, + requestRender: () => surface.request(), + }); + surface.show(picker); +} diff --git a/packages/tui/src/tui/features/settings/theme-picker.ts b/packages/tui/src/tui/features/settings/theme-picker.ts new file mode 100644 index 00000000..723f874b --- /dev/null +++ b/packages/tui/src/tui/features/settings/theme-picker.ts @@ -0,0 +1,248 @@ +import { panelLayout, renderPanelFrame } from '../../widgets/panel-frame.js'; +import { getKeybindings, matchesKey } from '../../engine/public.js'; +import type { Component } from '../../rendering/component.js'; +import { visibleWidth } from '../../rendering/text.js'; +import { tuiChalk as chalk, tuiColors as colors } from '../../theme/runtime.js'; +import type { + TuiResolvedAppearance, + TuiThemeColors, + TuiThemeDefinition, +} from '../../theme/contracts.js'; + +export type TuiThemeAppearanceChoice = 'auto' | TuiResolvedAppearance; + +export interface TuiThemePickerOptions { + readonly themes: readonly TuiThemeDefinition[]; + readonly currentThemeId: string; + readonly currentAppearance: TuiResolvedAppearance; + readonly appearanceOverride: TuiResolvedAppearance | undefined; + /** Apply without persisting, so moving the cursor previews live. */ + readonly preview: (themeId: string) => void; + readonly setAppearance: (choice: TuiThemeAppearanceChoice) => void; + readonly save: (themeId: string, appearance: TuiThemeAppearanceChoice) => Promise | void; + readonly onClose: () => void; + readonly requestRender: () => void; +} + +/** + * Theme chooser. Cursor movement previews each palette immediately so the user + * can judge a theme in the real transcript behind the panel; `Enter` persists + * and `Esc` restores the theme that was active when the picker opened. + */ +export class TuiThemePicker implements Component { + private selectedIndex: number; + private originalThemeId: string; + private originalAppearance: TuiThemeAppearanceChoice; + private appearance: TuiThemeAppearanceChoice; + private busy = false; + private error = false; + private disposed = false; + + constructor(private readonly options: TuiThemePickerOptions) { + this.originalThemeId = options.currentThemeId; + this.originalAppearance = options.appearanceOverride ?? 'auto'; + this.appearance = this.originalAppearance; + const index = options.themes.findIndex((theme) => theme.id === options.currentThemeId); + this.selectedIndex = index >= 0 ? index : 0; + } + + handleInput(data: string): void { + if (this.busy || this.disposed) return; + const keys = getKeybindings(); + if (keys.matches(data, 'tui.select.cancel') || matchesKey(data, 'ctrl+c')) { + this.restore(); + this.options.onClose(); + return; + } + if (keys.matches(data, 'tui.select.confirm')) { + this.commit(); + return; + } + const themes = this.options.themes; + if (themes.length === 0) return; + if (keys.matches(data, 'tui.select.up')) { + this.selectedIndex = (this.selectedIndex - 1 + themes.length) % themes.length; + } else if (keys.matches(data, 'tui.select.down')) { + this.selectedIndex = (this.selectedIndex + 1) % themes.length; + } else if (matchesKey(data, 'a')) { + this.appearance = cycleAppearance(this.appearance); + this.options.setAppearance(this.appearance); + } else { + return; + } + const focused = themes[this.selectedIndex]; + if (focused) this.options.preview(focused.id); + this.options.requestRender(); + } + + dispose(): void { + this.disposed = true; + } + + invalidate(): void {} + + render(width: number): string[] { + return this.renderViewport(width, 20); + } + + renderViewport(width: number, height: number): string[] { + const rows = Math.max(0, Math.floor(height)); + if (width <= 0 || rows === 0) return []; + const themes = this.options.themes; + const layout = panelLayout(width, rows, footerText(width, this.busy)); + if (themes.length === 0) { + return renderPanelFrame( + { title: 'Theme', body: ['No themes available.'], footer: footerText(width, false) }, + width, + rows, + ); + } + const focused = themes[Math.min(this.selectedIndex, themes.length - 1)]; + if (!focused) return []; + const active = focused.id === this.originalThemeId; + const nameWidth = Math.max( + ...themes.map((theme) => visibleWidth(theme.label)), + visibleWidth('Appearance'), + ); + const swatch = renderSwatch(focused, this.resolvedAppearance()); + const detail = [ + `${chalk.hex(colors.muted)('Appearance')} ${appearanceLabel(this.appearance, this.options.currentAppearance)}`, + `${chalk.hex(colors.muted)('Source')} ${focused.source === 'custom' ? 'custom file' : 'built-in'}`, + ...(focused.description + ? [`${chalk.hex(colors.muted)('About')} ${focused.description}`] + : []), + `${chalk.hex(colors.muted)('Palette')} ${swatch}`, + ]; + if (this.error) detail.push(chalk.hex(colors.error)('Could not save the theme selection.')); + + if (rows < 10) { + return layout.render({ + title: 'Theme', + body: [ + renderRow( + focused.label, + active, + this.options.currentThemeId === focused.id, + nameWidth, + this.resolvedAppearance(), + focused, + ), + ...(layout.bodyHeight >= 4 ? detail.slice(0, 2) : []), + ], + }); + } + + const listRows = Math.max(1, Math.min(themes.length, layout.bodyHeight - detail.length - 1)); + const start = Math.max( + 0, + Math.min(this.selectedIndex - listRows + 1, themes.length - listRows), + ); + return layout.render({ + title: 'Theme', + meta: `${themes.length} available`, + body: [ + ...themes + .slice(start, start + listRows) + .map((theme) => + renderRow( + theme.label, + theme.id === this.originalThemeId, + theme.id === focused.id, + nameWidth, + this.resolvedAppearance(), + theme, + ), + ), + ...detail, + ], + }); + } + + private resolvedAppearance(): TuiResolvedAppearance { + return this.appearance === 'auto' ? this.options.currentAppearance : this.appearance; + } + + private restore(): void { + const theme = this.options.themes.find((candidate) => candidate.id === this.originalThemeId); + if (theme) this.options.preview(this.originalThemeId); + this.appearance = this.originalAppearance; + this.options.setAppearance(this.originalAppearance); + } + + private async commit(): Promise { + const theme = this.options.themes[this.selectedIndex]; + if (!theme) return; + if (theme.id === this.originalThemeId && this.appearance === this.originalAppearance) { + this.options.onClose(); + return; + } + this.busy = true; + this.error = false; + this.options.requestRender(); + try { + await this.options.save(theme.id, this.appearance); + this.originalThemeId = theme.id; + this.originalAppearance = this.appearance; + if (!this.disposed) this.options.onClose(); + } catch { + this.error = true; + } finally { + this.busy = false; + if (!this.disposed) this.options.requestRender(); + } + } +} + +function cycleAppearance(current: TuiThemeAppearanceChoice): TuiThemeAppearanceChoice { + if (current === 'auto') return 'light'; + if (current === 'light') return 'dark'; + return 'auto'; +} + +function appearanceLabel( + choice: TuiThemeAppearanceChoice, + detected: TuiResolvedAppearance, +): string { + if (choice === 'auto') return `auto (${detected})`; + return choice === 'light' ? 'light (pinned)' : 'dark (pinned)'; +} + +function renderSwatch(theme: TuiThemeDefinition, appearance: TuiResolvedAppearance): string { + const palette = appearance === 'light' ? theme.light : theme.dark; + const roles = [ + 'brand', + 'accent', + 'markdownHeading', + 'markdownCode', + 'success', + 'warning', + 'error', + ] as const satisfies readonly (keyof TuiThemeColors)[]; + return roles.map((role) => chalk.bgHex(palette.colors[role])(' ')).join(''); +} + +function renderRow( + label: string, + saved: boolean, + focused: boolean, + nameWidth: number, + appearance: TuiResolvedAppearance, + theme: TuiThemeDefinition, +): string { + const marker = focused ? '›' : ' '; + const name = focused ? chalk.bold.hex(colors.signal)(label) : chalk.hex(colors.text)(label); + // Pad both columns so the swatches line up regardless of which row is current. + const pad = ' '.repeat(Math.max(0, nameWidth - visibleWidth(label))); + const badge = saved ? chalk.hex(colors.success)(CURRENT_BADGE.trim()) : ''; + const badgePad = ' '.repeat(Math.max(0, visibleWidth(CURRENT_BADGE) - visibleWidth(badge))); + return `${marker} ${name}${pad} ${badge}${badgePad} ${renderSwatch(theme, appearance)}`; +} + +const CURRENT_BADGE = ' current '; + +function footerText(width: number, busy: boolean): string { + if (busy) return 'Saving…'; + return width >= 64 + ? '↑↓ preview · a appearance · Enter save · Esc cancel' + : '↑↓ · a · Enter · Esc'; +} diff --git a/packages/tui/src/tui/launcher.ts b/packages/tui/src/tui/launcher.ts index 890d721c..19dd6e0b 100644 --- a/packages/tui/src/tui/launcher.ts +++ b/packages/tui/src/tui/launcher.ts @@ -58,7 +58,12 @@ import type { McodeUpdateApplication } from '../update/application.js'; import { tuiErrorDiagnostic } from '../user-facing-failure.js'; import { getConfig, resetConfig, writeTuiStatusLineSetting, type MavisRegion } from '@mavis/config'; import { markLoginRestartHandoff } from './login-restart-handoff.js'; -import { readTuiModeSetting, writeTuiModeSetting } from '../host/tui-settings.js'; +import { + readTuiModeSetting, + readTuiThemeSetting, + writeTuiModeSetting, + writeTuiThemeSetting, +} from '../host/tui-settings.js'; import { schedulePendingMcodePrefixUpdate } from '../update/prefix-update.js'; import { MCODE_TUI_RESULT_PATH_ENV } from './automation/result-writer.js'; import { startTuiStartupStatus, type TuiStartupStatus } from './startup-status.js'; @@ -77,6 +82,7 @@ export interface LaunchTuiOptions { dataDir?: string; terminal?: Terminal; tuiMode?: TuiMode; + theme?: string; externalEditorCommand?: string; resumeDraftAfterLogin?: boolean; lane?: string; @@ -131,6 +137,8 @@ export interface LaunchTuiDependencies { createIncidentReporter?: typeof createTuiIncidentReporter; readTuiMode?: typeof readTuiModeSetting; writeTuiMode?: typeof writeTuiModeSetting; + readTuiTheme?: typeof readTuiThemeSetting; + writeTuiTheme?: typeof writeTuiThemeSetting; createSharedAuthSession?: typeof createMcodeSharedAuthSession; createAuthApplication?: typeof createDefaultMcodeAuthApplication; } @@ -168,6 +176,7 @@ export async function launchTui( const dataDir = options.dataDir ?? (await (dependencies.prepareDataDir ?? prepareTuiDataDir)()); const baseTerminal = options.terminal ?? new ProcessTerminal(); const tuiMode = options.tuiMode ?? (dependencies.readTuiMode ?? readTuiModeSetting)(dataDir); + const theme = options.theme ?? (dependencies.readTuiTheme ?? readTuiThemeSetting)(dataDir); const terminalCapabilities = detectProcessTerminalCapabilities(); const authEnvironment = resolveMcodeAuthEnvironment({ runtimeRegion: process.env.MAVIS_REGION === 'en' ? 'en' : 'cn', @@ -398,6 +407,9 @@ export async function launchTui( terminal, tuiMode, persistTuiMode: (mode) => (dependencies.writeTuiMode ?? writeTuiModeSetting)(dataDir, mode), + ...(theme ? { theme } : {}), + persistTheme: (value) => + (dependencies.writeTuiTheme ?? writeTuiThemeSetting)(dataDir, value), persistStatusLineItems: (items) => writeTuiStatusLineSetting(dataDir, items), externalEditorCommand: options.externalEditorCommand, observability, diff --git a/packages/tui/src/tui/theme/contracts.ts b/packages/tui/src/tui/theme/contracts.ts index 862bd89a..f8067c61 100644 --- a/packages/tui/src/tui/theme/contracts.ts +++ b/packages/tui/src/tui/theme/contracts.ts @@ -32,12 +32,97 @@ export interface TuiThemeColors { readonly error: string; } +/** + * Syntax tones drive `cli-highlight` token mapping. They are kept beside — not + * inside — {@link TuiThemeColors} so terminal ANSI16 role resolution keeps + * iterating a flat list of paintable colors. + */ +export interface TuiThemeSyntaxTones { + readonly blue: string; + readonly flamingo: string; + readonly green: string; + readonly mauve: string; + readonly overlay2: string; + readonly peach: string; + readonly pink: string; + readonly red: string; + readonly sapphire: string; + readonly subtext0: string; + readonly teal: string; + readonly text: string; + readonly yellow: string; +} + +export const TUI_SYNTAX_TONE_NAMES = [ + 'blue', + 'flamingo', + 'green', + 'mauve', + 'overlay2', + 'peach', + 'pink', + 'red', + 'sapphire', + 'subtext0', + 'teal', + 'text', + 'yellow', +] as const satisfies readonly (keyof TuiThemeSyntaxTones)[]; + +export type TuiThemeSyntaxTone = (typeof TUI_SYNTAX_TONE_NAMES)[number]; + +export const TUI_THEME_COLOR_NAMES = [ + 'brand', + 'wordmarkHighlight', + 'wordmarkShadow', + 'signal', + 'orbit', + 'accent', + 'markdownHeading', + 'markdownCode', + 'markdownLink', + 'userMessageBg', + 'diffAddedBg', + 'diffRemovedBg', + 'text', + 'muted', + 'dim', + 'border', + 'line', + 'success', + 'warning', + 'error', +] as const satisfies readonly (keyof TuiThemeColors)[]; + +export type TuiThemeColorName = (typeof TUI_THEME_COLOR_NAMES)[number]; + export interface TuiThemePalette { readonly id: string; readonly appearance: TuiResolvedAppearance; readonly colors: TuiThemeColors; + readonly syntax: TuiThemeSyntaxTones; +} + +export type TuiThemeSource = 'builtin' | 'custom'; + +/** + * A user-selectable named theme. Every theme ships both appearances so a + * terminal that switches to light never falls back to a foreign palette. + */ +export interface TuiThemeDefinition { + readonly id: string; + readonly label: string; + readonly description?: string; + readonly source: TuiThemeSource; + readonly dark: TuiThemePalette; + readonly light: TuiThemePalette; + /** Absolute path for custom themes, used to offer file-level feedback. */ + readonly filePath?: string; + /** Validation diagnostics for partially loaded custom themes. */ + readonly issues?: readonly string[]; } export interface TuiThemeSnapshot extends TuiThemeDetection { readonly colorLevel: TuiColorLevel; + readonly themeId: string; } diff --git a/packages/tui/src/tui/theme/controller.ts b/packages/tui/src/tui/theme/controller.ts index c7063140..ca7d3f3c 100644 --- a/packages/tui/src/tui/theme/controller.ts +++ b/packages/tui/src/tui/theme/controller.ts @@ -1,12 +1,20 @@ -import { applyTuiRenderTheme } from './runtime.js'; +import { applyTuiRenderTheme, paletteSignature } from './runtime.js'; import type { TuiColorLevel, TuiResolvedAppearance, + TuiThemeDefinition, TuiThemeDetection, TuiThemeSnapshot, } from './contracts.js'; import { type RgbColor, appearanceFromRgb, resolveEnvironmentAppearance } from './detection.js'; -import { MINIMAX_CODE_DARK_THEME, MINIMAX_CODE_LIGHT_THEME } from './palettes.js'; +import { DEFAULT_THEME_ID } from './palettes.js'; +import { + loadCustomThemes, + watchCustomThemes, + type TuiThemeLoadIssue, + type TuiThemeLoadResult, +} from './custom-themes.js'; +import { TuiThemeRegistry } from './registry.js'; import type { TUI } from '../engine/public.js'; export type TuiThemeUi = Pick< @@ -21,7 +29,12 @@ export interface TuiThemeControllerOptions { readonly colorLevel: TuiColorLevel; readonly env?: Readonly>; readonly queryTimeoutMs?: number; + /** dataDir enables custom theme discovery and hot reload. */ + readonly dataDir?: string; + /** Persisted or CLI-supplied selection, e.g. `aurora` or `aurora/dark`. */ + readonly theme?: string; readonly onDetection?: (detection: TuiThemeSnapshot) => void; + readonly onThemesChanged?: (themes: readonly TuiThemeDefinition[]) => void; } export class TuiThemeController { @@ -30,9 +43,12 @@ export class TuiThemeController { private readonly env: Readonly>; private readonly queryTimeoutMs: number; private readonly onDetection: ((detection: TuiThemeSnapshot) => void) | undefined; + private readonly onThemesChanged: ((themes: readonly TuiThemeDefinition[]) => void) | undefined; + private readonly registry = new TuiThemeRegistry(); private state: TuiThemeSnapshot; private readonly listeners = new Set<(snapshot: TuiThemeSnapshot) => void>(); private stopTracking: (() => void) | undefined; + private stopThemeWatch: (() => void) | undefined; private refreshSequence = 0; private started = false; /** @@ -40,6 +56,12 @@ export class TuiThemeController { * Session, so it outranks `COLORFGBG`, which is only a process-start snapshot. */ private reportedAppearance: TuiResolvedAppearance | undefined; + /** When set, auto-detection stops overriding the appearance. */ + private appearanceOverride: TuiResolvedAppearance | undefined; + /** Latest terminal/env verdict, never replaced by {@link appearanceOverride}. */ + private latestDetection: TuiThemeDetection; + /** Signature of the palette currently bound to the render layer. */ + private activeSignature = ''; constructor(options: TuiThemeControllerOptions) { this.ui = options.ui; @@ -47,10 +69,29 @@ export class TuiThemeController { this.env = options.env ?? process.env; this.queryTimeoutMs = options.queryTimeoutMs ?? 250; this.onDetection = options.onDetection; + this.onThemesChanged = options.onThemesChanged; + + if (options.dataDir) { + this.applyLoadResult(loadCustomThemes(options.dataDir)); + this.startThemeWatch(options.dataDir); + } + const selection = this.registry.resolveSelection(options.theme); + this.appearanceOverride = selection.appearanceOverride; + const theme = this.registry.select(selection.themeId) ?? this.registry.selected(); + + const detected = resolveEnvironmentAppearance(this.env); + // Keep the terminal's own verdict separate from the pinned appearance so + // clearing the pin can restore it without waiting for another query. + this.latestDetection = detected; this.state = { - ...resolveEnvironmentAppearance(this.env), + ...detected, + // A pinned appearance must win on the very first frame, not only after the + // first terminal query resolves. + appearance: this.appearanceOverride ?? detected.appearance, colorLevel: this.colorLevel, + themeId: theme.id, }; + this.activeSignature = paletteSignature(this.registry.paletteFor(this.state.appearance)); this.onDetection?.(this.snapshot()); this.applyRenderTheme(); } @@ -64,6 +105,53 @@ export class TuiThemeController { return () => this.listeners.delete(listener); } + listThemes(): readonly TuiThemeDefinition[] { + return this.registry.list(); + } + + themeIssues(): readonly TuiThemeLoadIssue[] { + return this.registry.issuesList(); + } + + selectedThemeId(): string { + return this.registry.selectedIdValue(); + } + + /** + * Apply a theme by id without persisting it. Returns the resolved definition, + * or `undefined` when the id is unknown so the caller can keep the previous + * theme and report the failure. + */ + setTheme(id: string): TuiThemeDefinition | undefined { + const resolved = this.registry.select(id); + if (!resolved) return undefined; + this.commitTheme(); + return resolved; + } + + /** Switch the active palette without touching the saved selection. */ + previewTheme(id: string): TuiThemeDefinition | undefined { + const resolved = this.registry.select(id); + if (!resolved) return undefined; + this.applyRenderTheme(); + this.emit(); + return resolved; + } + + /** + * Pin the appearance (`light` / `dark`) or return to terminal-driven + * detection when `value` is undefined. + */ + setAppearanceOverride(value: string | undefined): TuiResolvedAppearance | undefined { + this.appearanceOverride = value === 'light' || value === 'dark' ? value : undefined; + this.commitDetection(); + return this.appearanceOverride; + } + + appearanceOverrideValue(): TuiResolvedAppearance | undefined { + return this.appearanceOverride; + } + async start(): Promise { if (!this.started) { this.started = true; @@ -89,7 +177,44 @@ export class TuiThemeController { .queryTerminalBackgroundColor({ timeoutMs: this.queryTimeoutMs }) .catch(() => undefined); if (sequence !== this.refreshSequence) return; - this.applyDetection(this.resolveDetection(background)); + this.commitDetection(this.resolveDetection(background)); + } + + private commitDetection(detection?: TuiThemeDetection): void { + if (detection) this.latestDetection = detection; + const base = this.latestDetection; + const appearance = this.appearanceOverride ?? base.appearance; + const next: TuiThemeSnapshot = { + ...base, + // A pinned appearance wins over terminal evidence. + appearance, + colorLevel: this.colorLevel, + themeId: this.registry.selectedIdValue(), + }; + + // Compare the palette content, not just its id: reloading a custom theme + // file in place keeps the same id while every color may have changed. + const signature = paletteSignature(this.registry.paletteFor(appearance)); + const renderingChanged = + signature !== this.activeSignature || + next.colorLevel !== this.state.colorLevel || + next.themeId !== this.state.themeId; + this.state = next; + this.onDetection?.(this.snapshot()); + if (!renderingChanged) return; + this.activeSignature = signature; + this.applyRenderTheme(); + this.emit(); + } + + private commitTheme(): void { + this.commitDetection(); + } + + private applyLoadResult(result: TuiThemeLoadResult): void { + this.registry.applyLoadResult(result); + this.stopThemeWatch?.(); + this.onThemesChanged?.(this.registry.list()); } /** @@ -121,29 +246,22 @@ export class TuiThemeController { this.refreshSequence += 1; this.stopTracking?.(); this.stopTracking = undefined; + this.stopThemeWatch?.(); + this.stopThemeWatch = undefined; this.ui.setTerminalColorSchemeNotifications(false); this.listeners.clear(); } - private applyDetection(detection: TuiThemeDetection): void { - const next: TuiThemeSnapshot = { - ...detection, - colorLevel: this.colorLevel, - }; - const renderingChanged = - next.appearance !== this.state.appearance || next.colorLevel !== this.state.colorLevel; - this.state = next; - this.onDetection?.(this.snapshot()); - if (!renderingChanged) return; - this.applyRenderTheme(); - this.emit(); + private startThemeWatch(dataDir: string): void { + this.stopThemeWatch = watchCustomThemes(dataDir, (result) => { + this.registry.applyLoadResult(result); + this.onThemesChanged?.(this.registry.list()); + this.commitDetection(); + }); } private applyRenderTheme(): void { - applyTuiRenderTheme( - this.state.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME, - this.state.colorLevel, - ); + applyTuiRenderTheme(this.registry.paletteFor(this.state.appearance), this.state.colorLevel); } private emit(): void { @@ -158,3 +276,5 @@ export class TuiThemeController { }); } } + +export { DEFAULT_THEME_ID }; diff --git a/packages/tui/src/tui/theme/custom-themes.ts b/packages/tui/src/tui/theme/custom-themes.ts new file mode 100644 index 00000000..453a8c72 --- /dev/null +++ b/packages/tui/src/tui/theme/custom-themes.ts @@ -0,0 +1,300 @@ +import { readdirSync, readFileSync, statSync, watch, type FSWatcher } from 'node:fs'; +import path from 'node:path'; +import { Ajv, type ValidateFunction } from 'ajv'; +import { + TUI_SYNTAX_TONE_NAMES, + TUI_THEME_COLOR_NAMES, + type TuiResolvedAppearance, + type TuiThemeColors, + type TuiThemeDefinition, + type TuiThemePalette, + type TuiThemeSyntaxTones, +} from './contracts.js'; +import { BUILT_IN_THEMES, DEFAULT_THEME_ID, defaultPalette } from './palettes.js'; + +const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/iu; +const THEME_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/iu; + +/** + * A color entry is a hex literal, the empty string for "terminal default", or + * a `vars` name. References are resolved after schema validation, so the schema + * only has to accept the identifier shape; an unresolvable name fails with a + * readable message from {@link resolveValue}. + */ +const COLOR_SCHEMA = { + anyOf: [ + { type: 'string', pattern: '^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$' }, + { type: 'string', maxLength: 0 }, + { type: 'string', pattern: '^[A-Za-z_][A-Za-z0-9_-]*$' }, + ], +} as const; + +/** + * `vars` holds literal colors only. Allowing an identifier here would let a + * nested reference pass validation and then fail during resolution with a + * confusing message, so reject it up front instead. + */ +const VAR_SCHEMA = { + anyOf: [ + { type: 'string', pattern: '^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$' }, + { type: 'string', maxLength: 0 }, + ], +} as const; + +const THEME_FILE_SCHEMA = { + type: 'object', + required: ['name', 'appearance', 'colors'], + additionalProperties: false, + properties: { + $schema: { type: 'string' }, + name: { type: 'string', pattern: THEME_ID.source }, + label: { type: 'string', minLength: 1, maxLength: 40 }, + description: { type: 'string', maxLength: 120 }, + appearance: { enum: ['dark', 'light'] }, + vars: { + type: 'object', + additionalProperties: VAR_SCHEMA, + }, + colors: { + type: 'object', + additionalProperties: false, + properties: Object.fromEntries(TUI_THEME_COLOR_NAMES.map((name) => [name, COLOR_SCHEMA])), + }, + syntax: { + type: 'object', + additionalProperties: false, + properties: Object.fromEntries(TUI_SYNTAX_TONE_NAMES.map((name) => [name, COLOR_SCHEMA])), + }, + }, +} as const; + +interface CustomThemeDocument { + readonly name: string; + readonly label?: string; + readonly description?: string; + readonly appearance: TuiResolvedAppearance; + readonly vars?: Readonly>; + readonly colors: Readonly>>; + readonly syntax?: Readonly>; +} + +export interface TuiThemeLoadIssue { + readonly filePath: string; + readonly message: string; +} + +export interface TuiThemeLoadResult { + readonly themes: readonly TuiThemeDefinition[]; + readonly issues: readonly TuiThemeLoadIssue[]; +} + +/** Directory MCode scans for user-authored theme files. */ +export function customThemesDirectory(dataDir: string): string { + return path.join(dataDir, 'tui', 'themes'); +} + +let cachedValidator: ValidateFunction | undefined; + +function themeFileValidator(): ValidateFunction { + if (cachedValidator) return cachedValidator; + const validator = new Ajv({ allErrors: true, strict: false }).compile(THEME_FILE_SCHEMA); + cachedValidator = validator; + return validator; +} + +interface StagedVariant extends TuiThemePalette { + readonly filePath: string; +} + +/** + * Resolve one `colors` / `syntax` value: an explicit hex, a `vars` reference, + * or the empty string meaning "terminal default". + */ +function resolveValue( + value: string, + vars: Readonly>, + fallback: string, + filePath: string, +): string { + if (value === '') return ''; + if (HEX_COLOR.test(value)) return value.toUpperCase(); + const referenced = vars[value]; + if (referenced === undefined) { + throw new Error(`"${value}" is not a hex color or a name defined in "vars"`); + } + if (referenced === '') return ''; + if (!HEX_COLOR.test(referenced)) { + throw new Error(`vars.${value} = "${referenced}" is not a hex color`); + } + void filePath; + return referenced.toUpperCase(); +} + +function buildVariant(document: CustomThemeDocument, filePath: string): StagedVariant { + const vars = document.vars ?? {}; + const base = defaultPalette(document.appearance); + const colors = { ...base.colors } as Record; + for (const [key, value] of Object.entries(document.colors)) { + const role = key as keyof TuiThemeColors; + const raw = value as string; + colors[role] = resolveValue(raw, vars, colors[role], filePath) || colors[role]; + } + const syntax = { ...base.syntax } as Record; + for (const [key, value] of Object.entries(document.syntax ?? {})) { + const tone = key as keyof TuiThemeSyntaxTones; + syntax[tone] = resolveValue(value as string, vars, syntax[tone], filePath) || syntax[tone]; + } + return { + id: document.name, + appearance: document.appearance, + colors: Object.freeze(colors) as TuiThemeColors, + syntax: Object.freeze(syntax) as TuiThemeSyntaxTones, + filePath, + }; +} + +function readThemeFile(filePath: string): CustomThemeDocument | Error { + try { + const raw = JSON.parse(readFileSync(filePath, 'utf8').replace(/^\uFEFF/u, '')) as unknown; + const validate = themeFileValidator(); + if (!validate(raw)) { + const detail = (validate.errors ?? []) + .slice(0, 4) + .map((error) => `${error.instancePath || '/'} ${error.message ?? 'is invalid'}`.trim()) + .join('; '); + return new Error(detail || 'does not match the theme schema'); + } + return raw as CustomThemeDocument; + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } +} + +/** + * Load every `*.json` under `/tui/themes`. + * + * A theme is assembled from one file per appearance: `aurora.json` plus + * `aurora-light.json` form a single selectable `aurora` theme. Any appearance a + * custom theme does not provide falls back to the default palette so selecting + * the theme never breaks rendering on a terminal of the other appearance. + * Built-in ids always win over a same-named file so a stray file cannot shadow + * a shipped theme. + */ +export function loadCustomThemes(dataDir: string): TuiThemeLoadResult { + const directory = customThemesDirectory(dataDir); + let entries: string[]; + try { + entries = readdirSync(directory); + } catch { + return { themes: [], issues: [] }; + } + + const builtinIds = new Set(BUILT_IN_THEMES.map((theme) => theme.id)); + const staged = new Map< + string, + { label?: string; description?: string; variants: StagedVariant[] } + >(); + const issues: TuiThemeLoadIssue[] = []; + + for (const entry of entries.sort()) { + if (!entry.toLowerCase().endsWith('.json')) continue; + const filePath = path.join(directory, entry); + try { + if (!statSync(filePath).isFile()) continue; + } catch { + continue; + } + const document = readThemeFile(filePath); + if (document instanceof Error) { + issues.push({ filePath, message: document.message }); + continue; + } + if (builtinIds.has(document.name)) { + issues.push({ + filePath, + message: `"${document.name}" is a built-in theme id and is ignored`, + }); + continue; + } + let variant: StagedVariant; + try { + variant = buildVariant(document, filePath); + } catch (error) { + issues.push({ filePath, message: error instanceof Error ? error.message : String(error) }); + continue; + } + const group = staged.get(document.name) ?? { variants: [] }; + if (document.label) group.label = document.label; + if (document.description) group.description = document.description; + const existing = group.variants.findIndex( + (candidate) => candidate.appearance === variant.appearance, + ); + if (existing >= 0) group.variants.splice(existing, 1, variant); + else group.variants.push(variant); + staged.set(document.name, group); + } + + const themes: TuiThemeDefinition[] = []; + for (const [id, group] of staged) { + const supplied = new Map(group.variants.map((variant) => [variant.appearance, variant])); + const pick = (appearance: TuiResolvedAppearance): TuiThemePalette => { + const variant = supplied.get(appearance); + if (variant) return variant; + // The file did not supply this appearance; borrow the default palette so + // selecting the theme still renders correctly on a terminal of that kind. + return { ...defaultPalette(appearance), id }; + }; + themes.push( + Object.freeze({ + id, + label: group.label ?? id, + ...(group.description ? { description: group.description } : {}), + source: 'custom', + dark: pick('dark'), + light: pick('light'), + filePath: group.variants[0]?.filePath, + }), + ); + } + + return { themes, issues }; +} + +/** + * Watch the custom theme directory and re-run the loader on change so editing a + * theme file updates the TUI without a restart. The watcher is a convenience: + * a failure to watch (missing directory, inotify limit) must never block the + * TUI, so errors collapse into a no-op. + */ +export function watchCustomThemes( + dataDir: string, + onChange: (result: TuiThemeLoadResult) => void, +): () => void { + const directory = customThemesDirectory(dataDir); + let watcher: FSWatcher | undefined; + let timer: NodeJS.Timeout | undefined; + const fire = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = undefined; + try { + onChange(loadCustomThemes(dataDir)); + } catch { + // A reload failure must never break the running session. + } + }, 150); + timer.unref?.(); + }; + try { + watcher = watch(directory, { persistent: false }, fire); + watcher.on('error', () => undefined); + } catch { + watcher = undefined; + } + return () => { + if (timer) clearTimeout(timer); + watcher?.close(); + }; +} + +export { DEFAULT_THEME_ID }; diff --git a/packages/tui/src/tui/theme/palettes.ts b/packages/tui/src/tui/theme/palettes.ts index bcdcfeaf..506be33e 100644 --- a/packages/tui/src/tui/theme/palettes.ts +++ b/packages/tui/src/tui/theme/palettes.ts @@ -1,9 +1,33 @@ -import type { TuiThemePalette } from './contracts.js'; +import type { + TuiResolvedAppearance, + TuiThemeColors, + TuiThemeDefinition, + TuiThemeSyntaxTones, +} from './contracts.js'; +import { CATPPUCCIN_SYNTAX_TONES } from './syntax.js'; -export const MINIMAX_CODE_DARK_THEME: TuiThemePalette = Object.freeze({ - id: 'minimax', - appearance: 'dark', - colors: Object.freeze({ +function palette( + id: string, + appearance: TuiResolvedAppearance, + colors: TuiThemeColors, + syntax: TuiThemeSyntaxTones, +) { + return Object.freeze({ + id, + appearance, + colors: Object.freeze(colors), + syntax: Object.freeze(syntax), + }); +} + +/** + * Default MCode palette. The values are frozen in place so a theme switch can + * never mutate a palette another theme still references. + */ +export const MINIMAX_CODE_DARK_THEME = palette( + 'minimax', + 'dark', + { brand: '#68C0FF', wordmarkHighlight: '#93D2FF', wordmarkShadow: '#3DAEFF', @@ -24,13 +48,14 @@ export const MINIMAX_CODE_DARK_THEME: TuiThemePalette = Object.freeze({ success: '#28C567', warning: '#FFC340', error: '#FF5E6C', - }), -}); + }, + CATPPUCCIN_SYNTAX_TONES.dark, +); -export const MINIMAX_CODE_LIGHT_THEME: TuiThemePalette = Object.freeze({ - id: 'minimax', - appearance: 'light', - colors: Object.freeze({ +export const MINIMAX_CODE_LIGHT_THEME = palette( + 'minimax', + 'light', + { brand: '#0094FC', wordmarkHighlight: '#3DAEFF', wordmarkShadow: '#0077D9', @@ -51,5 +76,314 @@ export const MINIMAX_CODE_LIGHT_THEME: TuiThemePalette = Object.freeze({ success: '#008635', warning: '#916300', error: '#E31937', - }), + }, + CATPPUCCIN_SYNTAX_TONES.light, +); + +/** Default palette for the active appearance. */ +export function defaultPalette(appearance: TuiResolvedAppearance) { + return appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME; +} + +// --------------------------------------------------------------------------- +// Built-in named themes +// --------------------------------------------------------------------------- + +/** + * Midnight keeps the MCode blue but deepens the background toward a blue-black + * and lifts foreground contrast, which suits high-DPI and OLED terminals. + */ +const MIDNIGHT: TuiThemeDefinition = Object.freeze({ + id: 'midnight', + label: 'Midnight', + description: 'Deep blue-black with lifted contrast', + source: 'builtin', + dark: palette( + 'midnight', + 'dark', + { + brand: '#5AB9FF', + wordmarkHighlight: '#8FD0FF', + wordmarkShadow: '#3A9BE0', + signal: '#5AB9FF', + orbit: '#2AD4DE', + accent: '#5AB9FF', + markdownHeading: '#B79CFF', + markdownCode: '#7EE787', + markdownLink: '#5AB9FF', + userMessageBg: '#141A22', + diffAddedBg: '#12301F', + diffRemovedBg: '#3D1A1A', + text: '#E6EDF3', + muted: '#9AA7B4', + dim: '#6B7785', + border: '#232C36', + line: '#7D8B99', + success: '#3FB950', + warning: '#E3B341', + error: '#F85149', + }, + { + blue: '#79C0FF', + flamingo: '#F0B7B0', + green: '#7EE787', + mauve: '#D2A8FF', + overlay2: '#8B949E', + peach: '#FFA657', + pink: '#F778BA', + red: '#FF7B72', + sapphire: '#A5D6FF', + subtext0: '#B1BAC4', + teal: '#39C5CF', + text: '#C9D1D9', + yellow: '#D29922', + }, + ), + light: palette( + 'midnight', + 'light', + { + brand: '#0A6FCE', + wordmarkHighlight: '#3DAEFF', + wordmarkShadow: '#07599F', + signal: '#0A6FCE', + orbit: '#0B7A82', + accent: '#0A6FCE', + markdownHeading: '#7A3FD1', + markdownCode: '#1A7F37', + markdownLink: '#0550AE', + userMessageBg: '#F2F5F9', + diffAddedBg: '#DDFBE4', + diffRemovedBg: '#FFE7E5', + text: '#1F2933', + muted: '#52606D', + dim: '#7B8794', + border: '#E4E9EF', + line: '#6B7785', + success: '#0F7B33', + warning: '#8A6100', + error: '#C21F39', + }, + { + blue: '#0550AE', + flamingo: '#B3594F', + green: '#116329', + mauve: '#8250DF', + overlay2: '#6E7781', + peach: '#953800', + pink: '#BF3989', + red: '#CF222E', + sapphire: '#1F6FEB', + subtext0: '#57606A', + teal: '#137C8B', + text: '#24292F', + yellow: '#9A6700', + }, + ), +}); + +/** Graphite strips most chroma from surfaces so dense logs stay calm. */ +const GRAPHITE: TuiThemeDefinition = Object.freeze({ + id: 'graphite', + label: 'Graphite', + description: 'Neutral low-chroma surfaces for dense output', + source: 'builtin', + dark: palette( + 'graphite', + 'dark', + { + brand: '#68C0FF', + wordmarkHighlight: '#9BD8FF', + wordmarkShadow: '#3D9AD6', + signal: '#68C0FF', + orbit: '#5AC8D2', + accent: '#68C0FF', + markdownHeading: '#C9D1D9', + markdownCode: '#A5D6A7', + markdownLink: '#68C0FF', + userMessageBg: '#22262B', + diffAddedBg: '#1B3324', + diffRemovedBg: '#3B2426', + text: '#DDE1E6', + muted: '#A8B0B8', + dim: '#71797F', + border: '#2E3338', + line: '#848C94', + success: '#3FB463', + warning: '#E0A92E', + error: '#F2606B', + }, + { + blue: '#7FB8E8', + flamingo: '#D9A0A8', + green: '#9CCFA5', + mauve: '#B5AEDA', + overlay2: '#8A9199', + peach: '#D9A97E', + pink: '#D5A8C4', + red: '#E08087', + sapphire: '#8FC9E0', + subtext0: '#AEB5BC', + teal: '#7FC5CB', + text: '#D7DBDF', + yellow: '#D6C07A', + }, + ), + light: palette( + 'graphite', + 'light', + { + brand: '#0B6FB8', + wordmarkHighlight: '#3DAEFF', + wordmarkShadow: '#07558F', + signal: '#0B6FB8', + orbit: '#0C7680', + accent: '#0B6FB8', + markdownHeading: '#3D444D', + markdownCode: '#1F7A3D', + markdownLink: '#0A5C9E', + userMessageBg: '#F4F5F6', + diffAddedBg: '#E2F3E6', + diffRemovedBg: '#FBE6E7', + text: '#2B3036', + muted: '#565E66', + dim: '#7C848C', + border: '#E6E8EA', + line: '#767E86', + success: '#0F7033', + warning: '#835A00', + error: '#B5202E', + }, + { + blue: '#2C6FAF', + flamingo: '#9E5A5F', + green: '#2F7A42', + mauve: '#6A5AA8', + overlay2: '#6D747B', + peach: '#96602A', + pink: '#9A4E80', + red: '#B23A44', + sapphire: '#2A7F9E', + subtext0: '#5B636B', + teal: '#237A82', + text: '#2B3036', + yellow: '#8A6D1F', + }, + ), }); + +/** Aurora pushes the secondary ramp toward cyan and mint. */ +const AURORA: TuiThemeDefinition = Object.freeze({ + id: 'aurora', + label: 'Aurora', + description: 'Cool cyan and mint secondary ramp', + source: 'builtin', + dark: palette( + 'aurora', + 'dark', + { + brand: '#5CC8E8', + wordmarkHighlight: '#8FE0F5', + wordmarkShadow: '#38A5C4', + signal: '#5CC8E8', + orbit: '#5FE3B0', + accent: '#5CC8E8', + markdownHeading: '#8FD9C0', + markdownCode: '#7BE0B4', + markdownLink: '#5CC8E8', + userMessageBg: '#16232A', + diffAddedBg: '#113028', + diffRemovedBg: '#3A1F26', + text: '#DCE9EE', + muted: '#93AAB3', + dim: '#647D86', + border: '#22333B', + line: '#7E99A3', + success: '#4FD18B', + warning: '#E8C15A', + error: '#F2788A', + }, + { + blue: '#6FC7E8', + flamingo: '#E8A9A0', + green: '#6FE0AE', + mauve: '#8FC9D9', + overlay2: '#7E99A3', + peach: '#E8B88C', + pink: '#E5A8CE', + red: '#F2788A', + sapphire: '#5AD4D4', + subtext0: '#A8C0C9', + teal: '#5FE3B0', + text: '#DCE9EE', + yellow: '#E8D08A', + }, + ), + light: palette( + 'aurora', + 'light', + { + brand: '#0A6E8C', + wordmarkHighlight: '#2FA8C9', + wordmarkShadow: '#07536A', + signal: '#0A6E8C', + orbit: '#0C7A5C', + accent: '#0A6E8C', + markdownHeading: '#0F6B57', + markdownCode: '#0F7350', + markdownLink: '#075C78', + userMessageBg: '#F1F6F8', + diffAddedBg: '#DDF3E9', + diffRemovedBg: '#FBE7EC', + text: '#1E2B31', + muted: '#4E646D', + dim: '#758D96', + border: '#E2EBEE', + line: '#6B8590', + success: '#0E7A52', + warning: '#856000', + error: '#B32B45', + }, + { + blue: '#0E6E8C', + flamingo: '#9E5A56', + green: '#0F7350', + mauve: '#0F6A72', + overlay2: '#6B8590', + peach: '#8A5A28', + pink: '#8E4470', + red: '#B32B45', + sapphire: '#0A7A7D', + subtext0: '#5A737D', + teal: '#0C7A5C', + text: '#1E2B31', + yellow: '#7A6418', + }, + ), +}); + +export const DEFAULT_THEME_ID = 'minimax'; + +/** + * The theme every lookup falls back to. Exported as a concrete value so callers + * never have to assert a built-in exists. + */ +export const DEFAULT_THEME: TuiThemeDefinition = Object.freeze({ + id: MINIMAX_CODE_DARK_THEME.id, + label: 'MCode', + description: 'The default MCode blue palette', + source: 'builtin', + dark: MINIMAX_CODE_DARK_THEME, + light: MINIMAX_CODE_LIGHT_THEME, +}); + +export const BUILT_IN_THEMES: readonly TuiThemeDefinition[] = Object.freeze([ + DEFAULT_THEME, + MIDNIGHT, + GRAPHITE, + AURORA, +]); + +export function builtinTheme(id: string): TuiThemeDefinition | undefined { + return BUILT_IN_THEMES.find((theme) => theme.id === id); +} diff --git a/packages/tui/src/tui/theme/registry.ts b/packages/tui/src/tui/theme/registry.ts new file mode 100644 index 00000000..49b587fa --- /dev/null +++ b/packages/tui/src/tui/theme/registry.ts @@ -0,0 +1,92 @@ +import type { TuiResolvedAppearance, TuiThemeDefinition, TuiThemePalette } from './contracts.js'; +import type { TuiThemeLoadIssue, TuiThemeLoadResult } from './custom-themes.js'; +import { BUILT_IN_THEMES, DEFAULT_THEME, DEFAULT_THEME_ID } from './palettes.js'; + +/** + * Owns the set of selectable themes for one TUI process. + * + * Built-ins are always present; custom files are layered on top and may be + * replaced at runtime when a watched theme file changes. A selected id that no + * longer resolves (file deleted, renamed, invalid) degrades to the default + * theme instead of leaving the TUI unpainted. + */ +export class TuiThemeRegistry { + private customThemes: readonly TuiThemeDefinition[] = []; + private issues: readonly TuiThemeLoadIssue[] = []; + private selectedId: string = DEFAULT_THEME_ID; + + applyLoadResult(result: TuiThemeLoadResult): void { + this.customThemes = result.themes; + this.issues = result.issues; + if (!this.resolve(this.selectedId)) this.selectedId = DEFAULT_THEME_ID; + } + + setIssues(issues: readonly TuiThemeLoadIssue[]): void { + this.issues = issues; + } + + list(): readonly TuiThemeDefinition[] { + return [...BUILT_IN_THEMES, ...this.customThemes]; + } + + issuesList(): readonly TuiThemeLoadIssue[] { + return this.issues; + } + + resolve(id: string): TuiThemeDefinition | undefined { + const normalized = id.trim().toLowerCase(); + return this.list().find((theme) => theme.id === normalized); + } + + selected(): TuiThemeDefinition { + return this.resolve(this.selectedId) ?? DEFAULT_THEME; + } + + selectedIdValue(): string { + return this.selected().id; + } + + /** + * Select a theme by id. Returns the resolved definition, or `undefined` when + * the id is unknown so the caller can surface the failure without mutating + * the current selection. + */ + select(id: string): TuiThemeDefinition | undefined { + const resolved = this.resolve(id); + if (!resolved) return undefined; + this.selectedId = resolved.id; + return resolved; + } + + /** + * Normalize a persisted or CLI-supplied value. `light/dark` selects an + * explicit appearance while keeping the current theme, which is the syntax + * MCode uses to pin a terminal that reports its background unreliably. + */ + resolveSelection(value: string | undefined): { + readonly themeId: string; + readonly appearanceOverride?: TuiResolvedAppearance; + } { + const raw = value?.trim(); + if (!raw) return { themeId: DEFAULT_THEME_ID }; + const pair = /^([a-z0-9._-]+)\/(light|dark)$/iu.exec(raw); + if (pair?.[1] && pair[2]) { + const theme = this.resolve(pair[1]); + if (theme) + return { + themeId: theme.id, + appearanceOverride: pair[2].toLowerCase() as TuiResolvedAppearance, + }; + } + return { themeId: this.resolve(raw)?.id ?? DEFAULT_THEME_ID }; + } + + palette(themeId: string, appearance: TuiResolvedAppearance): TuiThemePalette { + const theme = this.resolve(themeId) ?? DEFAULT_THEME; + return appearance === 'light' ? theme.light : theme.dark; + } + + paletteFor(appearance: TuiResolvedAppearance): TuiThemePalette { + return this.palette(this.selectedIdValue(), appearance); + } +} diff --git a/packages/tui/src/tui/theme/runtime.ts b/packages/tui/src/tui/theme/runtime.ts index 0d212787..cfa5b17f 100644 --- a/packages/tui/src/tui/theme/runtime.ts +++ b/packages/tui/src/tui/theme/runtime.ts @@ -4,16 +4,22 @@ import type { MarkdownTheme } from '../engine/public.js'; import { detectProcessTerminalCapabilities } from '../platform/terminal-capabilities.js'; import type { EditorTheme } from '../widgets/editor/editor.js'; import type { SelectListTheme } from '../widgets/select-list.js'; -import type { TuiColorLevel, TuiThemeColors, TuiThemePalette } from './contracts.js'; +import type { + TuiColorLevel, + TuiThemeColors, + TuiThemePalette, + TuiThemeSyntaxTones, +} from './contracts.js'; import { resolveTuiAnsi16Foreground, shouldSuppressTuiAnsi16Background } from './ansi16.js'; import { resolveEnvironmentAppearance } from './detection.js'; import { MINIMAX_CODE_DARK_THEME, MINIMAX_CODE_LIGHT_THEME } from './palettes.js'; -import { createCatppuccinHighlightTheme } from './syntax.js'; +import { createSyntaxHighlightTheme } from './syntax.js'; export interface TuiRenderThemeSnapshot { readonly name: string; readonly appearance: 'light' | 'dark'; readonly colorLevel: TuiColorLevel; + readonly signature: string; } const initialCapabilities = detectProcessTerminalCapabilities(); @@ -22,9 +28,18 @@ let renderThemeSnapshot: TuiRenderThemeSnapshot = { name: 'minimax', appearance: initialAppearance, colorLevel: initialCapabilities.colorLevel, + signature: '', }; let activeColors: TuiThemeColors = initialAppearance === 'light' ? MINIMAX_CODE_LIGHT_THEME.colors : MINIMAX_CODE_DARK_THEME.colors; +let activeSyntax: TuiThemeSyntaxTones = + initialAppearance === 'light' ? MINIMAX_CODE_LIGHT_THEME.syntax : MINIMAX_CODE_DARK_THEME.syntax; +renderThemeSnapshot = { + ...renderThemeSnapshot, + signature: paletteSignature( + initialAppearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME, + ), +}; export const tuiColors: TuiThemeColors = Object.freeze({ get brand() { @@ -210,19 +225,29 @@ export function renderTuiActionHint(value: string): string { .join(''); } +/** + * Identity of a palette's *content*, not just its name. Editing a custom theme + * file in place keeps the same id and appearance, so comparing ids alone would + * swallow the repaint and leave stale colors on screen. + */ +export function paletteSignature(palette: TuiThemePalette): string { + return JSON.stringify([palette.id, palette.appearance, palette.colors, palette.syntax]); +} + export function applyTuiRenderTheme(palette: TuiThemePalette, colorLevel: TuiColorLevel): boolean { + const signature = paletteSignature(palette); const changed = - renderThemeSnapshot.name !== palette.id || - renderThemeSnapshot.appearance !== palette.appearance || - renderThemeSnapshot.colorLevel !== colorLevel; + renderThemeSnapshot.signature !== signature || renderThemeSnapshot.colorLevel !== colorLevel; if (!changed) return false; const colorLevelChanged = renderThemeSnapshot.colorLevel !== colorLevel; renderThemeSnapshot = { name: palette.id, appearance: palette.appearance, colorLevel, + signature, }; activeColors = palette.colors; + activeSyntax = palette.syntax; if (colorLevelChanged) activeChalk = createTuiChalk({ colorLevel }); return true; } @@ -231,8 +256,9 @@ export function getTuiThemeSnapshot(): TuiRenderThemeSnapshot { return { ...renderThemeSnapshot }; } -const tuiHighlightTheme = createCatppuccinHighlightTheme( +const tuiHighlightTheme = createSyntaxHighlightTheme( tuiChalk, + () => activeSyntax, () => renderThemeSnapshot.appearance, ); diff --git a/packages/tui/src/tui/theme/syntax.ts b/packages/tui/src/tui/theme/syntax.ts index 278fdf2b..6bcc2719 100644 --- a/packages/tui/src/tui/theme/syntax.ts +++ b/packages/tui/src/tui/theme/syntax.ts @@ -1,58 +1,47 @@ import type { ChalkInstance } from 'chalk'; import type { Theme } from 'cli-highlight'; +import { type TuiThemeSyntaxTones, type TuiResolvedAppearance } from './contracts.js'; -type SyntaxTone = - | 'blue' - | 'flamingo' - | 'green' - | 'mauve' - | 'overlay2' - | 'peach' - | 'pink' - | 'red' - | 'sapphire' - | 'subtext0' - | 'teal' - | 'text' - | 'yellow'; +export type SyntaxTone = keyof TuiThemeSyntaxTones; -const CATPPUCCIN_SYNTAX_COLORS: Readonly< - Record<'dark' | 'light', Readonly>> -> = { - dark: { - blue: '#89B4FA', - flamingo: '#F2CDCD', - green: '#A6E3A1', - mauve: '#CBA6F7', - overlay2: '#9399B2', - peach: '#FAB387', - pink: '#F5C2E7', - red: '#F38BA8', - sapphire: '#74C7EC', - subtext0: '#A6ADC8', - teal: '#94E2D5', - text: '#CDD6F4', - yellow: '#F9E2AF', - }, - light: { - blue: '#1E66F5', - flamingo: '#DD7878', - green: '#40A02B', - mauve: '#8839EF', - overlay2: '#7C7F93', - peach: '#FE640B', - pink: '#EA76CB', - red: '#D20F39', - sapphire: '#209FB5', - subtext0: '#6C6F85', - teal: '#179299', - text: '#4C4F69', - yellow: '#DF8E1D', - }, -}; +export const CATPPUCCIN_SYNTAX_TONES: Readonly> = + Object.freeze({ + dark: Object.freeze({ + blue: '#89B4FA', + flamingo: '#F2CDCD', + green: '#A6E3A1', + mauve: '#CBA6F7', + overlay2: '#9399B2', + peach: '#FAB387', + pink: '#F5C2E7', + red: '#F38BA8', + sapphire: '#74C7EC', + subtext0: '#A6ADC8', + teal: '#94E2D5', + text: '#CDD6F4', + yellow: '#F9E2AF', + }), + light: Object.freeze({ + blue: '#1E66F5', + flamingo: '#DD7878', + green: '#40A02B', + mauve: '#8839EF', + overlay2: '#7C7F93', + peach: '#FE640B', + pink: '#EA76CB', + red: '#D20F39', + sapphire: '#209FB5', + subtext0: '#6C6F85', + teal: '#179299', + text: '#4C4F69', + yellow: '#DF8E1D', + }), + }); -// Pastel RGB colors collapse to white when Chalk approximates them to ANSI16. -// Keep token families distinct using the terminal's own semantic palette. +/** + * Pastel RGB colors collapse to white when Chalk approximates them to ANSI16. + * Keep token families distinct using the terminal's own semantic palette. + */ const ANSI16_SYNTAX_STYLES = { dark: { blue: 'blueBright', @@ -84,22 +73,28 @@ const ANSI16_SYNTAX_STYLES = { text: 'black', yellow: 'yellow', }, -} as const satisfies Record<'dark' | 'light', Record>; +} as const satisfies Record>; -export function createCatppuccinHighlightTheme( +/** + * Builds the Highlight.js token map for whichever theme is currently active. + * `tones` is read on every call so a theme switch is picked up without + * re-creating the highlight theme object. + */ +export function createSyntaxHighlightTheme( chalk: ChalkInstance, - appearance: () => 'light' | 'dark', + tones: () => TuiThemeSyntaxTones, + appearance: () => TuiResolvedAppearance, ): Theme { const color = (tone: SyntaxTone) => (text: string) => { const mode = appearance(); - if (chalk.level !== 1) return chalk.hex(CATPPUCCIN_SYNTAX_COLORS[mode][tone])(text); + if (chalk.level !== 1) return chalk.hex(tones()[tone])(text); const styled = chalk[ANSI16_SYNTAX_STYLES[mode][tone]](text); // Bright white + dim remains legible where ANSI bright-black is very dark. return mode === 'dark' && tone === 'overlay2' ? chalk.dim(styled) : styled; }; const strong = (text: string) => chalk.bold(color('red')(text)); - // Exact Catppuccin Highlight.js token mapping, adapted to ANSI output. As in Codex, + // Highlight.js token mapping, adapted to ANSI output. As in Codex, // terminal-hostile italic, underline, and syntax-theme backgrounds are intentionally omitted. return { default: color('text'), diff --git a/packages/tui/src/types/tui-app.ts b/packages/tui/src/types/tui-app.ts index b4c8da4b..2228931f 100644 --- a/packages/tui/src/types/tui-app.ts +++ b/packages/tui/src/types/tui-app.ts @@ -35,6 +35,9 @@ export interface CreateTuiAppOptions extends TuiUpdateOptions { terminal?: Terminal; tuiMode?: TuiMode; persistTuiMode?: (mode: TuiMode) => void; + /** Saved theme selection, e.g. `aurora` or `aurora/dark`. */ + theme?: string; + persistTheme?: (theme: string) => void; runtimeLogDirectory?: string; resolveAttachment?: ( reference: string, diff --git a/packages/tui/test/helpers/theme-contrast.ts b/packages/tui/test/helpers/theme-contrast.ts new file mode 100644 index 00000000..0f31d10f --- /dev/null +++ b/packages/tui/test/helpers/theme-contrast.ts @@ -0,0 +1,57 @@ +import type { TuiThemeColors } from '../../src/tui/theme/contracts.js'; + +type TuiThemeColorRole = keyof TuiThemeColors; + +export const MINIMAX_CODE_THEME_CONTRAST_POLICY = Object.freeze({ + backgrounds: Object.freeze({ + dark: '#000000', + light: '#FFFFFF', + }), + normalText: Object.freeze({ + minimum: 4.5, + roles: Object.freeze([ + 'signal', + 'orbit', + 'accent', + 'markdownHeading', + 'markdownCode', + 'markdownLink', + 'text', + 'muted', + 'success', + 'warning', + 'error', + ] satisfies readonly TuiThemeColorRole[]), + exceptions: Object.freeze([ + Object.freeze({ appearance: 'light', role: 'signal', minimum: 3 }), + Object.freeze({ appearance: 'light', role: 'accent', minimum: 3 }), + ] satisfies readonly { + readonly appearance: 'light' | 'dark'; + readonly role: TuiThemeColorRole; + readonly minimum: number; + }[]), + }), + nonText: Object.freeze({ + minimum: 3, + roles: Object.freeze(['line'] satisfies readonly TuiThemeColorRole[]), + }), +}); + +export function contrastRatio(first: string, second: string): number { + const firstLuminance = relativeLuminance(first); + const secondLuminance = relativeLuminance(second); + const lighter = Math.max(firstLuminance, secondLuminance); + const darker = Math.min(firstLuminance, secondLuminance); + return (lighter + 0.05) / (darker + 0.05); +} + +function relativeLuminance(hex: string): number { + const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/iu.exec(hex); + if (!match) throw new Error(`Expected a six-digit hex color, received ${hex}`); + const [, red = '00', green = '00', blue = '00'] = match; + const linearize = (channel: string): number => { + const value = Number.parseInt(channel, 16) / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * linearize(red) + 0.7152 * linearize(green) + 0.0722 * linearize(blue); +} diff --git a/packages/tui/test/unit/host-tui-settings.test.ts b/packages/tui/test/unit/host-tui-settings.test.ts new file mode 100644 index 00000000..3e995aee --- /dev/null +++ b/packages/tui/test/unit/host-tui-settings.test.ts @@ -0,0 +1,157 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + readTuiModeSetting, + readTuiThemeSetting, + writeTuiModeSetting, + writeTuiThemeSetting, +} from '../../src/host/tui-settings.js'; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +async function temporaryDataDir(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'mcode-tui-settings-')); + directories.push(directory); + return directory; +} + +describe('TUI mode settings', () => { + it('uses regular mode when no valid explicit setting exists', async () => { + const dataDir = await temporaryDataDir(); + + expect(readTuiModeSetting(dataDir)).toBe('regular'); + await mkdir(join(dataDir, 'tui'), { recursive: true }); + await writeFile(join(dataDir, 'tui', 'tui-settings.json'), '{broken', 'utf8'); + expect(readTuiModeSetting(dataDir)).toBe('regular'); + await writeFile(join(dataDir, 'tui', 'tui-settings.json'), '{"tuiMode":"other"}', 'utf8'); + expect(readTuiModeSetting(dataDir)).toBe('regular'); + }); + + it('persists the selected fullscreen mode in the MCode data directory', async () => { + const dataDir = await temporaryDataDir(); + + writeTuiModeSetting(dataDir, 'fullscreen'); + + expect(readTuiModeSetting(dataDir)).toBe('fullscreen'); + expect(JSON.parse(await readFile(join(dataDir, 'tui', 'tui-settings.json'), 'utf8'))).toEqual({ + tuiMode: 'fullscreen', + }); + }); + + it('accepts a UTF-8 BOM written by Windows editors', async () => { + const dataDir = await temporaryDataDir(); + await mkdir(join(dataDir, 'tui'), { recursive: true }); + await writeFile( + join(dataDir, 'tui', 'tui-settings.json'), + '\uFEFF{"tuiMode":"fullscreen"}', + 'utf8', + ); + + expect(readTuiModeSetting(dataDir)).toBe('fullscreen'); + }); + + it('reads the legacy root file without writing back to the root', async () => { + const dataDir = await temporaryDataDir(); + await writeFile(join(dataDir, 'tui-settings.json'), '{"tuiMode":"fullscreen"}', 'utf8'); + + expect(readTuiModeSetting(dataDir)).toBe('fullscreen'); + writeTuiModeSetting(dataDir, 'regular'); + expect(JSON.parse(await readFile(join(dataDir, 'tui', 'tui-settings.json'), 'utf8'))).toEqual({ + tuiMode: 'regular', + }); + }); +}); + +describe('TUI theme settings', () => { + it('uses no theme override when nothing is saved', async () => { + const dataDir = await temporaryDataDir(); + + expect(readTuiThemeSetting(dataDir)).toBeUndefined(); + }); + + it('round-trips a theme and a pinned appearance', async () => { + const dataDir = await temporaryDataDir(); + + writeTuiThemeSetting(dataDir, 'aurora'); + expect(readTuiThemeSetting(dataDir)).toBe('aurora'); + + writeTuiThemeSetting(dataDir, 'aurora/dark'); + expect(readTuiThemeSetting(dataDir)).toBe('aurora/dark'); + }); + + it('does not drop the theme when the TUI mode is written afterwards', async () => { + const dataDir = await temporaryDataDir(); + + writeTuiThemeSetting(dataDir, 'midnight'); + writeTuiModeSetting(dataDir, 'fullscreen'); + + expect(readTuiThemeSetting(dataDir)).toBe('midnight'); + expect(readTuiModeSetting(dataDir)).toBe('fullscreen'); + }); + + it('does not drop the TUI mode when the theme is written afterwards', async () => { + const dataDir = await temporaryDataDir(); + + writeTuiModeSetting(dataDir, 'fullscreen'); + writeTuiThemeSetting(dataDir, 'graphite'); + + expect(readTuiModeSetting(dataDir)).toBe('fullscreen'); + expect(readTuiThemeSetting(dataDir)).toBe('graphite'); + }); + + it('rejects a theme id that is not a safe identifier', async () => { + const dataDir = await temporaryDataDir(); + + expect(() => writeTuiThemeSetting(dataDir, '../escape')).toThrow(); + expect(() => writeTuiThemeSetting(dataDir, 'a/b/c')).toThrow(); + }); + + it('ignores a malformed saved theme instead of failing startup', async () => { + const dataDir = await temporaryDataDir(); + await mkdir(join(dataDir, 'tui'), { recursive: true }); + await writeFile( + join(dataDir, 'tui', 'tui-settings.json'), + JSON.stringify({ theme: { nested: true } }), + 'utf8', + ); + + expect(readTuiThemeSetting(dataDir)).toBeUndefined(); + expect(readTuiModeSetting(dataDir)).toBe('regular'); + }); + + it('ignores a saved theme that tries to traverse a path', async () => { + const dataDir = await temporaryDataDir(); + await mkdir(join(dataDir, 'tui'), { recursive: true }); + await writeFile( + join(dataDir, 'tui', 'tui-settings.json'), + JSON.stringify({ theme: '../../etc/passwd' }), + 'utf8', + ); + + expect(readTuiThemeSetting(dataDir)).toBeUndefined(); + }); + + it('keeps unknown settings keys when rewriting a known one', async () => { + const dataDir = await temporaryDataDir(); + await mkdir(join(dataDir, 'tui'), { recursive: true }); + await writeFile( + join(dataDir, 'tui', 'tui-settings.json'), + JSON.stringify({ futureSetting: 'keep-me' }), + 'utf8', + ); + + writeTuiThemeSetting(dataDir, 'aurora'); + + const document = JSON.parse( + await readFile(join(dataDir, 'tui', 'tui-settings.json'), 'utf8'), + ) as Record; + expect(document.futureSetting).toBe('keep-me'); + expect(document.theme).toBe('aurora'); + }); +}); diff --git a/packages/tui/test/unit/tui/features/settings/theme-picker.test.ts b/packages/tui/test/unit/tui/features/settings/theme-picker.test.ts new file mode 100644 index 00000000..e7a2f07f --- /dev/null +++ b/packages/tui/test/unit/tui/features/settings/theme-picker.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TuiThemePicker } from '../../../../../src/tui/features/settings/theme-picker.js'; +import { stripAnsi } from '../../../../../src/tui/rendering/text.js'; +import { BUILT_IN_THEMES } from '../../../../../src/tui/theme/palettes.js'; +import type { TuiThemeDefinition } from '../../../../../src/tui/theme/contracts.js'; + +const CUSTOM: TuiThemeDefinition = { + id: 'mine', + label: 'Mine', + description: 'Loaded from disk', + source: 'custom', + dark: BUILT_IN_THEMES[0]!.dark, + light: BUILT_IN_THEMES[0]!.light, +}; + +const THEMES = [...BUILT_IN_THEMES, CUSTOM]; + +function build(overrides: Partial[0]> = {}) { + const preview = vi.fn(); + const setAppearance = vi.fn(); + const save = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + const requestRender = vi.fn(); + const picker = new TuiThemePicker({ + themes: THEMES, + currentThemeId: 'minimax', + currentAppearance: 'dark', + appearanceOverride: undefined, + preview, + setAppearance, + save, + onClose, + requestRender, + ...overrides, + }); + return { picker, preview, setAppearance, save, onClose, requestRender }; +} + +describe('TuiThemePicker', () => { + it('focuses the current theme and shows each theme with a color swatch', () => { + const { picker } = build(); + + const rendered = stripAnsi(picker.render(100).join('\n')); + + expect(rendered).toContain('Theme'); + expect(rendered).toContain('MCode'); + expect(rendered).toContain('Midnight'); + expect(rendered).toContain('Aurora'); + expect(rendered).toContain('Mine'); + expect(rendered).toContain('current'); + // The cursor starts on the theme that is already active. + expect(rendered).toContain('› MCode'); + }); + + it('previews the focused theme while navigating', () => { + const { picker, preview, requestRender } = build(); + + picker.handleInput('\u001b[B'); + expect(preview).toHaveBeenLastCalledWith(BUILT_IN_THEMES[1]!.id); + expect(requestRender).toHaveBeenCalled(); + + picker.handleInput('\u001b[A'); + expect(preview).toHaveBeenLastCalledWith('minimax'); + }); + + it('wraps around at both ends of the list', () => { + const { picker, preview } = build(); + + picker.handleInput('\u001b[A'); + expect(preview).toHaveBeenLastCalledWith(CUSTOM.id); + + picker.handleInput('\u001b[B'); + expect(preview).toHaveBeenLastCalledWith('minimax'); + }); + + it('cycles the appearance with the a key', () => { + const { picker, setAppearance } = build(); + + picker.handleInput('a'); + expect(setAppearance).toHaveBeenLastCalledWith('light'); + picker.handleInput('a'); + expect(setAppearance).toHaveBeenLastCalledWith('dark'); + picker.handleInput('a'); + expect(setAppearance).toHaveBeenLastCalledWith('auto'); + }); + + it('saves the focused theme and closes', async () => { + const { picker, save, onClose } = build(); + + picker.handleInput('\u001b[B'); + picker.handleInput('\r'); + await vi.waitFor(() => expect(onClose).toHaveBeenCalled()); + + expect(save).toHaveBeenCalledWith(BUILT_IN_THEMES[1]!.id, 'auto'); + }); + + it('saves a pinned appearance alongside the theme', async () => { + const { picker, save, onClose } = build(); + + picker.handleInput('\u001b[B'); + picker.handleInput('a'); + picker.handleInput('\r'); + await vi.waitFor(() => expect(onClose).toHaveBeenCalled()); + + expect(save).toHaveBeenCalledWith(BUILT_IN_THEMES[1]!.id, 'light'); + }); + + it('closes without saving when nothing changed', async () => { + const { picker, save, onClose } = build(); + + picker.handleInput('\r'); + await vi.waitFor(() => expect(onClose).toHaveBeenCalled()); + + expect(save).not.toHaveBeenCalled(); + }); + + it('restores the original theme and appearance on cancel', () => { + const { picker, preview, setAppearance, save, onClose } = build(); + + picker.handleInput('\u001b[B'); + picker.handleInput('a'); + preview.mockClear(); + picker.handleInput('\u001b'); + + expect(preview).toHaveBeenCalledWith('minimax'); + expect(setAppearance).toHaveBeenLastCalledWith('auto'); + expect(save).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it('keeps the picker open and reports a failed save', async () => { + const save = vi.fn().mockRejectedValue(new Error('read-only')); + const { picker, onClose, requestRender } = build({ save }); + + picker.handleInput('\u001b[B'); + picker.handleInput('\r'); + await vi.waitFor(() => expect(requestRender).toHaveBeenCalled()); + + expect(onClose).not.toHaveBeenCalled(); + expect(stripAnsi(picker.render(100).join('\n'))).toContain( + 'Could not save the theme selection.', + ); + }); + + it('renders without a theme list', () => { + const { picker } = build({ themes: [] }); + + expect(stripAnsi(picker.render(80).join('\n'))).toContain('No themes available.'); + }); + + it('fits a narrow terminal without overflowing', () => { + const { picker } = build(); + + for (const width of [40, 60, 100, 160]) { + for (const line of picker.render(width)) { + expect(stripAnsi(line).length).toBeLessThanOrEqual(width); + } + } + }); +}); diff --git a/packages/tui/test/unit/tui/theme/custom-themes.test.ts b/packages/tui/test/unit/tui/theme/custom-themes.test.ts new file mode 100644 index 00000000..23020c02 --- /dev/null +++ b/packages/tui/test/unit/tui/theme/custom-themes.test.ts @@ -0,0 +1,275 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + customThemesDirectory, + loadCustomThemes, +} from '../../../../src/tui/theme/custom-themes.js'; +import { TuiThemeRegistry } from '../../../../src/tui/theme/registry.js'; +import type { TuiThemeDefinition } from '../../../../src/tui/theme/contracts.js'; +import { + DEFAULT_THEME_ID, + MINIMAX_CODE_DARK_THEME, + MINIMAX_CODE_LIGHT_THEME, +} from '../../../../src/tui/theme/palettes.js'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +async function themesDataDir(files: Readonly>): Promise { + const dataDir = await mkdtemp(join(tmpdir(), 'mcode-tui-themes-')); + temporaryDirectories.push(dataDir); + const directory = customThemesDirectory(dataDir); + await mkdir(directory, { recursive: true }); + for (const [name, content] of Object.entries(files)) { + await writeFile(join(directory, name), JSON.stringify(content, null, 2)); + } + return dataDir; +} + +/** Fail loudly instead of reaching for a non-null assertion on a test subject. */ +function onlyTheme(themes: readonly TuiThemeDefinition[]): TuiThemeDefinition { + expect(themes).toHaveLength(1); + const [theme] = themes; + if (!theme) throw new Error('expected exactly one loaded theme'); + return theme; +} + +describe('custom TUI theme files', () => { + it('returns no themes when the directory is absent', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'mcode-tui-themes-empty-')); + temporaryDirectories.push(dataDir); + + expect(loadCustomThemes(dataDir)).toEqual({ themes: [], issues: [] }); + }); + + it('loads a partial file on top of the default palette', async () => { + const dataDir = await themesDataDir({ + 'mine.json': { name: 'mine', appearance: 'dark', colors: { brand: '#112233' } }, + }); + + const { themes, issues } = loadCustomThemes(dataDir); + + expect(issues).toEqual([]); + const theme = onlyTheme(themes); + expect(theme.id).toBe('mine'); + expect(theme.source).toBe('custom'); + expect(theme.label).toBe('mine'); + expect(theme.dark.colors.brand).toBe('#112233'); + // Untouched roles keep the default palette so the theme is usable immediately. + expect(theme.dark.colors.error).toBe(MINIMAX_CODE_DARK_THEME.colors.error); + }); + + it('resolves vars references and normalizes case', async () => { + const dataDir = await themesDataDir({ + 'vars.json': { + name: 'vars', + appearance: 'dark', + vars: { brand: '#aabbcc' }, + colors: { brand: 'brand', accent: 'brand' }, + }, + }); + + const { themes, issues } = loadCustomThemes(dataDir); + + expect(issues).toEqual([]); + const theme = onlyTheme(themes); + expect(theme.dark.colors.brand).toBe('#AABBCC'); + expect(theme.dark.colors.accent).toBe('#AABBCC'); + }); + + it('pairs a dark and a light file into one selectable theme', async () => { + const dataDir = await themesDataDir({ + 'dual.json': { name: 'dual', appearance: 'dark', colors: { brand: '#010101' } }, + 'dual-light.json': { name: 'dual', appearance: 'light', colors: { brand: '#fefefe' } }, + }); + + const { themes } = loadCustomThemes(dataDir); + + const theme = onlyTheme(themes); + expect(theme.dark.colors.brand).toBe('#010101'); + expect(theme.light.colors.brand).toBe('#FEFEFE'); + }); + + it('falls back to the default palette for an appearance the files do not supply', async () => { + const dataDir = await themesDataDir({ + 'dark-only.json': { name: 'dark-only', appearance: 'dark', colors: { brand: '#010101' } }, + }); + + const { themes } = loadCustomThemes(dataDir); + + const theme = onlyTheme(themes); + expect(theme.dark.colors.brand).toBe('#010101'); + // The light variant is a complete, usable palette borrowed from the default + // light theme — not a blank object and not the dark value. + expect(theme.light.colors.brand).toBe(MINIMAX_CODE_LIGHT_THEME.colors.brand); + expect(theme.light.colors.text).toBe(MINIMAX_CODE_LIGHT_THEME.colors.text); + expect(theme.light.id).toBe('dark-only'); + }); + + it('reports a malformed file without dropping valid themes', async () => { + const dataDir = await themesDataDir({ + 'good.json': { name: 'good', appearance: 'dark', colors: { brand: '#010101' } }, + 'bad.json': { name: 'bad' }, + 'badvar.json': { + name: 'badvar', + appearance: 'dark', + colors: { brand: 'nope' }, + }, + }); + + const { themes, issues } = loadCustomThemes(dataDir); + + expect(themes.map((theme) => theme.id)).toEqual(['good']); + expect(issues).toHaveLength(2); + expect(issues.map((issue) => issue.message).join(' ')).toMatch(/brand|appearance/u); + }); + + it('rejects a nested vars reference during validation', async () => { + const dataDir = await themesDataDir({ + 'nested.json': { + name: 'nested', + appearance: 'dark', + // vars must hold literal colors; an identifier here would otherwise + // pass validation and fail later with a misleading message. + vars: { outer: 'inner', inner: '#112233' }, + colors: { brand: 'outer' }, + }, + }); + + const { themes, issues } = loadCustomThemes(dataDir); + + expect(themes).toEqual([]); + expect(issues).toHaveLength(1); + expect(issues[0]?.message).toMatch(/vars\/outer/u); + }); + + it('refuses to shadow a built-in theme id', async () => { + const dataDir = await themesDataDir({ + 'minimax.json': { name: 'minimax', appearance: 'dark', colors: { brand: '#010101' } }, + }); + + const { themes, issues } = loadCustomThemes(dataDir); + + expect(themes).toEqual([]); + expect(issues[0]?.message).toMatch(/built-in/u); + }); + + it('rejects ids that could escape the themes directory', async () => { + const dataDir = await themesDataDir({ + 'escape.json': { name: '../escape', appearance: 'dark', colors: { brand: '#010101' } }, + }); + + const { themes, issues } = loadCustomThemes(dataDir); + + expect(themes).toEqual([]); + expect(issues).toHaveLength(1); + }); +}); + +describe('TuiThemeRegistry', () => { + it('lists built-ins first and layers custom themes after them', async () => { + const dataDir = await themesDataDir({ + 'mine.json': { name: 'mine', appearance: 'dark', colors: { brand: '#010101' } }, + }); + const registry = new TuiThemeRegistry(); + + registry.applyLoadResult(loadCustomThemes(dataDir)); + + const ids = registry.list().map((theme) => theme.id); + expect(ids[0]).toBe(DEFAULT_THEME_ID); + expect(ids).toContain('mine'); + expect(ids.filter((id) => id === 'minimax')).toHaveLength(1); + }); + + it('keeps a valid selection when unrelated custom themes reload', async () => { + const dataDir = await themesDataDir({ + 'mine.json': { name: 'mine', appearance: 'dark', colors: { brand: '#010101' } }, + }); + const registry = new TuiThemeRegistry(); + registry.applyLoadResult(loadCustomThemes(dataDir)); + registry.select('mine'); + + registry.applyLoadResult(loadCustomThemes(dataDir)); + + expect(registry.selectedIdValue()).toBe('mine'); + }); + + it('falls back to the default theme when the selected theme disappears', async () => { + const dataDir = await themesDataDir({ + 'mine.json': { name: 'mine', appearance: 'dark', colors: { brand: '#010101' } }, + }); + const registry = new TuiThemeRegistry(); + registry.applyLoadResult(loadCustomThemes(dataDir)); + registry.select('mine'); + + registry.applyLoadResult({ themes: [], issues: [] }); + + expect(registry.selectedIdValue()).toBe(DEFAULT_THEME_ID); + }); + + it('rejects an unknown selection instead of clearing the current theme', async () => { + const registry = new TuiThemeRegistry(); + registry.select('midnight'); + + expect(registry.select('does-not-exist')).toBeUndefined(); + expect(registry.selectedIdValue()).toBe('midnight'); + }); + + it('resolves a pinned light/dark selection against the current theme', () => { + const registry = new TuiThemeRegistry(); + + expect(registry.resolveSelection('aurora/dark')).toEqual({ + themeId: 'aurora', + appearanceOverride: 'dark', + }); + expect(registry.resolveSelection('AURORA/LIGHT')).toEqual({ + themeId: 'aurora', + appearanceOverride: 'light', + }); + // A pinned appearance for an unknown theme still resolves to the default. + expect(registry.resolveSelection('nope/dark')).toEqual({ themeId: DEFAULT_THEME_ID }); + expect(registry.resolveSelection(undefined)).toEqual({ themeId: DEFAULT_THEME_ID }); + }); + + it('picks the palette matching the requested appearance', () => { + const registry = new TuiThemeRegistry(); + registry.select('midnight'); + + expect(registry.paletteFor('dark').id).toBe('midnight'); + expect(registry.paletteFor('light').id).toBe('midnight'); + expect(registry.paletteFor('dark').colors.brand).not.toBe( + registry.paletteFor('light').colors.brand, + ); + }); +}); + +describe('custom theme documentation', () => { + const docPath = new URL('../../../../docs/theme-config.md', import.meta.url); + + it('ships a JSON example the loader actually accepts', async () => { + const doc = await readFile(docPath, 'utf8'); + // The guide also shows tui-settings.json; only theme files carry `appearance`. + const blocks = [...doc.matchAll(/```json\n([\s\S]*?)```/gu)] + .map((match) => match[1] ?? '') + .filter((block) => block.includes('"appearance"')); + expect(blocks.length).toBeGreaterThan(0); + + const dataDir = await themesDataDir( + Object.fromEntries(blocks.map((block, index) => [`doc-${index}.json`, JSON.parse(block)])), + ); + + const { themes, issues } = loadCustomThemes(dataDir); + + // A documented example that the schema rejects is worse than no example: + // users copy it verbatim and the theme silently disappears from /theme. + expect(issues).toEqual([]); + expect(themes.length).toBe(blocks.length); + }); +}); diff --git a/packages/tui/test/unit/tui/theme/palettes.test.ts b/packages/tui/test/unit/tui/theme/palettes.test.ts new file mode 100644 index 00000000..434cf8f0 --- /dev/null +++ b/packages/tui/test/unit/tui/theme/palettes.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { + BUILT_IN_THEMES, + DEFAULT_THEME_ID, + MINIMAX_CODE_DARK_THEME, + MINIMAX_CODE_LIGHT_THEME, +} from '../../../../src/tui/theme/palettes.js'; +import { + TUI_SYNTAX_TONE_NAMES, + TUI_THEME_COLOR_NAMES, +} from '../../../../src/tui/theme/contracts.js'; +import { + MINIMAX_CODE_THEME_CONTRAST_POLICY, + contrastRatio, +} from '../../../helpers/theme-contrast.js'; + +/** Every palette in both appearances of every built-in theme. */ +const ALL_PALETTES = BUILT_IN_THEMES.flatMap((theme) => [ + { theme, palette: theme.dark }, + { theme, palette: theme.light }, +]); + +describe('built-in TUI themes', () => { + it('keeps the default MCode palette byte-identical to the pre-theme implementation', () => { + // The default theme is what every existing user sees, so it must not move. + expect(MINIMAX_CODE_DARK_THEME.colors).toEqual({ + brand: '#68C0FF', + wordmarkHighlight: '#93D2FF', + wordmarkShadow: '#3DAEFF', + signal: '#68C0FF', + orbit: '#1CCDD2', + accent: '#68C0FF', + markdownHeading: '#CBA6F7', + markdownCode: '#A6E3A1', + markdownLink: '#68C0FF', + userMessageBg: '#262626', + diffAddedBg: '#213A2B', + diffRemovedBg: '#4A221D', + text: '#D6D6D6', + muted: '#ADADAD', + dim: '#666666', + border: '#303030', + line: '#666666', + success: '#28C567', + warning: '#FFC340', + error: '#FF5E6C', + }); + expect(MINIMAX_CODE_LIGHT_THEME.colors.text).toBe('#303030'); + expect(DEFAULT_THEME_ID).toBe('minimax'); + }); + + it.each(ALL_PALETTES)( + 'enforces readable semantic colors for $theme.id $palette.appearance', + ({ palette }) => { + const background = MINIMAX_CODE_THEME_CONTRAST_POLICY.backgrounds[palette.appearance]; + + for (const role of MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.roles) { + const exception = MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.exceptions.find( + (candidate) => candidate.appearance === palette.appearance && candidate.role === role, + ); + expect( + contrastRatio(palette.colors[role], background), + `${palette.id}.${palette.appearance}.${role} must remain readable against ${background}`, + ).toBeGreaterThanOrEqual( + exception?.minimum ?? MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.minimum, + ); + } + for (const role of MINIMAX_CODE_THEME_CONTRAST_POLICY.nonText.roles) { + expect( + contrastRatio(palette.colors[role], background), + `${palette.id}.${palette.appearance}.${role} must stay distinguishable against ${background}`, + ).toBeGreaterThanOrEqual(MINIMAX_CODE_THEME_CONTRAST_POLICY.nonText.minimum); + } + }, + ); + + it.each(ALL_PALETTES)( + 'defines every color and syntax tone for $theme.id $palette.appearance', + ({ palette }) => { + for (const name of TUI_THEME_COLOR_NAMES) { + expect(palette.colors[name], `${palette.id}.${name}`).toMatch( + /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/iu, + ); + } + for (const tone of TUI_SYNTAX_TONE_NAMES) { + expect(palette.syntax[tone], `${palette.id}.syntax.${tone}`).toMatch( + /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/iu, + ); + } + }, + ); + + it.each(ALL_PALETTES)( + 'keeps syntax text legible for $theme.id $palette.appearance', + ({ palette }) => { + const background = MINIMAX_CODE_THEME_CONTRAST_POLICY.backgrounds[palette.appearance]; + // Comments are the lowest-emphasis token but still have to be readable. + expect(contrastRatio(palette.syntax.overlay2, background)).toBeGreaterThanOrEqual(3); + expect(contrastRatio(palette.syntax.text, background)).toBeGreaterThanOrEqual(4.5); + }, + ); + + it('gives every built-in theme a matching id across both appearances', () => { + for (const theme of BUILT_IN_THEMES) { + expect(theme.dark.id).toBe(theme.id); + expect(theme.light.id).toBe(theme.id); + expect(theme.source).toBe('builtin'); + expect(theme.label.length).toBeGreaterThan(0); + } + const ids = BUILT_IN_THEMES.map((theme) => theme.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/packages/tui/test/unit/tui/theme/runtime.test.ts b/packages/tui/test/unit/tui/theme/runtime.test.ts new file mode 100644 index 00000000..d0da7544 --- /dev/null +++ b/packages/tui/test/unit/tui/theme/runtime.test.ts @@ -0,0 +1,695 @@ +import { stripVTControlCharacters } from 'node:util'; +import { describe, expect, it, vi } from 'vitest'; +import { + MINIMAX_CODE_DARK_THEME, + MINIMAX_CODE_LIGHT_THEME, +} from '../../../../src/tui/theme/palettes.js'; +import { + MINIMAX_CODE_THEME_CONTRAST_POLICY, + contrastRatio, +} from '../../../helpers/theme-contrast.js'; +import { + appearanceFromRgb, + parseColorFgBgAppearance, + resolveEnvironmentAppearance, +} from '../../../../src/tui/theme/detection.js'; +import { TuiThemeController, type TuiThemeUi } from '../../../../src/tui/theme/controller.js'; +import { bindThemeRendering } from '../../../../src/tui/theme/render-binding.js'; +import type { TUI } from '../../../../src/tui/engine/public.js'; +import { + applyTuiRenderTheme, + createTuiMarkdownTheme, + getTuiThemeSnapshot, + renderTuiActionHint, + tuiChalk, + tuiColors, + tuiStreamingMarkdownTheme, +} from '../../../../src/tui/theme/runtime.js'; + +describe('MCode terminal theme palettes', () => { + it.each([MINIMAX_CODE_DARK_THEME, MINIMAX_CODE_LIGHT_THEME])( + 'enforces readable semantic colors for the $appearance palette', + (palette) => { + const background = MINIMAX_CODE_THEME_CONTRAST_POLICY.backgrounds[palette.appearance]; + + for (const role of MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.roles) { + const exception = MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.exceptions.find( + (candidate) => candidate.appearance === palette.appearance && candidate.role === role, + ); + expect( + contrastRatio(palette.colors[role], background), + `${palette.appearance}.${role} must remain readable against ${background}`, + ).toBeGreaterThanOrEqual( + exception?.minimum ?? MINIMAX_CODE_THEME_CONTRAST_POLICY.normalText.minimum, + ); + } + for (const role of MINIMAX_CODE_THEME_CONTRAST_POLICY.nonText.roles) { + expect( + contrastRatio(palette.colors[role], background), + `${palette.appearance}.${role} must remain distinguishable against ${background}`, + ).toBeGreaterThanOrEqual(MINIMAX_CODE_THEME_CONTRAST_POLICY.nonText.minimum); + } + }, + ); + + it('uses explicit ANSI16 semantics instead of nearest-RGB collisions', () => { + const original = getTuiThemeSnapshot(); + const originalPalette = + original.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME; + + try { + applyTuiRenderTheme(MINIMAX_CODE_DARK_THEME, 1); + expect(tuiChalk.hex(tuiColors.text)('text')).toBe('text'); + expect(tuiChalk.bold.hex(tuiColors.text)('strong')).toContain('\u001B[1m'); + expect(tuiChalk.hex(tuiColors.muted)('muted')).toContain('\u001B[2m'); + expect(tuiChalk.hex(tuiColors.brand)('brand')).toContain('\u001B[36m'); + expect(tuiChalk.hex(tuiColors.signal)('signal')).toContain('\u001B[36m'); + expect(tuiChalk.hex(tuiColors.accent)('accent')).toContain('\u001B[36m'); + expect(tuiChalk.hex(tuiColors.line)('line')).toContain('\u001B[90m'); + expect(tuiChalk.hex(tuiColors.warning)('warning')).toContain('\u001B[93m'); + expect(tuiChalk.hex(tuiColors.error)('error')).toContain('\u001B[91m'); + expect(tuiChalk.bgHex(tuiColors.userMessageBg)('message')).toBe('message'); + expect(tuiChalk.bgHex(tuiColors.diffAddedBg)('addition')).toBe('addition'); + expect(tuiChalk.bgHex(tuiColors.diffRemovedBg)('deletion')).toBe('deletion'); + + applyTuiRenderTheme(MINIMAX_CODE_DARK_THEME, 2); + expect(tuiChalk.hex(tuiColors.text)('text')).toContain('\u001B[38;5;'); + expect(tuiChalk.hex(tuiColors.signal)('signal')).toContain('\u001B[38;5;'); + + applyTuiRenderTheme(MINIMAX_CODE_DARK_THEME, 3); + expect(tuiChalk.hex(tuiColors.text)('text')).toContain('\u001B[38;2;'); + expect(tuiChalk.hex(tuiColors.signal)('signal')).toContain('\u001B[38;2;'); + + applyTuiRenderTheme(MINIMAX_CODE_LIGHT_THEME, 1); + expect(tuiChalk.bold.hex(tuiColors.signal)('signal')).toContain('\u001B[94m'); + expect(tuiChalk.hex(tuiColors.line)('line')).toContain('\u001B[90m'); + expect(tuiChalk.hex(tuiColors.warning)('warning')).toContain('\u001B[33m'); + expect(tuiChalk.hex(tuiColors.error)('error')).toContain('\u001B[31m'); + expect(tuiChalk.bgHex(tuiColors.userMessageBg)('message')).toBe('message'); + expect(tuiChalk.bgHex(tuiColors.diffAddedBg)('addition')).toBe('addition'); + expect(tuiChalk.bgHex(tuiColors.diffRemovedBg)('deletion')).toBe('deletion'); + + applyTuiRenderTheme(MINIMAX_CODE_LIGHT_THEME, 2); + expect(tuiChalk.hex(tuiColors.signal)('signal')).toContain('\u001B[38;5;'); + expect(tuiChalk.bgHex(tuiColors.userMessageBg)('message')).toContain('\u001B[48;5;'); + expect(tuiChalk.bgHex(tuiColors.diffAddedBg)('addition')).toContain('\u001B[48;5;'); + } finally { + applyTuiRenderTheme(originalPalette, original.colorLevel); + } + }); + + it('keeps action guidance readable and makes keyboard controls prominent', () => { + const original = getTuiThemeSnapshot(); + const originalPalette = + original.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME; + + try { + applyTuiRenderTheme(MINIMAX_CODE_DARK_THEME, 3); + const rendered = renderTuiActionHint( + '↑↓ select · 1-9 choose · Enter confirm · d details · Ctrl+C stop · Option+M mode · /status refresh · Esc cancel', + ); + + expect(stripVTControlCharacters(rendered)).toBe( + '↑↓ select · 1-9 choose · Enter confirm · d details · Ctrl+C stop · Option+M mode · /status refresh · Esc cancel', + ); + for (const key of ['↑', '↓', '1-9', 'Enter', 'd', 'Ctrl+C', 'Option+M', '/status', 'Esc']) { + expect(rendered).toContain(tuiChalk.bold.hex(tuiColors.text)(key)); + } + } finally { + applyTuiRenderTheme(originalPalette, original.colorLevel); + } + }); + + it('uses adaptive Catppuccin syntax colors for settled and streaming code', () => { + const original = getTuiThemeSnapshot(); + const originalPalette = + original.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME; + const code = "const retries = 3; // keep streaming\nreturn 'ready';"; + + try { + applyTuiRenderTheme(MINIMAX_CODE_DARK_THEME, 3); + const settled = createTuiMarkdownTheme().highlightCode?.(code, 'ts').join('\n'); + const streaming = tuiStreamingMarkdownTheme.highlightCode?.(code, 'ts').join('\n'); + + expect(settled).toContain('\u001B[38;2;'); + expect(settled).not.toContain('\u001B[3m'); + expect(settled).not.toContain('\u001B[4m'); + expect(stripVTControlCharacters(settled ?? '')).toBe(code); + expect(streaming).toBe(settled); + + applyTuiRenderTheme(MINIMAX_CODE_LIGHT_THEME, 3); + const light = createTuiMarkdownTheme().highlightCode?.(code, 'ts').join('\n'); + + expect(light).toContain('\u001B[38;2;'); + expect(light).not.toBe(settled); + expect(stripVTControlCharacters(light ?? '')).toBe(code); + } finally { + applyTuiRenderTheme(originalPalette, original.colorLevel); + } + }); +}); + +describe('MCode terminal appearance detection', () => { + it('uses the final COLORFGBG component, including the ANSI 256 grayscale ramp', () => { + expect(parseColorFgBgAppearance('15;0')).toBe('dark'); + expect(parseColorFgBgAppearance('0;15')).toBe('light'); + expect(parseColorFgBgAppearance('7;232')).toBe('dark'); + expect(parseColorFgBgAppearance('0;255')).toBe('light'); + expect(parseColorFgBgAppearance('broken')).toBeUndefined(); + }); + + it('computes appearance from relative luminance', () => { + expect(appearanceFromRgb({ r: 23, g: 23, b: 23 })).toBe('dark'); + expect(appearanceFromRgb({ r: 250, g: 250, b: 250 })).toBe('light'); + }); + + it('falls back without treating the operating-system theme as terminal evidence', () => { + expect(resolveEnvironmentAppearance({ COLORFGBG: '15;0' })).toMatchObject({ + appearance: 'dark', + source: 'colorfgbg', + }); + expect(resolveEnvironmentAppearance({})).toEqual({ + appearance: 'dark', + source: 'fallback', + detail: 'no terminal background hint', + }); + }); +}); + +describe('TuiThemeController', () => { + it('uses Pi scheduled rendering when a theme change invalidates the component tree', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 255, g: 255, b: 255 }; + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + }); + const invalidate = vi.fn(); + const requestRender = vi.fn(); + const rendering = bindThemeRendering( + controller, + { invalidate, requestRender } as unknown as TUI, + () => true, + ); + + rendering.start(); + await vi.waitFor(() => expect(requestRender).toHaveBeenCalledOnce()); + + expect(invalidate).toHaveBeenCalledOnce(); + expect(requestRender).toHaveBeenCalledWith(); + controller.dispose(); + }); + + it('applies passive auto detection immediately through live theme bindings', () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '0;15' }, + }); + + expect(controller.snapshot()).toMatchObject({ appearance: 'light', source: 'colorfgbg' }); + expect(getTuiThemeSnapshot()).toMatchObject({ appearance: 'light' }); + expect(tuiColors.text).toBe(MINIMAX_CODE_LIGHT_THEME.colors.text); + expect(tuiChalk.hex(tuiColors.text)('body')).toContain('38;2;48;48;48'); + controller.dispose(); + }); + + it('uses passive evidence immediately and upgrades to terminal evidence asynchronously', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 255, g: 255, b: 255 }; + const onChange = vi.fn(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + }); + controller.onChange(onChange); + + expect(controller.snapshot()).toMatchObject({ appearance: 'dark', source: 'colorfgbg' }); + await controller.start(); + + expect(controller.snapshot()).toMatchObject({ appearance: 'light', source: 'osc11' }); + expect(onChange).toHaveBeenCalled(); + }); + + it('uses the actual OSC 11 background when a terminal reports a conflicting appearance', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 40, g: 44, b: 52 }; + const queryBackground = vi.spyOn(ui, 'queryTerminalBackgroundColor'); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '0;15' }, + }); + + await controller.start(); + ui.emit('light'); + await vi.waitFor(() => expect(queryBackground).toHaveBeenCalledTimes(2)); + + expect(controller.snapshot()).toMatchObject({ appearance: 'dark', source: 'osc11' }); + controller.dispose(); + }); + + it('does not request a redraw when only the detection source changes', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 0, g: 0, b: 0 }; + const onChange = vi.fn(); + const onDetection = vi.fn(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + onDetection, + }); + controller.onChange(onChange); + + await controller.start(); + + expect(controller.snapshot()).toMatchObject({ appearance: 'dark', source: 'osc11' }); + expect(onChange).not.toHaveBeenCalled(); + expect(onDetection).toHaveBeenLastCalledWith( + expect.objectContaining({ appearance: 'dark', source: 'osc11' }), + ); + controller.dispose(); + }); + + it('falls back to environment evidence when later terminal queries time out', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 255, g: 255, b: 255 }; + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + }); + + await controller.start(); + expect(controller.snapshot()).toMatchObject({ appearance: 'light', source: 'osc11' }); + + ui.nextBackground = undefined; + await controller.start(); + + expect(controller.snapshot()).toMatchObject({ appearance: 'dark', source: 'colorfgbg' }); + controller.dispose(); + }); + + it('always tracks supported terminal appearance changes', async () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ ui, colorLevel: 3, env: {} }); + await controller.start(); + + ui.nextBackground = { r: 255, g: 255, b: 255 }; + ui.emit('light'); + await vi.waitFor(() => expect(controller.snapshot().appearance).toBe('light')); + controller.dispose(); + }); + + it('follows a reported appearance change when the background query stops answering', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 255, g: 255, b: 255 }; + const onChange = vi.fn(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '0;15' }, + }); + controller.onChange(onChange); + + await controller.start(); + expect(controller.snapshot()).toMatchObject({ appearance: 'light', source: 'osc11' }); + + ui.nextBackground = undefined; + ui.emit('dark'); + + await vi.waitFor(() => + expect(controller.snapshot()).toMatchObject({ + appearance: 'dark', + source: 'terminal-report', + }), + ); + expect(onChange).toHaveBeenCalled(); + controller.dispose(); + }); + + it('never lets the process-start COLORFGBG snapshot override a later terminal report', async () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '0;15' }, + }); + + await controller.start(); + ui.emit('dark'); + await vi.waitFor(() => expect(controller.snapshot().appearance).toBe('dark')); + + // A renderer rebind re-queries without a fresh report; the stale light COLORFGBG must not win. + controller.rebindUi(); + await vi.waitFor(() => expect(controller.snapshot().source).toBe('terminal-report')); + expect(controller.snapshot().appearance).toBe('dark'); + controller.dispose(); + }); + + it('invalidates an in-flight terminal query when the active Pi renderer changes', async () => { + const ui = new ThemeUi(); + let resolveOldQuery: ((value: { r: number; g: number; b: number }) => void) | undefined; + const oldQuery = new Promise<{ r: number; g: number; b: number }>((resolve) => { + resolveOldQuery = resolve; + }); + const queryBackground = vi + .spyOn(ui, 'queryTerminalBackgroundColor') + .mockImplementationOnce(() => oldQuery) + .mockResolvedValue({ r: 0, g: 0, b: 0 }); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '0;15' }, + }); + + const starting = controller.start(); + await vi.waitFor(() => expect(queryBackground).toHaveBeenCalledOnce()); + controller.rebindUi(); + await vi.waitFor(() => expect(queryBackground).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => + expect(controller.snapshot()).toMatchObject({ appearance: 'dark', source: 'osc11' }), + ); + + resolveOldQuery?.({ r: 255, g: 255, b: 255 }); + await starting; + expect(controller.snapshot()).toMatchObject({ appearance: 'dark', source: 'osc11' }); + controller.dispose(); + }); +}); + +class ThemeUi implements TuiThemeUi { + nextBackground: { r: number; g: number; b: number } | undefined; + notificationsEnabled = false; + private listener: ((scheme: 'light' | 'dark') => void) | undefined; + + async queryTerminalBackgroundColor() { + return this.nextBackground; + } + + setTerminalColorSchemeNotifications(enabled: boolean): void { + this.notificationsEnabled = enabled; + } + + onTerminalColorSchemeChange(listener: NonNullable): () => void { + this.listener = listener; + return () => { + if (this.listener === listener) this.listener = undefined; + }; + } + + emit(scheme: Parameters>[0]): void { + this.listener?.(scheme); + } +} + +describe('TuiThemeController theme selection', () => { + const restoreTheme = () => { + const snapshot = getTuiThemeSnapshot(); + applyTuiRenderTheme( + snapshot.appearance === 'light' ? MINIMAX_CODE_LIGHT_THEME : MINIMAX_CODE_DARK_THEME, + snapshot.colorLevel, + ); + }; + + it('applies the saved theme selection at construction', () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + theme: 'aurora', + }); + + expect(controller.selectedThemeId()).toBe('aurora'); + expect(controller.snapshot().themeId).toBe('aurora'); + expect(getTuiThemeSnapshot()).toMatchObject({ name: 'aurora', appearance: 'dark' }); + expect(tuiColors.brand).toBe('#5CC8E8'); + controller.dispose(); + restoreTheme(); + }); + + it('falls back to the default theme when the saved theme is unknown', () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + theme: 'not-a-real-theme', + }); + + expect(controller.selectedThemeId()).toBe('minimax'); + controller.dispose(); + restoreTheme(); + }); + + it('switches the live palette and notifies listeners on setTheme', () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' } }); + const onChange = vi.fn(); + controller.onChange(onChange); + const before = tuiColors.brand; + + const applied = controller.setTheme('midnight'); + + expect(applied?.id).toBe('midnight'); + expect(controller.selectedThemeId()).toBe('midnight'); + expect(tuiColors.brand).not.toBe(before); + expect(tuiColors.brand).toBe('#5AB9FF'); + expect(onChange).toHaveBeenCalled(); + controller.dispose(); + restoreTheme(); + }); + + it('keeps the current theme when an unknown id is requested', () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' } }); + controller.setTheme('graphite'); + const brand = tuiColors.brand; + + expect(controller.setTheme('nope')).toBeUndefined(); + + expect(controller.selectedThemeId()).toBe('graphite'); + expect(tuiColors.brand).toBe(brand); + controller.dispose(); + restoreTheme(); + }); + + it('previews a theme without losing the previous selection on restore', () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' } }); + const original = tuiColors.brand; + + controller.previewTheme('aurora'); + expect(getTuiThemeSnapshot().name).toBe('aurora'); + + controller.previewTheme('minimax'); + expect(tuiColors.brand).toBe(original); + controller.dispose(); + restoreTheme(); + }); + + it('repaints code blocks with the active theme syntax tones', () => { + const ui = new ThemeUi(); + const controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' } }); + const code = "const retries = 3; // keep streaming\nreturn 'ready';"; + const minimaxCode = createTuiMarkdownTheme().highlightCode?.(code, 'ts').join('\n'); + + controller.setTheme('midnight'); + const midnightCode = createTuiMarkdownTheme().highlightCode?.(code, 'ts').join('\n'); + + expect(midnightCode).not.toBe(minimaxCode); + expect(stripVTControlCharacters(midnightCode ?? '')).toBe(code); + controller.dispose(); + restoreTheme(); + }); + + it('pins the appearance so terminal evidence cannot override it', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 0, g: 0, b: 0 }; + const controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' } }); + + controller.setAppearanceOverride('light'); + await controller.start(); + ui.emit('dark'); + await vi.waitFor(() => expect(controller.snapshot().source).toBe('osc11')); + + expect(controller.snapshot().appearance).toBe('light'); + expect(getTuiThemeSnapshot().appearance).toBe('light'); + controller.dispose(); + restoreTheme(); + }); + + it('restores terminal-driven appearance when the pin is cleared', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 255, g: 255, b: 255 }; + const controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' } }); + await controller.start(); + + controller.setAppearanceOverride('dark'); + expect(controller.snapshot().appearance).toBe('dark'); + + // No extra query: clearing the pin must restore the last terminal verdict + // immediately, which is what /theme's Esc and the auto cycle actually do. + controller.setAppearanceOverride(undefined); + + expect(controller.appearanceOverrideValue()).toBeUndefined(); + expect(controller.snapshot().appearance).toBe('light'); + expect(getTuiThemeSnapshot().appearance).toBe('light'); + expect(tuiColors.text).toBe(MINIMAX_CODE_LIGHT_THEME.colors.text); + controller.dispose(); + restoreTheme(); + }); + + it('restores a dark terminal verdict when clearing a light pin', async () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 0, g: 0, b: 0 }; + const controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' } }); + await controller.start(); + expect(controller.snapshot().appearance).toBe('dark'); + + controller.setAppearanceOverride('light'); + expect(controller.snapshot().appearance).toBe('light'); + + controller.setAppearanceOverride(undefined); + + expect(controller.snapshot().appearance).toBe('dark'); + expect(tuiColors.text).toBe(MINIMAX_CODE_DARK_THEME.colors.text); + controller.dispose(); + restoreTheme(); + }); + + it('repaints live colors when a custom theme file changes in place', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const dataDir = await mkdtemp(join(tmpdir(), 'mcode-theme-reload-')); + try { + const themesDir = join(dataDir, 'tui', 'themes'); + await mkdir(themesDir, { recursive: true }); + const file = join(themesDir, 'mine.json'); + const write = (brand: string) => + writeFile(file, JSON.stringify({ name: 'mine', appearance: 'dark', colors: { brand } })); + + await write('#112233'); + const ui = new ThemeUi(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + dataDir, + }); + expect(controller.setTheme('mine')).toBeDefined(); + expect(tuiColors.brand).toBe('#112233'); + const onChange = vi.fn(); + controller.onChange(onChange); + + // Same id, same appearance — only the color values move. + await write('#AABBCC'); + await vi.waitFor(() => expect(tuiColors.brand).toBe('#AABBCC'), { timeout: 4000 }); + + expect(controller.selectedThemeId()).toBe('mine'); + expect(onChange).toHaveBeenCalled(); + controller.dispose(); + } finally { + await rm(dataDir, { recursive: true, force: true }); + restoreTheme(); + } + }); + + it('does not repaint when a custom theme reload changes nothing', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const dataDir = await mkdtemp(join(tmpdir(), 'mcode-theme-noop-')); + try { + const themesDir = join(dataDir, 'tui', 'themes'); + await mkdir(themesDir, { recursive: true }); + const file = join(themesDir, 'mine.json'); + const body = JSON.stringify({ + name: 'mine', + appearance: 'dark', + colors: { brand: '#112233' }, + }); + await writeFile(file, body); + + const ui = new ThemeUi(); + const onThemesChanged = vi.fn(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + dataDir, + onThemesChanged, + }); + controller.setTheme('mine'); + const onChange = vi.fn(); + controller.onChange(onChange); + // The constructor load already fired once; only a watcher-driven reload + // may satisfy the wait below. + onThemesChanged.mockClear(); + + await writeFile(file, body); + // Wait for the reload to actually land. A bare sleep would also pass when + // the watcher never fired, which is exactly what this test must rule out. + await vi.waitFor(() => expect(onThemesChanged).toHaveBeenCalled(), { timeout: 4000 }); + + expect(tuiColors.brand).toBe('#112233'); + expect(onChange).not.toHaveBeenCalled(); + controller.dispose(); + } finally { + await rm(dataDir, { recursive: true, force: true }); + restoreTheme(); + } + }); + + it('applies a pinned appearance from the saved selection', () => { + const ui = new ThemeUi(); + ui.nextBackground = { r: 0, g: 0, b: 0 }; + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + theme: 'aurora/light', + }); + + expect(controller.selectedThemeId()).toBe('aurora'); + expect(controller.appearanceOverrideValue()).toBe('light'); + expect(controller.snapshot().appearance).toBe('light'); + expect(tuiColors.text).toBe('#1E2B31'); + controller.dispose(); + restoreTheme(); + }); + + it('lists built-in themes and surfaces custom load problems', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const dataDir = await mkdtemp(join(tmpdir(), 'mcode-theme-controller-')); + try { + await mkdir(join(dataDir, 'tui', 'themes'), { recursive: true }); + await writeFile( + join(dataDir, 'tui', 'themes', 'broken.json'), + JSON.stringify({ name: 'broken' }), + ); + const ui = new ThemeUi(); + const controller = new TuiThemeController({ + ui, + colorLevel: 3, + env: { COLORFGBG: '15;0' }, + dataDir, + }); + + expect(controller.listThemes().map((theme) => theme.id)).toContain('minimax'); + expect(controller.themeIssues()).toHaveLength(1); + controller.dispose(); + } finally { + await rm(dataDir, { recursive: true, force: true }); + restoreTheme(); + } + }); +}); diff --git a/release/public-source.json b/release/public-source.json index addd3d8c..058758e3 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -2791,6 +2791,7 @@ "packages/tui/THIRD_PARTY_NOTICES.md", "packages/tui/configs/data-minimal.yaml", "packages/tui/docs/status-line-config.md", + "packages/tui/docs/theme-config.md", "packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node", "packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node", "packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node", @@ -2970,6 +2971,7 @@ "packages/tui/src/tui/controller/product/model-state.ts", "packages/tui/src/tui/controller/product/session-mutation-flow.ts", "packages/tui/src/tui/controller/product/status-line-setup.ts", + "packages/tui/src/tui/controller/product/theme-setup.ts", "packages/tui/src/tui/controller/product/update-admission.ts", "packages/tui/src/tui/controller/product/update-flow.ts", "packages/tui/src/tui/controller/projection/status-metrics-flow.ts", @@ -3100,6 +3102,7 @@ "packages/tui/src/tui/features/settings/picker.ts", "packages/tui/src/tui/features/settings/status-line-copy.ts", "packages/tui/src/tui/features/settings/status-line-picker.ts", + "packages/tui/src/tui/features/settings/theme-picker.ts", "packages/tui/src/tui/features/transcript/panel.ts", "packages/tui/src/tui/features/update/panel.ts", "packages/tui/src/tui/foundation/bordered-table.ts", @@ -3162,8 +3165,10 @@ "packages/tui/src/tui/theme/ansi16.ts", "packages/tui/src/tui/theme/contracts.ts", "packages/tui/src/tui/theme/controller.ts", + "packages/tui/src/tui/theme/custom-themes.ts", "packages/tui/src/tui/theme/detection.ts", "packages/tui/src/tui/theme/palettes.ts", + "packages/tui/src/tui/theme/registry.ts", "packages/tui/src/tui/theme/render-binding.ts", "packages/tui/src/tui/theme/runtime.ts", "packages/tui/src/tui/theme/shell-command.ts", @@ -3211,6 +3216,7 @@ "packages/tui/src/update/startup-notice.ts", "packages/tui/src/update/versioned-prefix.ts", "packages/tui/src/user-facing-failure.ts", + "packages/tui/test/helpers/theme-contrast.ts", "packages/tui/test/helpers/virtual-terminal.ts", "packages/tui/test/pi-084-upstream/virtual-terminal.ts", "packages/tui/test/unit/acp-agent.test.ts", @@ -3227,6 +3233,7 @@ "packages/tui/test/unit/headless-model-selection.test.ts", "packages/tui/test/unit/headless-preparation-cancellation.test.ts", "packages/tui/test/unit/headless-runner.test.ts", + "packages/tui/test/unit/host-tui-settings.test.ts", "packages/tui/test/unit/incident-reporter-privacy.test.ts", "packages/tui/test/unit/mcode-tools-integration.test.ts", "packages/tui/test/unit/model-effort.test.ts", @@ -3287,6 +3294,10 @@ "packages/tui/test/unit/tui/features/composer/attachments.test.ts", "packages/tui/test/unit/tui/features/settings/hotkeys-picker.test.ts", "packages/tui/test/unit/tui/features/settings/status-line-picker.test.ts", + "packages/tui/test/unit/tui/features/settings/theme-picker.test.ts", + "packages/tui/test/unit/tui/theme/custom-themes.test.ts", + "packages/tui/test/unit/tui/theme/palettes.test.ts", + "packages/tui/test/unit/tui/theme/runtime.test.ts", "packages/tui/test/unit/tui/widgets/editor/editor-behavior.test.ts", "packages/tui/test/unit/update-application.test.ts", "packages/tui/test/unit/update-service.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 0601656f..b1c80517 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -165,7 +165,12 @@ "packages/local-runtime-v2/test/integration/btw-settled-history.integration.test.ts", "packages/tui/test/unit/tui/controller/side-session-flow.test.ts", "packages/tui/test/unit/observability.test.ts", - "packages/local-runtime-v2/src/service/session-system/fork/side-history-boundary.test.ts" + "packages/local-runtime-v2/src/service/session-system/fork/side-history-boundary.test.ts", + "packages/tui/test/unit/host-tui-settings.test.ts", + "packages/tui/test/unit/tui/features/settings/theme-picker.test.ts", + "packages/tui/test/unit/tui/theme/custom-themes.test.ts", + "packages/tui/test/unit/tui/theme/palettes.test.ts", + "packages/tui/test/unit/tui/theme/runtime.test.ts" ], "status-contract": [ "packages/tui/test/unit/tui-build-mode-contract.test.ts" From a346908903d66757dbff385a3e26a02c232f9c3c Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:57:53 +0800 Subject: [PATCH 05/15] chore: release MiniMax Code 0.5.2 (#313) --- package.json | 2 +- packages/tui/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index b231e65f..0accb098 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minimax-code", - "version": "0.5.1", + "version": "0.5.2", "private": true, "type": "module", "description": "Standalone MiniMax Code TUI with managed accounts, BYOK models, cloud tools, plugins and ACP.", diff --git a/packages/tui/package.json b/packages/tui/package.json index 8342bcb7..95e9a476 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@minimax/code", - "version": "0.5.1", + "version": "0.5.2", "private": true, "description": "Minimax Code CLI and TUI product entry.", "type": "module", From 44b13d381e6f6494357d48e8f2473e0e65753379 Mon Sep 17 00:00:00 2001 From: TrentChou <49196512+TrentChou@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:40:49 +0800 Subject: [PATCH 06/15] perf(edit): bound the post-edit diff and unified patch (#284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(edit): bound the post-edit diff and unified patch `edit` computed its display diff and its unified patch with unbounded Myers, which costs O((N+M)*D) in the edit-script length D. A whole-file rewrite therefore scaled quadratically in the number of changed lines: rewriting every line of a 20 000-line file blocked the tool for over two minutes and produced a multi-megabyte patch that no renderer displays. The sibling `write` path already caps the same work in `packages/agent-tools/src/shared/write-capture.ts`; `edit` had no cap. Pass jsdiff's `maxEditLength` (1000 edits) and `timeout` (5 s) to `diffLines` and `createTwoFilesPatch`. `maxEditLength` is the primary bound because it is deterministic and therefore unit-testable; `timeout` only backstops slow machines. When a bound trips, `details.diff` carries a one-line notice so every renderer still has something to show, `details.patch` is omitted, and the new `details.diffOmitted` names the reason. The unified patch is skipped once the display diff was abandoned rather than repeating a second Myers run that aborts on the same bound. The file is written before any diff runs, so a dropped diff never changes what lands on disk. Measured end to end through `createEditTool` on a 20 000-line whole-file rewrite, three runs each on the same machine: 120 s / 138 s / 139 s before, 60 ms / 60 ms / 58 ms after. Assisted-by: minimax-code reason:bound-edit-diff-cost * fix(tui): render an omitted edit diff as a summary block When the edit tool hits its diff bounds it replaces the diff body with a one-line notice, so the body carries no `+`/`-` lines. Both preview consumers misread that notice as a real diff: - The TUI counted changed lines from the body and rendered `subject.ts · +0 -0 · Applied` for a 20k-line rewrite. - The ACP projector ran the notice through `parseUnifiedDiff`, emitting a diff block with `oldText === newText === "(diff omitted: ...)"`, which a client renders as "no changes". Read `details.diffOmitted` alongside `details.diff` and emit the existing summary block instead, which renders as a header plus the notice and needs no line counts. `timeout` maps to `unavailable`, bounds hits to `too-large`. E2E: PASS (tmux TUI against dist/cli.js, 20000-line replace_all edit: header is `subject.ts · Applied` + `(diff omitted: more than 1000 lines changed)`; a 1-line edit still renders `+1 -1 · Applied`) * fix(agent-tools): keep Compatible hooks working when the edit patch is bounded out Bounding the post-edit diff drops `details.patch` on exactly the edits the bound targets, and `withCompatibleEditToolResponse` returned the result untouched in that case. The Compatible PostToolUse payload then carried no `tool_response`, so `needsPostToolAdapter` found no usable CLAUDE handler and the runner rejected the hook with HOOK_INVALID_INPUT — every Compatible PostToolUse hook was silently skipped on large edits that used to run fine. There is no fallback for `edit` in `buildCompatiblePostToolResponse`, unlike bash, read and mcp. Synthesize the structured patch instead of reparsing one that does not exist: a bounded-out edit is by definition a wholesale rewrite, so emit a single hunk that removes every old line and adds every new one. This costs no Myers run, keeps the payload the same order as the `originalFile` already in it, and stays a correct — if imprecise — description of what landed on disk. The `\ No newline at end of file` marker is reproduced on whichever side lacks the trailing newline, in jsdiff's position. `EditCapture` now also carries the written content, and BOM stripping is shared between the two sides so the synthesized hunk matches what jsdiff would have produced. Assisted-by: minimax-code reason:bound-edit-diff-cost * fix(edit): report why the unified patch was omitted The unified patch is a second, independently timed Myers run over the same input as the display diff. When only that run ran out of time, `details` held a diff, no patch, and no `diffOmitted`, so a consumer could not tell an omitted patch apart from a tool that never produces one. Add `details.patchOmitted`: it mirrors `diffOmitted` when the display diff was abandoned, and reports `timeout` when only the patch was, so `details.patch` is absent exactly when `patchOmitted` is set. jsdiff routes `createTwoFilesPatch` through the same bounded `diffLines`, so `maxEditLength` is deterministic across both runs and can never trip for the patch alone; only its separately measured wall clock can. `diffOmitted` keeps its meaning and is unchanged. The patch-only timeout is not unit-testable — it needs the two runs to land on opposite sides of a 5 s wall clock, which no deterministic input can force — so the tests pin the invariant on both reachable paths instead. Assisted-by: minimax-code reason:bound-edit-diff-cost * fix(edit): raise the diff bound so ordinary rewrites keep their diff `maxEditLength` bounds D, the length of the Myers edit script, not the number of changed lines: replacing a line costs one deletion plus one insertion. A bound of 1000 therefore gave up on any full rewrite past 500 lines, and a reported 501-line whole-file replacement lost its diff even though the whole tool call, diff included, finishes in under 55 ms. That is well inside what the tool should still render. Raise the bound to 2000, which admits a full rewrite of any file up to 1000 lines. Measured end to end through `createEditTool`, three runs each on the same machine: 53 / 49 / 47 ms for a 501-line rewrite, 177 / 173 / 177 ms for 1000 lines, and 200 / 192 / 193 ms for a 20 000-line rewrite that still gives up — against 167 620 / 173 523 / 167 547 ms unbounded, with peak heap dropping from 47-60 MB to 14-15 MB. Doubling the bound roughly doubles the worst case the bound itself admits, which stays near 200 ms. Also correct the notice the bound emits. It read "more than N lines changed", which misreports a rewrite by a factor of two; D is exactly the number of added plus removed lines, so say that instead. Assisted-by: minimax-code reason:bound-edit-diff-cost * fix(agent-tools): send an empty structured patch when the edit patch is bounded out Synthesizing a whole-file hunk kept Compatible PostToolUse handlers selectable when the bound dropped `details.patch`, but pushed the hook payload past the runner's 1 MiB input limit on exactly the large files the bound targets. A 20 000-line file with 1001 changed lines serialized to 1 679 234 B and failed `serializeHookInput`, so the handler still never ran — it now failed one step later than before. Trimming the common prefix and suffix does not fix that either: the same shape still reaches 1 154 543 B on a 960 KB file. `structuredPatch` is a required array in the Compatible file-edit output schema, and the upstream tool already sends `[]` when its own diff times out, so the empty array is that schema's own way to say "no diff information". It keeps the handler selectable, keeps `edit` renamed to `Edit` for matchers, and passes the write-back `hasCompatibleJsonShape` check, which compares array-ness only and so still accepts a hook that returns real hunks. The same payload now serializes to 563 600 B. `EditCapture.updatedFile` existed only to feed the synthesized hunk and goes away with it, restoring the capture to its previous shape. A malformed `details.patch` now also degrades to `[]` instead of dropping the whole compatible response, which is strictly better: the handler runs rather than being skipped with HOOK_INVALID_INPUT. This does not make every hook payload fit. The response separately carries `originalFile`, `oldString` and `newString` in full, so a whole-file rewrite of a large file still exceeds the limit on those fields alone, both before and after this change. That bound predates this branch and is left alone. Also correct the stale timings in the test header, which predated the remeasurement at the current bound. Assisted-by: minimax-code reason:bound-edit-diff-cost --- .../src/desktop/edit-diff-bounds.test.ts | 179 ++++++++++++++++++ .../src/plugin-hooks/vendor-tool-response.ts | 54 ++++-- packages/tui/src/runtime/tool-preview.ts | 36 +++- .../tui-tool-preview-diff-omitted.test.ts | 106 +++++++++++ release/public-source.json | 2 + test/vitest-suites.json | 2 + third_party/pi-mono/MINIMAX_CHANGES.md | 16 ++ .../coding-agent/src/core/tools/edit-diff.ts | 66 ++++++- .../coding-agent/src/core/tools/edit.ts | 29 ++- 9 files changed, 456 insertions(+), 34 deletions(-) create mode 100644 packages/agent-tools/src/desktop/edit-diff-bounds.test.ts create mode 100644 packages/tui/test/unit/tui-tool-preview-diff-omitted.test.ts diff --git a/packages/agent-tools/src/desktop/edit-diff-bounds.test.ts b/packages/agent-tools/src/desktop/edit-diff-bounds.test.ts new file mode 100644 index 00000000..9877decd --- /dev/null +++ b/packages/agent-tools/src/desktop/edit-diff-bounds.test.ts @@ -0,0 +1,179 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { readPluginHookCompatibleToolResponse } from '../plugin-hooks/vendor-tool-response.js'; +import { LocalEditTool } from './local-pi-tools.js'; + +interface CompatibleHunk { + readonly oldStart: number; + readonly oldLines: number; + readonly newStart: number; + readonly newLines: number; + readonly lines: readonly string[]; +} + +const context = { sessionId: 'edit-diff-bounds-session', turnId: 'edit-diff-bounds-turn' }; + +// Myers costs O((N+M)·D) in the length D of the edit script, so a whole-file +// rewrite is quadratic in the number of changed lines. Rewriting every line of +// this file took 167.5-173.5 s unbounded (the tool diffed the same input twice) +// and produced a diff no renderer displays; bounded it settles in 192-200 ms. +// Vitest's default 5 s timeout therefore also guards the bound: if it is ever +// removed, the whole-file case stops finishing in time. +const wholeFileRewriteLines = 20_000; + +// Replacing a line costs 2 edits, so DIFF_MAX_EDIT_LENGTH (2000) admits a full +// rewrite of a file this long and omits anything longer. +const boundedRewriteLines = 1_000; + +function body(lines: number, render: (index: number) => string): string { + return `${Array.from({ length: lines }, (_, index) => render(index)).join('\n')}\n`; +} + +describe('edit diff bounds', () => { + let directory: string; + let file: string; + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'edit-diff-bounds-')); + file = join(directory, 'subject.ts'); + }); + + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + it('keeps the diff and patch for an ordinary edit', async () => { + await writeFile(file, 'const value = 1;\nconst other = 2;\n'); + + const result = await new LocalEditTool(directory).execute(context, { + file_path: 'subject.ts', + old_string: 'const value = 1;', + new_string: 'const value = 42;', + }); + + expect(await readFile(file, 'utf8')).toBe('const value = 42;\nconst other = 2;\n'); + expect(result.details?.diffOmitted).toBeUndefined(); + expect(result.details?.patchOmitted).toBeUndefined(); + expect(result.details?.diff).toContain('const value = 42;'); + expect(result.details?.patch).toContain('@@'); + expect(result.details?.patch).toContain('+const value = 42;'); + }); + + it('keeps the diff for a large but bounded block replacement', async () => { + const original = body(wholeFileRewriteLines, (index) => `const value${index} = ${index};`); + await writeFile(file, original); + // 400 replaced lines cost 800 edits, which stays under the bound even + // though the surrounding file is large: the bound tracks changed lines, + // not file size. + const oldBlock = body(400, (index) => `const value${index} = ${index};`); + const newBlock = body(400, (index) => `const value${index} = ${index + 1};`); + + const result = await new LocalEditTool(directory).execute(context, { + file_path: 'subject.ts', + old_string: oldBlock, + new_string: newBlock, + }); + + expect(await readFile(file, 'utf8')).toBe(`${newBlock}${original.slice(oldBlock.length)}`); + expect(result.details?.diffOmitted).toBeUndefined(); + expect(result.details?.patch).toContain('@@'); + // The Compatible hook keeps the real hunks whenever the patch survives. + const hunks = readPluginHookCompatibleToolResponse(result)?.structuredPatch as + | readonly CompatibleHunk[] + | undefined; + expect(hunks?.length).toBeGreaterThan(0); + expect(hunks?.some((hunk) => hunk.lines.includes('+const value0 = 1;'))).toBe(true); + }); + + // Rewriting a few hundred lines is an ordinary edit, not the pathological + // case this bound targets: a reported 501-line whole-file replacement lost + // its diff under the first bound this test pins. + it('keeps the diff for a whole-file rewrite that fits the bound', async () => { + const original = body(boundedRewriteLines, (index) => `const value${index} = ${index};`); + const rewritten = body(boundedRewriteLines, (index) => `let renamed${index} = ${index * 2};`); + await writeFile(file, original); + + const result = await new LocalEditTool(directory).execute(context, { + file_path: 'subject.ts', + old_string: original, + new_string: rewritten, + }); + + expect(await readFile(file, 'utf8')).toBe(rewritten); + expect(result.details?.diffOmitted).toBeUndefined(); + expect(result.details?.patchOmitted).toBeUndefined(); + expect(result.details?.patch).toContain('+let renamed0 = 0;'); + }); + + it('omits the diff for a whole-file rewrite but still writes the file', async () => { + const original = body(wholeFileRewriteLines, (index) => `const value${index} = ${index};`); + const rewritten = body(wholeFileRewriteLines, (index) => `let renamed${index} = ${index * 2};`); + await writeFile(file, original); + + const result = await new LocalEditTool(directory).execute(context, { + file_path: 'subject.ts', + old_string: original, + new_string: rewritten, + }); + + // The edit itself must be unaffected: the diff is a receipt computed after + // the write, so giving it up never changes what lands on disk. + expect(await readFile(file, 'utf8')).toBe(rewritten); + expect(result.isError).toBeFalsy(); + + expect(result.details?.diffOmitted).toBe('too_many_changes'); + expect(result.details?.patchOmitted).toBe('too_many_changes'); + expect(result.details?.patch).toBeUndefined(); + expect(result.details?.diff).toContain('diff omitted'); + }); + + // A Compatible PostToolUse handler is skipped with HOOK_INVALID_INPUT when + // `structuredPatch` is missing, so dropping `details.patch` silently disabled + // every such hook on exactly the edits this bound targets. + it('sends the Compatible hook an empty structured patch when the patch is omitted', async () => { + const original = body(wholeFileRewriteLines, (index) => `const value${index} = ${index};`); + const rewritten = body(wholeFileRewriteLines, (index) => `let renamed${index} = ${index * 2};`); + await writeFile(file, original); + + const result = await new LocalEditTool(directory).execute(context, { + file_path: 'subject.ts', + old_string: original, + new_string: rewritten, + }); + + expect(result.details?.patch).toBeUndefined(); + const response = readPluginHookCompatibleToolResponse(result); + expect(response).toBeDefined(); + expect(response?.originalFile).toBe(original); + expect(response?.structuredPatch).toEqual([]); + }); + + // The runner rejects a serialized hook input over MAX_INPUT_BYTES (1 MiB), so + // an unavailable patch has to degrade to something bounded: describing the + // edit as one whole-file hunk grew this payload to 1 679 234 B against + // 563 600 B here, and failed the same handler a second way. The response + // separately carries `originalFile`, `oldString` and `newString` in full, so + // this case keeps the edit strings small to isolate the patch field. + it('keeps the Compatible hook payload under the runner input limit', async () => { + const original = body(wholeFileRewriteLines, (index) => `const value${index} = ${index};`); + await writeFile(file, original); + // 1001 replaced lines cost 2002 edits, one past the bound. + const oldBlock = body(1_001, (index) => `const value${index} = ${index};`); + const newBlock = body(1_001, (index) => `const value${index} = ${index + 1};`); + + const result = await new LocalEditTool(directory).execute(context, { + file_path: 'subject.ts', + old_string: oldBlock, + new_string: newBlock, + }); + + expect(result.details?.patchOmitted).toBe('too_many_changes'); + const response = readPluginHookCompatibleToolResponse(result); + expect(response?.structuredPatch).toEqual([]); + expect(Buffer.byteLength(JSON.stringify(response), 'utf8')).toBeLessThan(1024 * 1024); + }); +}); diff --git a/packages/agent-tools/src/plugin-hooks/vendor-tool-response.ts b/packages/agent-tools/src/plugin-hooks/vendor-tool-response.ts index 69ca7c8d..09e4a4c9 100644 --- a/packages/agent-tools/src/plugin-hooks/vendor-tool-response.ts +++ b/packages/agent-tools/src/plugin-hooks/vendor-tool-response.ts @@ -195,6 +195,14 @@ export function withCompatibleGrepToolResponse( }); } +interface CompatibleStructuredPatchHunk { + readonly oldStart: number; + readonly oldLines: number; + readonly newStart: number; + readonly newLines: number; + readonly lines: readonly string[]; +} + export function withCompatibleEditToolResponse( result: ToolResult, input: { @@ -206,30 +214,40 @@ export function withCompatibleEditToolResponse( readonly userModified: boolean; }, ): ToolResult { - const patch = typeof result.details?.patch === 'string' ? result.details.patch : undefined; - if (!patch) return result; - let parsed: ReturnType[number] | undefined; - try { - parsed = parsePatch(patch)[0]; - } catch { - // Hook compatibility is an enhancement; malformed vendor metadata must - // never turn a successful edit into a failed tool call. - return result; - } - if (!parsed) return result; return withPluginHookCompatibleToolResponse(result, { filePath: input.filePath, oldString: input.oldString, newString: input.newString, originalFile: input.originalFile, - structuredPatch: parsed.hunks.map((hunk) => ({ - oldStart: hunk.oldStart, - oldLines: hunk.oldLines, - newStart: hunk.newStart, - newLines: hunk.newLines, - lines: hunk.lines, - })), + structuredPatch: parseStructuredPatch(result.details?.patch), userModified: input.userModified, replaceAll: input.replaceAll, }); } + +/** + * `structuredPatch` is a required array in the Compatible file-edit response, + * and the upstream tool already sends `[]` when its own diff gives up, so an + * unavailable patch degrades to the empty array. Dropping the field instead + * makes the runner skip the handler before it starts, and describing the edit + * as one whole-file hunk pushes the payload past the runner's input limit. + */ +function parseStructuredPatch(patch: unknown): CompatibleStructuredPatchHunk[] { + if (typeof patch !== 'string') return []; + let parsed: ReturnType[number] | undefined; + try { + parsed = parsePatch(patch)[0]; + } catch { + // Hook compatibility is an enhancement; malformed vendor metadata must + // never turn a successful edit into a failed tool call. + return []; + } + if (!parsed) return []; + return parsed.hunks.map((hunk) => ({ + oldStart: hunk.oldStart, + oldLines: hunk.oldLines, + newStart: hunk.newStart, + newLines: hunk.newLines, + lines: hunk.lines, + })); +} diff --git a/packages/tui/src/runtime/tool-preview.ts b/packages/tui/src/runtime/tool-preview.ts index f1dd3190..9f04dbeb 100644 --- a/packages/tui/src/runtime/tool-preview.ts +++ b/packages/tui/src/runtime/tool-preview.ts @@ -40,7 +40,13 @@ function buildEditBlocks( ): TuiStructuredPreviewBlock[] { const path = readString(input, ['path', 'filePath', 'file_path']); const resultDiff = readToolResultDiff(output); - if (resultDiff) return [createDiffBlock(path, resultDiff)]; + if (resultDiff) { + return [ + resultDiff.omitted === undefined + ? createDiffBlock(path, resultDiff.diff) + : createOmittedDiffBlock(path, resultDiff.diff, resultDiff.omitted), + ]; + } return readEditPairs(input).map(({ oldText, newText }) => { const diff = [ @@ -108,7 +114,28 @@ function createDiffBlock(path: string | undefined, rawDiff: string): TuiStructur }; } -function readToolResultDiff(output: unknown): string | undefined { +// The edit tool replaces the diff body with a one-line notice once it hits its own +// bounds, so the body carries no `+`/`-` lines. Rendering that as a diff block reports +// `+0 -0`, which reads as "nothing changed" on exactly the largest edits. +function createOmittedDiffBlock( + path: string | undefined, + message: string, + omittedReason: string, +): TuiStructuredPreviewBlock { + return { + kind: 'summary', + ...(path ? { path } : {}), + message, + reason: omittedReason === 'timeout' ? 'unavailable' : 'too-large', + }; +} + +interface ToolResultDiff { + readonly diff: string; + readonly omitted?: string; +} + +function readToolResultDiff(output: unknown): ToolResultDiff | undefined { const root = parseRecord(output); if (!root) return undefined; const candidates = [root, parseRecord(root.result), parseRecord(root.output)].filter( @@ -117,7 +144,10 @@ function readToolResultDiff(output: unknown): string | undefined { for (const candidate of candidates) { const details = parseRecord(candidate.details); const diff = details ? readString(details, ['diff', 'previewDiff', 'preview_diff']) : undefined; - if (diff) return diff; + if (diff) { + const omitted = details ? readString(details, ['diffOmitted', 'diff_omitted']) : undefined; + return omitted === undefined ? { diff } : { diff, omitted }; + } } return undefined; } diff --git a/packages/tui/test/unit/tui-tool-preview-diff-omitted.test.ts b/packages/tui/test/unit/tui-tool-preview-diff-omitted.test.ts new file mode 100644 index 00000000..4e886441 --- /dev/null +++ b/packages/tui/test/unit/tui-tool-preview-diff-omitted.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { TuiAcpUpdateProjector } from '../../src/acp/updates.js'; +import { buildTuiToolPreview } from '../../src/runtime/tool-preview.js'; + +const PATH = '/tmp/subject.ts'; + +const INPUT = { + file_path: PATH, + old_string: 'oldToken', + new_string: 'newToken', + replace_all: true, +}; + +function editPreview(details: Record) { + return buildTuiToolPreview({ + toolName: 'edit', + status: 'completed', + input: INPUT, + output: { details }, + }); +} + +function acpToolContent(details: Record) { + const update = new TuiAcpUpdateProjector().project({ + type: 'delta', + toolCalls: [ + { + id: 'tool-1', + name: 'edit', + status: 'completed', + input: INPUT, + output: { details }, + structuredPreview: editPreview(details), + }, + ], + })[0]; + if (update?.sessionUpdate !== 'tool_call') { + throw new Error(`expected a tool_call update, got ${update?.sessionUpdate}`); + } + return update.content; +} + +describe('buildTuiToolPreview edit diff bounds', () => { + it('keeps a diff block with real counts when the edit tool returns a diff body', () => { + const preview = editPreview({ + diff: ['-1 export const a = oldToken(1);', '+1 export const a = newToken(1);'].join('\n'), + patch: 'irrelevant', + }); + + const block = preview?.blocks[0]; + if (block?.kind !== 'diff') throw new Error(`expected a diff block, got ${block?.kind}`); + expect(block.addedLines).toBe(1); + expect(block.removedLines).toBe(1); + expect(block.path).toBe(PATH); + }); + + it('renders a summary block instead of a misleading +0 -0 diff when the diff is omitted', () => { + const preview = editPreview({ + diff: '(diff omitted: more than 2000 added or removed lines)', + diffOmitted: 'too_many_changes', + }); + + const block = preview?.blocks[0]; + if (block?.kind !== 'summary') throw new Error(`expected a summary block, got ${block?.kind}`); + expect(block.reason).toBe('too-large'); + expect(block.message).toBe('(diff omitted: more than 2000 added or removed lines)'); + expect(block.path).toBe(PATH); + expect(block).not.toHaveProperty('addedLines'); + expect(block).not.toHaveProperty('removedLines'); + }); + + it('maps a timed-out diff to the unavailable summary reason', () => { + const preview = editPreview({ + diff: '(diff omitted: computing it exceeded 5000 ms)', + diffOmitted: 'timeout', + }); + + const block = preview?.blocks[0]; + if (block?.kind !== 'summary') throw new Error(`expected a summary block, got ${block?.kind}`); + expect(block.reason).toBe('unavailable'); + }); +}); + +describe('TuiAcpUpdateProjector edit diff bounds', () => { + it('still projects a real diff body as an ACP diff block', () => { + const content = acpToolContent({ + diff: ['-1 export const a = oldToken(1);', '+1 export const a = newToken(1);'].join('\n'), + }); + + expect(content?.[0]).toMatchObject({ type: 'diff', path: PATH }); + }); + + it('projects an omitted diff as text instead of a diff block with a parsed-notice body', () => { + const content = acpToolContent({ + diff: '(diff omitted: more than 2000 added or removed lines)', + diffOmitted: 'too_many_changes', + }); + + expect(content).toEqual([ + { + type: 'content', + content: { type: 'text', text: '(diff omitted: more than 2000 added or removed lines)' }, + }, + ]); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 058758e3..b5fd4d50 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -331,6 +331,7 @@ "packages/agent-tools/src/desktop/builtin-defs.ts", "packages/agent-tools/src/desktop/canonical-tool-policy.ts", "packages/agent-tools/src/desktop/cloud-session-reader.ts", + "packages/agent-tools/src/desktop/edit-diff-bounds.test.ts", "packages/agent-tools/src/desktop/host-trash-executor.ts", "packages/agent-tools/src/desktop/index.ts", "packages/agent-tools/src/desktop/local-ask-user.ts", @@ -3281,6 +3282,7 @@ "packages/tui/test/unit/tui-terminal-image-paste.test.ts", "packages/tui/test/unit/tui-terminal-text-paste.test.ts", "packages/tui/test/unit/tui-thinking-preview.test.ts", + "packages/tui/test/unit/tui-tool-preview-diff-omitted.test.ts", "packages/tui/test/unit/tui-transcript-presentation.test.ts", "packages/tui/test/unit/tui-transcript-view.test.ts", "packages/tui/test/unit/tui-workspace-status-line.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index b1c80517..c8a8090f 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -8,6 +8,8 @@ "packages/agent-core/test/unit/bash-subprocess-env.test.ts", "packages/agent-modules/permission/test/unit/permission/bash-policy-regressions.test.ts", "packages/agent-tools/src/shared/replace-all-edit.test.ts", + "packages/agent-tools/src/desktop/edit-diff-bounds.test.ts", + "packages/tui/test/unit/tui-tool-preview-diff-omitted.test.ts", "packages/local-runtime-v2/test/unit/agent/agent-import.test.ts", "packages/tui/test/unit/auth-application.test.ts", "packages/tui/test/unit/tui-assistant-content.test.ts", diff --git a/third_party/pi-mono/MINIMAX_CHANGES.md b/third_party/pi-mono/MINIMAX_CHANGES.md index fb2a7d86..d81c2b27 100644 --- a/third_party/pi-mono/MINIMAX_CHANGES.md +++ b/third_party/pi-mono/MINIMAX_CHANGES.md @@ -13,6 +13,22 @@ This directory vendors `pi-mono` as source so MiniMax can patch, validate, and s No upstream source files are changed in the baseline import. +### 2026-09-21 — report why the edit unified patch was omitted + +- Reason: the unified patch is a second, independently timed Myers run over the same input as the display diff. When only that run ran out of time, `details` held a diff, no patch, and no `diffOmitted` — consumers could not tell an omitted patch apart from a tool that never produces one. +- Affected package: `packages/coding-agent` (`@earendil-works/pi-coding-agent`), edit tool details assembly. +- Change type: generic, upstreamable correctness fix. Add `details.patchOmitted`, set to `diffOmitted` when the display diff was abandoned and to `timeout` when only the patch was, so `details.patch` is absent exactly when `patchOmitted` is set. jsdiff routes `createTwoFilesPatch` through the same bounded `diffLines`, so `maxEditLength` is deterministic across both runs and can never trip for the patch alone; only its separately measured wall clock can. `diffOmitted` keeps its meaning and is unchanged. +- Upstream PR: not opened. +- Validation: `packages/agent-tools/src/desktop/edit-diff-bounds.test.ts` pins the invariant on both reachable paths (an ordinary edit and a bounded-out whole-file rewrite). The patch-only timeout is not unit-testable: it needs the two runs to land on opposite sides of a 5 s wall clock, which no deterministic input can force. + +### 2026-09-21 — bound the post-edit diff of the edit tool + +- Reason: `edit` computed its display diff and its unified patch with unbounded Myers, which costs O((N+M)·D) in the length D of the edit script. A whole-file rewrite therefore scaled quadratically in the number of changed lines: rewriting every line of a 20 000-line file blocked the tool for over two minutes and produced a multi-megabyte patch that no renderer displays. The sibling `write` path already caps the same work (`packages/agent-tools/src/shared/write-capture.ts`); `edit` had no cap. +- Affected package: `packages/coding-agent` (`@earendil-works/pi-coding-agent`), edit tool diff generation. +- Change type: generic, upstreamable performance fix. Pass jsdiff's `maxEditLength` (2000 edits) and `timeout` (5 s) to `diffLines` and `createTwoFilesPatch`. `maxEditLength` is the primary bound because it is deterministic and therefore testable; `timeout` only backstops slow machines. Replacing a line costs 2 edits, so the bound admits a full rewrite of any file up to 1000 lines and only gives up past that. When a bound trips, `details.diff` carries a one-line notice so every renderer still has something to show, `details.patch` is omitted, and the new `details.diffOmitted` names the reason. The unified patch is skipped once the display diff was abandoned rather than repeating a second Myers run that aborts on the same bound. The file is written before any diff runs, so a dropped diff never changes what lands on disk. +- Upstream PR: not opened. +- Validation: `packages/agent-tools/src/desktop/edit-diff-bounds.test.ts` (registered in the `capability` suite) covers an ordinary edit, a large block replacement that stays under the bound, a full rewrite at the bound that keeps its diff, and a whole-file rewrite that keeps the write while dropping the diff; `pnpm verify --profile platform` on macOS. Measured end to end through `createEditTool`, three runs each on the same machine: a 20 000-line whole-file rewrite took 167 620 / 173 523 / 167 547 ms unbounded and 200 / 192 / 193 ms bounded, with peak heap dropping from 47–60 MB to 14–15 MB; a 501-line rewrite takes 53 / 49 / 47 ms and a 1000-line rewrite 177 / 173 / 177 ms, both keeping their diff. + ### 2026-09-19 — preserve the system role for Mistral Chat Completions - Reason: thinking-enabled custom OpenAI-compatible connections to `api.mistral.ai` emitted `developer`, which is absent from the [Mistral Chat Completions message contract](https://docs.mistral.ai/api/endpoint/chat). [OpenClaw's compatibility defaults](https://github.com/openclaw/openclaw/blob/e2bcb1614de060927121bd72de850cee3a08d308/packages/ai/src/transports/openai-completions-compat.ts#L184-L210) also disable this role for the Mistral public endpoint. diff --git a/third_party/pi-mono/packages/coding-agent/src/core/tools/edit-diff.ts b/third_party/pi-mono/packages/coding-agent/src/core/tools/edit-diff.ts index b2851d6f..39e2db2b 100644 --- a/third_party/pi-mono/packages/coding-agent/src/core/tools/edit-diff.ts +++ b/third_party/pi-mono/packages/coding-agent/src/core/tools/edit-diff.ts @@ -394,24 +394,72 @@ export function applyEditsToNormalizedContent( return { baseContent: normalizedContent, newContent }; } -/** Generate a standard unified patch. */ -export function generateUnifiedPatch(path: string, oldContent: string, newContent: string, contextLines = 4): string { +/** + * Bounds for the post-edit diff. The diff is a receipt computed after the file + * has already been written (and, for the TUI preview, a courtesy rendering), so + * giving it up never changes what lands on disk. + * + * Myers runs in O((N+M)·D) where D is the edit-script length. A whole-file + * replacement that touches tens of thousands of lines therefore takes minutes + * and hundreds of MB unbounded, and produces a multi-MB diff nothing can + * render. `maxEditLength` caps D deterministically (the same input always + * aborts at the same point, so the behavior is unit-testable); `timeout` is a + * wall-clock safety net for slow machines and pathological inputs. + * + * D is sized so that a full rewrite of an ordinary source file keeps its diff: + * replacing every line costs 2 edits per line, so 2000 covers files up to 1000 + * lines. Measured end to end on an M-series laptop, rewriting every line costs + * 49 ms at 500 lines and 177 ms at 1000; a 20 000-line rewrite gives up after + * 193 ms, against about 170 s unbounded. + */ +export const DIFF_MAX_EDIT_LENGTH = 2000; +export const DIFF_TIMEOUT_MS = 5_000; + +/** Why no diff was produced: the edit script exceeded DIFF_MAX_EDIT_LENGTH, or DIFF_TIMEOUT_MS elapsed first. */ +export type DiffOmittedReason = "too_many_changes" | "timeout"; + +const DIFF_BOUNDS = { maxEditLength: DIFF_MAX_EDIT_LENGTH, timeout: DIFF_TIMEOUT_MS } as const; + +/** + * Generate a standard unified patch. + * Returns `undefined` when the diff exceeds DIFF_MAX_EDIT_LENGTH or DIFF_TIMEOUT_MS. + */ +export function generateUnifiedPatch( + path: string, + oldContent: string, + newContent: string, + contextLines = 4, +): string | undefined { return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, { context: contextLines, headerOptions: Diff.FILE_HEADERS_ONLY, + ...DIFF_BOUNDS, }); } /** * Generate a display-oriented diff string with line numbers and context. * Returns both the diff string and the first changed line number (in the new file). + * + * When the diff exceeds DIFF_MAX_EDIT_LENGTH or DIFF_TIMEOUT_MS, `diff` is a + * single notice line (so every renderer still has something to show) and + * `omitted` names the reason. */ -export function generateDiffString( - oldContent: string, - newContent: string, - contextLines = 4, -): { diff: string; firstChangedLine: number | undefined } { - const parts = Diff.diffLines(oldContent, newContent); +export function generateDiffString(oldContent: string, newContent: string, contextLines = 4): EditDiffResult { + const startedAt = Date.now(); + const parts = Diff.diffLines(oldContent, newContent, DIFF_BOUNDS); + if (parts === undefined) { + // jsdiff stops on whichever bound trips first; only the clock tells them apart. + const omitted: DiffOmittedReason = Date.now() - startedAt >= DIFF_TIMEOUT_MS ? "timeout" : "too_many_changes"; + return { + diff: + omitted === "timeout" + ? `(diff omitted: computing it exceeded ${DIFF_TIMEOUT_MS} ms)` + : `(diff omitted: more than ${DIFF_MAX_EDIT_LENGTH} added or removed lines)`, + firstChangedLine: undefined, + omitted, + }; + } const output: string[] = []; const oldLines = oldContent.split("\n"); @@ -534,6 +582,8 @@ export function generateDiffString( export interface EditDiffResult { diff: string; firstChangedLine: number | undefined; + /** Set when the diff was not computed; `diff` then holds a one-line notice. */ + omitted?: DiffOmittedReason; } export interface EditDiffError { diff --git a/third_party/pi-mono/packages/coding-agent/src/core/tools/edit.ts b/third_party/pi-mono/packages/coding-agent/src/core/tools/edit.ts index 25aa1e17..7a5a3190 100644 --- a/third_party/pi-mono/packages/coding-agent/src/core/tools/edit.ts +++ b/third_party/pi-mono/packages/coding-agent/src/core/tools/edit.ts @@ -10,6 +10,7 @@ import { applyEditsToNormalizedContent, computeEditsDiff, detectLineEnding, + type DiffOmittedReason, type Edit, type EditDiffError, type EditDiffResult, @@ -59,12 +60,16 @@ type LegacyEditToolInput = EditToolInput & { }; export interface EditToolDetails { - /** Display-oriented diff of the changes made */ + /** Display-oriented diff of the changes made (a one-line notice when `diffOmitted` is set) */ diff: string; - /** Standard unified patch of the changes made */ - patch: string; + /** Standard unified patch of the changes made; absent exactly when `patchOmitted` is set */ + patch?: string; /** Line number of the first change in the new file (for editor navigation) */ firstChangedLine?: number; + /** Set when the diff exceeded DIFF_MAX_EDIT_LENGTH / DIFF_TIMEOUT_MS. The file was still written. */ + diffOmitted?: DiffOmittedReason; + /** Why `patch` is absent. Mirrors `diffOmitted`, and also covers the patch timing out on its own. */ + patchOmitted?: DiffOmittedReason; } /** @@ -347,8 +352,16 @@ export function createEditToolDefinition( await ops.writeFile(absolutePath, finalContent); throwIfAborted(); + // The file is already on disk, so both diffs below are receipts. When the + // display diff was abandoned the patch would abort on the same bounds, so + // skip it rather than paying for a second Myers run that cannot finish. const diffResult = generateDiffString(baseContent, newContent); - const patch = generateUnifiedPatch(path, baseContent, newContent); + const patch = diffResult.omitted ? undefined : generateUnifiedPatch(path, baseContent, newContent); + // jsdiff routes createTwoFilesPatch through the same bounded diffLines, so + // maxEditLength is deterministic across both runs and can never trip for + // the patch alone; only its separately measured wall clock can. + const patchOmitted: DiffOmittedReason | undefined = + diffResult.omitted ?? (patch === undefined ? "timeout" : undefined); return { content: [ { @@ -356,7 +369,13 @@ export function createEditToolDefinition( text: `Successfully replaced ${edits.length} block(s) in ${path}.`, }, ], - details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine }, + details: { + diff: diffResult.diff, + patch, + firstChangedLine: diffResult.firstChangedLine, + diffOmitted: diffResult.omitted, + patchOmitted, + }, }; }); }, From 2ede8e419170056f12e2959ce403f43cdfc49d1f Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:23:27 +0800 Subject: [PATCH 07/15] docs: document BYOK session-affinity compat key (#323) An Anthropic-compatible relay that routes prompt-cache hits per session had no documented way to learn the session identity. The request builder forwards metadata.user_id only when a caller sets metadata explicitly, because it is an abuse-detection and attribution field rather than a cache key, so nothing in the public documentation explained how to preserve session identity instead. Document the supported sendSessionAffinityHeaders compat override for custom providers, and pin the resulting wire-level request: no session identity without the override, x-session-affinity once a provider entry opts in, and no session header when the turn runs without prompt caching. Closes #318 --- docs/examples.md | 20 +++++ .../resolution/local-model-resolver.test.ts | 88 +++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/docs/examples.md b/docs/examples.md index 677cbb75..83e4147d 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -92,6 +92,26 @@ custom_provider: [Live acceptance](verification.md) separately verified MiniMax Token Plan and one configured BYOK provider. This is not a guarantee for every compatible service. +### Prompt-cache session affinity + +A relay that routes prompt-cache hits per session needs to recognize which session a request belongs to. MCode never derives that from the Anthropic `metadata.user_id` field: `metadata` is forwarded only when a caller sets it explicitly, because it is an abuse-detection and attribution field rather than a cache key. The supported mechanism is the `sendSessionAffinityHeaders` compatibility override, declared per model under `compat`: + +```yaml +custom_provider: + my-relay: + options: + apiKey: sk-relay-key + baseURL: https://relay.example.com + models: + MiniMax-M2: + compat: + sendSessionAffinityHeaders: true +``` + +With the override enabled, every request carries the current session id as `x-session-affinity`. An `openai-completions` provider additionally sends the same value as `session_id` and `x-client-request-id`. Key the relay's cache routing on those headers. Requests made with cache retention disabled send no session headers at all; leave the routing fallback in place rather than treating a missing header as a new session. + +The override defaults to `false`, so a provider that ignores these headers is unaffected; it is enabled automatically only for endpoints known to require it, such as Fireworks and the Anthropic route of Cloudflare AI Gateway. `compat` accepts further per-model capability overrides, and each value is applied only when it has the declared type, so a quoted `"false"` is discarded rather than read as true. Restart MCode after editing configuration. + ## 3. Search and image input For a custom BYOK model, declare image input support explicitly when adding the diff --git a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts index b8e4c32f..92bbd389 100644 --- a/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.test.ts @@ -1530,3 +1530,91 @@ describe('LocalModelResolver custom provider compat overrides', () => { ).toBe('developer'); }); }); + +// A relay keyed on session identity only sees it when the provider entry opts in, so these +// cases pin the wire-level request for an Anthropic-compatible custom provider. +describe('LocalModelResolver custom provider session affinity', () => { + const readHeaders = (source: unknown): Record => { + if (!source) return {}; + const entries = + typeof Headers !== 'undefined' && source instanceof Headers + ? [...source.entries()] + : Object.entries(source as Record); + return Object.fromEntries( + entries.flatMap(([key, value]) => + typeof value === 'string' ? [[key.toLowerCase(), value]] : [], + ), + ); + }; + + const requestFor = async ( + compat: LocalModelConfig['compat'], + cacheRetention?: 'none', + ) => { + const modelConfig: LocalModelConfig = compat ? { compat } : {}; + const resolver = new LocalModelResolver({ + byokConfigGetter: () => ({ + custom_provider: { + relay: { + api: 'anthropic-messages', + options: { apiKey: 'relay-key', baseURL: 'https://relay.example' }, + models: { 'MiniMax-M2': modelConfig }, + }, + }, + }), + }); + const resolved = await resolver.resolveModel({ + sessionId: 'session-affinity-wire', + turnId: 'turn-affinity-wire', + agentConfig: { + ...AGENT_CONFIG, + model: modelRefForModel('custom_provider:relay', 'MiniMax-M2', modelConfig), + }, + }); + + let headers: Record = {}; + let payload: unknown; + await streamSimple( + resolved.model, + { + systemPrompt: 'Follow instructions.', + messages: [{ role: 'user', content: 'Hi', timestamp: Date.now() }], + }, + { + apiKey: 'relay-key', + sessionId: 'session-affinity-wire', + ...(cacheRetention ? { cacheRetention } : {}), + onPayload: (params: unknown) => { + payload = params; + }, + // Headers are captured before transport, so the request never leaves the test. + fetch: ((_url: unknown, init?: { headers?: unknown }) => { + headers = readHeaders(init?.headers); + return Promise.reject(new Error('offline')); + }) as unknown as typeof globalThis.fetch, + }, + ).result(); + return { headers, payload: payload as { metadata?: { user_id?: string } } }; + }; + + it('omits session identity when the provider entry declares no compat', async () => { + const { headers, payload } = await requestFor(undefined); + + expect(headers['x-session-affinity']).toBeUndefined(); + expect(payload.metadata).toBeUndefined(); + }); + + it('sends x-session-affinity once the provider entry opts in', async () => { + const { headers, payload } = await requestFor({ sendSessionAffinityHeaders: true }); + + expect(headers['x-session-affinity']).toBe('session-affinity-wire'); + // The session id stays out of `metadata.user_id`, which is an attribution field. + expect(payload.metadata).toBeUndefined(); + }); + + it('withholds the header when the turn runs without prompt caching', async () => { + const { headers } = await requestFor({ sendSessionAffinityHeaders: true }, 'none'); + + expect(headers['x-session-affinity']).toBeUndefined(); + }); +}); From b47eb90fc6c4777798a365d572afe4fe0ecfe210 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:44:14 +0800 Subject: [PATCH 08/15] fix(tui): preserve transcript position while reading history (#324) * docs(tui): design follow-tail preservation * docs(tui): add follow-tail implementation plan * fix(tui): preserve detached transcript position * fix(tui): re-arm follow-tail on explicit navigation * test(tui): cover follow-tail intent boundaries * fix(tui): re-arm follow-tail for queued messages * fix(tui): ignore stale queue admissions --- .../plans/2026-09-23-tui-follow-tail.md | 379 ++++++++++++++++++ .../2026-09-23-tui-follow-tail-design.md | 103 +++++ packages/tui/src/tui/app.ts | 8 +- .../tui/controller/product/command-flow.ts | 50 ++- packages/tui/src/tui/shell/chat-layout.ts | 6 + packages/tui/test/unit/tui-app.test.ts | 97 +++++ .../unit/tui-scrollbar-interaction.test.ts | 49 +++ .../controller/product/command-flow.test.ts | 15 +- release/public-source.json | 2 + 9 files changed, 691 insertions(+), 18 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-23-tui-follow-tail.md create mode 100644 docs/superpowers/specs/2026-09-23-tui-follow-tail-design.md diff --git a/docs/superpowers/plans/2026-09-23-tui-follow-tail.md b/docs/superpowers/plans/2026-09-23-tui-follow-tail.md new file mode 100644 index 00000000..225ed1d5 --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-tui-follow-tail.md @@ -0,0 +1,379 @@ +# TUI Follow-Tail Preservation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Preserve a detached TUI transcript position during streaming while re-arming follow-tail for explicit submission and Session navigation. + +**Architecture:** Keep `ScrollView.isFollowingEnd` as the single follow-tail state. Make `TuiChatLayout.followBottom()` conditional on that state, expose `forceFollowBottom()` for explicit user/Session intent, and wire only submission and Session transitions to the force path. + +**Tech Stack:** TypeScript, Vitest, MiniMax Code TUI, `ScrollView`, `TuiChatLayout`, `TuiAltScreen`. + +## Global Constraints + +- Support Node.js `>=22.19 <23 || >=24.2 <27` and use the repository-pinned `pnpm@9.12.0` workflow. +- Reuse `ScrollView.isFollowingEnd`; do not add a second follow-tail state. +- Do not add a `followTail` configuration key or a "new output below" indicator. +- Guard ordinary assistant, bash, tool, and interaction content updates. +- Force-follow only for user submission, `/new`, and Session activation/switching. +- Preserve the existing `End` / `tui.altScreen.bottom` behavior. +- Use synthetic transcripts and temporary runtime data in tests. +- Keep new documentation and commit messages in English. +- Treat this as a streaming/rendering-path change and add the `perf:full` label to the pull request. + +--- + +## File Structure + +- Modify `packages/tui/src/tui/shell/chat-layout.ts`: own guarded content-follow and explicit force-follow behavior. +- Modify `packages/tui/src/tui/app.ts`: route user submission and Session-transition callbacks to force-follow; leave content callbacks guarded. +- Modify `packages/tui/test/unit/tui-scrollbar-interaction.test.ts`: verify observable detached-position, re-arm, and force-follow behavior through the real layout. +- Modify `packages/tui/test/unit/tui-app.test.ts`: verify application wiring invokes force-follow for submission and Session transitions. +- Modify `release/public-source.json`: regenerate after adding this public implementation-plan document. + +### Task 1: Preserve detached transcript position + +**Files:** +- Modify: `packages/tui/test/unit/tui-scrollbar-interaction.test.ts:22-36,279-304` +- Modify: `packages/tui/src/tui/shell/chat-layout.ts:142-144` + +**Interfaces:** +- Consumes: `ScrollView.isFollowingEnd: boolean` and `ScrollView.scrollToEnd(): void` from `packages/tui/src/tui/engine/components/scroll-view.ts`. +- Produces: `TuiChatLayout.followBottom(): void` for conditional content updates and `TuiChatLayout.forceFollowBottom(): void` for explicit intent. + +- [ ] **Step 1: Make the synthetic transcript appendable** + +Add this method to `ScrollableLines`: + +```ts + appendLine(line: string): void { + this.lines.push(line); + } +``` + +- [ ] **Step 2: Write the failing observable behavior test** + +Add the test beside the existing actual-chat-layout scrollbar test: + +```ts + it("preserves a detached transcript position until follow-tail is explicitly re-armed", async () => { + const terminal = new VirtualTerminal(40, 15); + const empty = { render: () => [], invalidate() {} }; + const content = new ScrollableLines(CONTENT_LINES); + const layout = new TuiChatLayout(terminal, { + surface: () => "conversation", + transcript: content, + welcome: empty, + interaction: { ...empty, isActive: () => false }, + activity: empty, + followUp: empty, + composer: { render: () => ["composer"], invalidate() {} }, + status: empty, + }); + const tui = new TuiAltScreen(terminal); + screens.push(tui); + tui.setLayoutRoot(layout.fullscreenLayoutRoot); + tui.start(); + await terminal.waitForRender(); + + expect(viewportText(terminal)).toContain("line-59"); + + terminal.sendInput(sgrPress(37, 0)); + terminal.sendInput(sgrRelease(37, 0)); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-00"); + + content.appendLine("line-60"); + layout.followBottom(); + tui.requestRender(); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-00"); + expect(viewportText(terminal)).not.toContain("line-60"); + + layout.forceFollowBottom(); + tui.requestRender(); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-60"); + + content.appendLine("line-61"); + layout.followBottom(); + tui.requestRender(); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-61"); + }); +``` + +- [ ] **Step 3: Install the isolated worktree dependencies** + +Run: + +```bash +pnpm install --frozen-lockfile +``` + +Expected: install exits successfully and creates `node_modules` in the worktree without changing the lockfile. + +- [ ] **Step 4: Run the focused test and verify the current failure** + +Run: + +```bash +pnpm exec vitest run --config vitest.oss.config.mjs packages/tui/test/unit/tui-scrollbar-interaction.test.ts +``` + +Expected: FAIL because the current unconditional `followBottom()` moves the detached viewport to `line-60`; the new `forceFollowBottom()` method is not yet present. + +- [ ] **Step 5: Implement the minimal layout behavior** + +Replace the current method with: + +```ts + followBottom(): void { + if (this.fullscreenBodyViewport.isFollowingEnd) { + this.fullscreenBodyViewport.scrollToEnd(); + } + } + + forceFollowBottom(): void { + this.fullscreenBodyViewport.scrollToEnd(); + } +``` + +- [ ] **Step 6: Run the focused test and verify the fix** + +Run: + +```bash +pnpm exec vitest run --config vitest.oss.config.mjs packages/tui/test/unit/tui-scrollbar-interaction.test.ts +``` + +Expected: PASS, including the existing mouse, wheel, scrollbar, and fullscreen-layout tests. + +- [ ] **Step 7: Commit the behavioral fix** + +```bash +git add packages/tui/src/tui/shell/chat-layout.ts packages/tui/test/unit/tui-scrollbar-interaction.test.ts +git -c user.name='hetaoBackend' -c user.email='hetao7@pku.edu.cn' commit -m 'fix(tui): preserve detached transcript position' +``` + +### Task 2: Force-follow explicit submission and Session intent + +**Files:** +- Modify: `packages/tui/test/unit/tui-app.test.ts:15,501-548` (imports and application lifecycle tests) +- Modify: `packages/tui/src/tui/app.ts:128-131,445-450` + +**Interfaces:** +- Consumes: `TuiChatLayout.forceFollowBottom(): void` from Task 1. +- Produces: application wiring where `onUserSubmissionProjected` and `TuiSessionFlow.followBottom` call `forceFollowBottom()`, while `followChatBottom` and interaction refreshes continue calling `followBottom()`. + +- [ ] **Step 1: Add the failing application-wiring test** + +Import the layout class: + +```ts +import { TuiChatLayout } from "../../src/tui/shell/chat-layout.js"; +``` + +Add an application lifecycle test: + +```ts + it("force-follows user submissions and Session transitions", async () => { + const forceFollowBottom = vi.spyOn(TuiChatLayout.prototype, "forceFollowBottom"); + const app = createTuiApp({ + runtime: createRuntime(), + terminal: new FakeTerminal(), + version: "test", + workspaceDir: "/workspace", + }); + app.start(); + + try { + await app.ready; + await app.openSession("session-1"); + + forceFollowBottom.mockClear(); + await app.submit("Continue with the next step"); + await vi.waitFor(() => expect(forceFollowBottom).toHaveBeenCalled()); + + forceFollowBottom.mockClear(); + await app.openSession("session-2"); + expect(forceFollowBottom).toHaveBeenCalled(); + } finally { + await app.stop(); + forceFollowBottom.mockRestore(); + } + }); +``` + +- [ ] **Step 2: Run the app test and verify the wiring failure** + +Run: + +```bash +pnpm exec vitest run --config vitest.oss.config.mjs packages/tui/test/unit/tui-app.test.ts +``` + +Expected: FAIL because the application still calls `layout.followBottom()` for both explicit-intent paths. + +- [ ] **Step 3: Route user submission to force-follow** + +In `onUserSubmissionProjected`, replace: + +```ts + followChatBottom(); +``` + +with: + +```ts + layout.forceFollowBottom(); +``` + +Leave the bash-flow callback unchanged so ordinary tool output remains guarded. + +- [ ] **Step 4: Route Session transitions to force-follow** + +In the `TuiSessionFlow` options, replace: + +```ts + followBottom: () => layout.followBottom(), +``` + +with: + +```ts + followBottom: () => layout.forceFollowBottom(), +``` + +This callback is used by both `startNewSession()` and `activateSessionById()`. + +- [ ] **Step 5: Run focused app and viewport tests** + +Run: + +```bash +pnpm exec vitest run --config vitest.oss.config.mjs packages/tui/test/unit/tui-app.test.ts packages/tui/test/unit/tui-scrollbar-interaction.test.ts +``` + +Expected: PASS with no snapshot, lifecycle, submission, or scroll regression. + +- [ ] **Step 6: Run type checking and source checks** + +Run: + +```bash +pnpm typecheck +pnpm check:source +git diff --check +``` + +Expected: all commands exit successfully; source inventory reports the committed spec and plan; no whitespace errors. + +- [ ] **Step 7: Commit the application wiring** + +```bash +git add packages/tui/src/tui/app.ts packages/tui/test/unit/tui-app.test.ts +git -c user.name='hetaoBackend' -c user.email='hetao7@pku.edu.cn' commit -m 'fix(tui): re-arm follow-tail on explicit navigation' +``` + +### Task 3: Verify and prepare the pull request + +**Files:** +- Verify: all changed files +- Verify: `release/public-source.json` + +**Interfaces:** +- Consumes: the complete implementation from Tasks 1 and 2. +- Produces: a verified `fix/tui-follow-tail` branch and a pull request that fixes issue #320. + +- [ ] **Step 1: Verify the committed public source inventory** + +Run: + +```bash +pnpm check:source +git status --short +``` + +Expected: the approved spec and implementation-plan paths are recorded; no untracked or modified deliverable file remains before full verification. + +- [ ] **Step 2: Run the full applicable verification profile** + +Run: + +```bash +pnpm verify +``` + +Expected: PASS for every macOS-applicable gate. Report any intentional platform skips and any live-service or Windows boundary that was not exercised. + +- [ ] **Step 3: Verify commit identity and branch state** + +Run: + +```bash +git log --format=fuller origin/main..HEAD +git status --short --branch +``` + +Expected: every new commit uses `hetaoBackend ` as both author and committer; the tracked working tree is clean. + +- [ ] **Step 4: Obtain confirmation immediately before publishing** + +State that the next actions push `fix/tui-follow-tail` to `origin` and create a public GitHub pull request containing the issue link, user-visible behavior, tests actually run, untested platform boundaries, and the `perf:full` label. + +- [ ] **Step 5: Push the feature branch** + +After explicit confirmation: + +```bash +git push -u origin fix/tui-follow-tail +``` + +Expected: the remote branch is created without modifying `main`. + +- [ ] **Step 6: Create the pull request** + +After the push succeeds: + +```bash +PR_BODY=$(cat <<'EOF' +## Summary + +- preserve a user-scrolled transcript position while assistant and tool output continues +- keep automatic follow-tail active when the viewport is already at the bottom +- force-follow after an explicit user submission or Session transition +- reuse the existing `ScrollView` follow-end state without adding configuration or UI + +## Validation + +- `pnpm exec vitest run --config vitest.oss.config.mjs packages/tui/test/unit/tui-app.test.ts packages/tui/test/unit/tui-scrollbar-interaction.test.ts` +- `pnpm typecheck` +- `pnpm check:source` +- `pnpm verify` + +## Boundaries + +- report the exact `pnpm verify` result, including intentional skips +- report that live Windows acceptance was not run unless it was actually run + +Closes #320 +EOF +) +gh pr create \ + --base main \ + --head fix/tui-follow-tail \ + --title 'fix(tui): preserve transcript position while reading history' \ + --body "$PR_BODY" \ + --label 'perf:full' +``` + +- [ ] **Step 7: Verify the created pull request** + +Run: + +```bash +gh pr view --json number,title,url,baseRefName,headRefName,labels,state +gh pr checks +``` + +Expected: the pull request targets `main`, comes from `fix/tui-follow-tail`, has the `perf:full` label, and its checks are queued or passing without an unexpected skip. diff --git a/docs/superpowers/specs/2026-09-23-tui-follow-tail-design.md b/docs/superpowers/specs/2026-09-23-tui-follow-tail-design.md new file mode 100644 index 00000000..d6b99674 --- /dev/null +++ b/docs/superpowers/specs/2026-09-23-tui-follow-tail-design.md @@ -0,0 +1,103 @@ +# TUI Follow-Tail Preservation Design + +Date: 2026-09-23 +Issue: https://github.com/MiniMax-AI/minimax-code/issues/320 + +## Summary + +The chat TUI currently scrolls to the transcript bottom whenever assistant output, tool output, or an interaction update changes. This overrides a user's deliberate attempt to read earlier output. The change makes automatic scrolling conditional on the existing `ScrollView` follow-end state, while preserving explicit bottom navigation and intentionally re-arming follow-tail after a submission or Session transition. + +## Problem + +`TuiChatLayout.followBottom()` currently calls `ScrollView.scrollToEnd()` unconditionally. `ScrollView` already tracks whether it is following the end, but the chat layout bypasses that state for every update. The result is that a user who scrolls upward is pulled back to the newest output as soon as the next token or tool result arrives. + +## Goals + +- Preserve the user's reading position while output continues to stream or tool output changes. +- Continue following new output while the viewport is at the bottom. +- Re-arm follow-tail when the user explicitly navigates to the bottom. +- Re-arm follow-tail after the user submits a message. +- Re-arm follow-tail when creating or activating a Session. +- Reuse the existing `ScrollView.isFollowingEnd` state without introducing a second source of truth. + +## Non-goals + +- Do not add a `followTail` configuration setting in this change. +- Do not add a "new output below" indicator or overlay. +- Do not change `ScrollView` scrollbar behavior, search behavior, or general keyboard bindings. +- Do not change prompt history or queued-message restoration behavior. + +## Design + +### Conditional content follow + +`TuiChatLayout.followBottom()` will inspect the primary transcript viewport's existing `isFollowingEnd` state. It will call `scrollToEnd()` only when the viewport is still following the end. This keeps the method safe for repeated content-update callbacks without duplicating user-scroll state in `TuiChatLayout`. + +The ordinary guarded path covers: + +- assistant streaming and transcript updates; +- bash and tool output; +- interaction or composition refreshes that can change visible content; +- any future content-update caller that uses the default method. + +### Explicit force-follow operations + +`TuiChatLayout` exposes an explicit `forceFollowBottom()` method. It calls `scrollToEnd()` regardless of the prior follow-end state. + +Force-follow applies only to user or Session intent: + +1. the user submits a new message; +2. `/new` resets the current Session; +3. the user activates or switches Sessions. + +The application will pass the force-follow callback only to these explicit intent paths. Session-flow callbacks used for reset and activation will always force the new Session's transcript to the bottom. + +### Re-arming through existing navigation + +Scrolling back to the bottom, including the existing `tui.altScreen.bottom` / `End` path, continues to call `ScrollView.scrollToEnd()`. That operation already sets `isFollowingEnd` to true, so no separate toggle state or synchronization callback is required. + +When the content is shorter than the viewport, `ScrollView` continues to consider itself at the end, preserving the current compact-transcript behavior. + +## Data flow + +1. The user scrolls upward; `ScrollView.isFollowingEnd` becomes false. +2. Assistant, bash, tool, or interaction content changes. +3. The existing update callback invokes the guarded `followBottom()` path. +4. `TuiChatLayout` sees `isFollowingEnd === false` and leaves `scrollTop` unchanged. +5. The next layout pass updates the content height while preserving the current position. +6. The user presses `End` or navigates to the bottom; `ScrollView.scrollToEnd()` sets `isFollowingEnd` to true. +7. Subsequent content changes once again follow the end. + +Submission and Session-transition flows instead invoke the force-follow callback before rendering the new state, so the viewport is always at the bottom for the new user or Session context. + +## Edge cases + +- A user who scrolls up during streaming remains detached until returning to the bottom. +- Content growth cannot re-arm follow-tail by itself. +- Content shrink continues to use the existing `ScrollView` clamping behavior. +- Empty or short transcripts remain logically at the end. +- Multiple updates before a render remain safe because the follow decision reads the current viewport state. +- A Session transition is not treated as ordinary streaming; it always shows the new Session's bottom. + +## Testing + +Focused tests will cover the `TuiChatLayout` and existing scroll/viewport regression surfaces: + +- while following the end, content growth continues to move to the newest output; +- after scrolling upward, content growth preserves `scrollTop`; +- after returning to the bottom, later content growth follows again; +- the force-follow path moves to the end after the user has scrolled upward; +- force-follow is used for user submission, Session reset, and Session activation call sites; +- ordinary bash, tool, and interaction update call sites use the guarded path; +- existing `End` / `tui.altScreen.bottom` behavior remains unchanged. + +Tests will use synthetic transcripts and temporary runtime data only. + +## Acceptance criteria + +- Scrolling upward to read earlier output is not overridden by new assistant or tool output. +- A viewport already at the bottom continues to follow streaming output. +- `End` or the existing bottom-navigation binding re-arms follow-tail. +- Submitting a message returns to the bottom and resumes following. +- Creating, resetting, or switching Sessions shows the bottom of the selected Session. +- No new configuration key, status indicator, or duplicate follow state is introduced. diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index c70b0cee..c8812f83 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -127,7 +127,6 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { onTodoChange: (items) => tasks.setItems(items), onUserSubmissionProjected: () => { codexHandoffFlow?.dismiss(); - followChatBottom(); if (started && !stopped) tui.requestImmediateRender(); }, onSessionLifecycle: (sessionId) => { @@ -446,7 +445,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { updateChrome(controller.snapshot()); tui.requestRender(); }, - followBottom: () => layout.followBottom(), + followBottom: () => layout.forceFollowBottom(), requestWelcomeRebuild: () => tui.requestImmediateRender(), switchComposerDraft: (sessionKey) => draftLifecycle?.switchSession(sessionKey) ?? Promise.resolve(), @@ -541,7 +540,10 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { tui.requestRender(); }, userMessageCount: () => transcript.snapshot().filter((cell) => cell.kind === 'user').length, - onMessageAdmitted: (input) => businessEventTracker?.trackChatSend(input), + onMessageAdmitted: (input) => { + businessEventTracker?.trackChatSend(input); + layout.forceFollowBottom(); + }, }); activeRunFlow.setCommandCatalog(commandFlow.catalog); runtimeEventFlow = new TuiRuntimeEventFlow({ diff --git a/packages/tui/src/tui/controller/product/command-flow.ts b/packages/tui/src/tui/controller/product/command-flow.ts index 695d9bcf..b78157c0 100644 --- a/packages/tui/src/tui/controller/product/command-flow.ts +++ b/packages/tui/src/tui/controller/product/command-flow.ts @@ -579,6 +579,7 @@ export class TuiCommandFlow { submission.attachments.map((attachment) => attachment.fileName), ); } + let queuedItemId: string | undefined; try { await this.options.controller.requireLoginForAgentAction(); const draft = { @@ -590,9 +591,19 @@ export class TuiCommandFlow { submission.transportContent || submission.reviewRequest ) { - await this.options.queueFlow.enqueue(command, draft, submission, activeAdmissionId); + queuedItemId = await this.options.queueFlow.enqueue( + command, + draft, + submission, + activeAdmissionId, + ); } else { - await this.options.queueFlow.enqueue(command, draft, undefined, activeAdmissionId); + queuedItemId = await this.options.queueFlow.enqueue( + command, + draft, + undefined, + activeAdmissionId, + ); } } catch (error) { this.options.queueFlow.cancelAdmission(activeAdmissionId); @@ -602,10 +613,12 @@ export class TuiCommandFlow { disposition: await this.restorePreparedSubmission(seed, submission, input), }; } - this.options.onMessageAdmitted?.({ - attachmentCount: submission.attachments.length, - isFirstMessage, - }); + if (queuedItemId) { + this.options.onMessageAdmitted?.({ + attachmentCount: submission.attachments.length, + isFirstMessage, + }); + } return { disposition: 'consumed' }; } this.options.setHint('Wait for the current response or press Esc to interrupt'); @@ -746,19 +759,32 @@ export class TuiCommandFlow { command, submission.attachments.map((attachment) => attachment.fileName), ); + let fallbackItemId: string | undefined; try { const draft = { attachments: submission.attachments, }; if (prepared.atomic || submission.clientIntent || submission.reviewRequest) { - await this.options.queueFlow.enqueue(command, draft, submission, fallbackAdmissionId); + fallbackItemId = await this.options.queueFlow.enqueue( + command, + draft, + submission, + fallbackAdmissionId, + ); } else { - await this.options.queueFlow.enqueue(command, draft, undefined, fallbackAdmissionId); + fallbackItemId = await this.options.queueFlow.enqueue( + command, + draft, + undefined, + fallbackAdmissionId, + ); + } + if (fallbackItemId) { + this.options.onMessageAdmitted?.({ + attachmentCount: submission.attachments.length, + isFirstMessage, + }); } - this.options.onMessageAdmitted?.({ - attachmentCount: submission.attachments.length, - isFirstMessage, - }); return 'consumed'; } catch (error) { this.options.queueFlow.cancelAdmission(fallbackAdmissionId); diff --git a/packages/tui/src/tui/shell/chat-layout.ts b/packages/tui/src/tui/shell/chat-layout.ts index e3e2a62e..79a74870 100644 --- a/packages/tui/src/tui/shell/chat-layout.ts +++ b/packages/tui/src/tui/shell/chat-layout.ts @@ -140,6 +140,12 @@ export class TuiChatLayout implements Component { } followBottom(): void { + if (this.fullscreenBodyViewport.isFollowingEnd) { + this.fullscreenBodyViewport.scrollToEnd(); + } + } + + forceFollowBottom(): void { this.fullscreenBodyViewport.scrollToEnd(); } diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 0dd3b9cd..147f85d9 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -13,6 +13,7 @@ import { resolveTuiStartupEnvironmentOption } from "../../src/cli/environment.js import { runTuiCli } from "../../src/cli/main.js"; import { launchTui } from "../../src/tui/launcher.js"; import { createTuiApp } from "../../src/tui/app.js"; +import { TuiChatLayout } from "../../src/tui/shell/chat-layout.js"; import type { TuiModel, TuiQuestionnaireRequest, @@ -517,6 +518,91 @@ function createRuntime(): TuiRuntime { } describe("createTuiApp", () => { + it("force-follows user submissions, /new, and Session transitions", async () => { + const forceFollowBottom = vi.spyOn(TuiChatLayout.prototype, "forceFollowBottom"); + const app = createTuiApp({ + runtime: createRuntime(), + terminal: new FakeTerminal(), + version: "test", + workspaceDir: "/workspace", + }); + app.start(); + + try { + await app.ready; + await app.openSession("session-1"); + + forceFollowBottom.mockClear(); + await app.submit("Continue with the next step"); + await vi.waitFor(() => expect(forceFollowBottom).toHaveBeenCalled()); + + forceFollowBottom.mockClear(); + await app.submit("/new"); + expect(forceFollowBottom).toHaveBeenCalled(); + + forceFollowBottom.mockClear(); + await app.openSession("session-2"); + expect(forceFollowBottom).toHaveBeenCalled(); + } finally { + await app.stop(); + forceFollowBottom.mockRestore(); + } + }); + + it("re-arms follow-tail when a follow-up is accepted into the queue", async () => { + let releaseRun: (() => void) | undefined; + const runGate = new Promise((resolve) => { + releaseRun = resolve; + }); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* pendingRun() { + yield { + type: "delta", + turnId: "turn-pending", + role: "assistant", + content: "Working", + }; + await runGate; + yield { type: "done", turnId: "turn-pending" }; + }, + ); + const forceFollowBottom = vi.spyOn(TuiChatLayout.prototype, "forceFollowBottom"); + const app = createTuiApp({ + runtime, + terminal: new FakeTerminal(), + version: "test", + workspaceDir: "/workspace", + productFeatures: { queue: true }, + }); + app.start(); + + try { + await app.ready; + const firstSubmission = app.submit("Start the live run"); + await vi.waitFor(() => + expect(app.controller.snapshot().status).toBe("running"), + ); + + forceFollowBottom.mockClear(); + await app.submit("Queue the follow-up"); + + expect(runtime.enqueueMessage).toHaveBeenCalledWith( + "session-1", + "Queue the follow-up", + expect.objectContaining({ attachments: [] }), + ); + expect(forceFollowBottom).toHaveBeenCalled(); + + releaseRun?.(); + await firstSubmission; + } finally { + releaseRun?.(); + await app.stop(); + forceFollowBottom.mockRestore(); + } + }); + it.each(["regular", "fullscreen"] as const)( "configures /statusline from terminal input and reopens the saved selection in %s mode", async (tuiMode) => { @@ -689,6 +775,11 @@ describe("createTuiApp", () => { it("runs an editor ! command locally and carries its result into the next model message", async () => { const directory = await mkdtemp(join(tmpdir(), "tui-bash-app-")); const runtime = createRuntime(); + const followBottom = vi.spyOn(TuiChatLayout.prototype, "followBottom"); + const forceFollowBottom = vi.spyOn( + TuiChatLayout.prototype, + "forceFollowBottom", + ); const app = createTuiApp({ runtime, terminal: new FakeTerminal(), @@ -697,6 +788,8 @@ describe("createTuiApp", () => { }); try { await app.ready; + followBottom.mockClear(); + forceFollowBottom.mockClear(); app.editor.setText( process.platform === "win32" ? "!Write-Output shell-result" @@ -716,6 +809,8 @@ describe("createTuiApp", () => { ).toBe(true), { timeout: 5000 }, ); + expect(followBottom).toHaveBeenCalled(); + expect(forceFollowBottom).not.toHaveBeenCalled(); expect(runtime.sendMessage).not.toHaveBeenCalled(); expect( app.transcript @@ -734,6 +829,8 @@ describe("createTuiApp", () => { ); } finally { await app.stop(); + followBottom.mockRestore(); + forceFollowBottom.mockRestore(); await rm(directory, { recursive: true, force: true }); } }); diff --git a/packages/tui/test/unit/tui-scrollbar-interaction.test.ts b/packages/tui/test/unit/tui-scrollbar-interaction.test.ts index 10d95b16..09342865 100644 --- a/packages/tui/test/unit/tui-scrollbar-interaction.test.ts +++ b/packages/tui/test/unit/tui-scrollbar-interaction.test.ts @@ -32,6 +32,10 @@ class ScrollableLines implements Component { return [...this.lines]; } + appendLine(line: string): void { + this.lines.push(line); + } + invalidate(): void {} } @@ -302,4 +306,49 @@ describe("Scrollbar interaction boundaries", () => { expect(viewportText(terminal)).not.toContain("line-59"); expect(viewportText(terminal)).toContain("composer"); }); + + it("preserves a detached transcript position until follow-tail is explicitly re-armed", async () => { + const terminal = new VirtualTerminal(40, 15); + const empty = { render: () => [], invalidate() {} }; + const content = new ScrollableLines(CONTENT_LINES); + const layout = new TuiChatLayout(terminal, { + surface: () => "conversation", + transcript: content, + welcome: empty, + interaction: { ...empty, isActive: () => false }, + activity: empty, + followUp: empty, + composer: { render: () => ["composer"], invalidate() {} }, + status: empty, + }); + const tui = new TuiAltScreen(terminal); + screens.push(tui); + tui.setLayoutRoot(layout.fullscreenLayoutRoot); + tui.start(); + await terminal.waitForRender(); + + expect(viewportText(terminal)).toContain("line-59"); + + terminal.sendInput(sgrPress(37, 0)); + terminal.sendInput(sgrRelease(37, 0)); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-00"); + + content.appendLine("line-60"); + layout.followBottom(); + tui.requestRender(); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-00"); + expect(viewportText(terminal)).not.toContain("line-60"); + + terminal.sendInput("\x1b[F"); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-60"); + + content.appendLine("line-61"); + layout.followBottom(); + tui.requestRender(); + await terminal.waitForRender(); + expect(viewportText(terminal)).toContain("line-61"); + }); }); diff --git a/packages/tui/test/unit/tui/controller/product/command-flow.test.ts b/packages/tui/test/unit/tui/controller/product/command-flow.test.ts index d101bf8e..d77c98e5 100644 --- a/packages/tui/test/unit/tui/controller/product/command-flow.test.ts +++ b/packages/tui/test/unit/tui/controller/product/command-flow.test.ts @@ -1058,13 +1058,15 @@ describe("TuiCommandFlow", () => { }); it.each([ - ["accepts the handoff", false], - ["rejects the handoff", true], + ["accepts the handoff", false, "queue-item-1"], + ["rejects the handoff", true, undefined], + ["drops a stale Session result", false, undefined], ] as const)( "preserves direct-admission semantics when the Runtime queue %s", - async (_scenario, enqueueFails) => { + async (_scenario, enqueueFails, queuedItemId) => { const draft = { attachments: [] }; const append = vi.fn(); + const onMessageAdmitted = vi.fn(); const controller = { snapshot: vi.fn(() => ({ status: "idle" as const, @@ -1086,6 +1088,7 @@ describe("TuiCommandFlow", () => { cancelAdmission: vi.fn(), enqueue: vi.fn(async () => { if (enqueueFails) throw new Error("queue unavailable"); + return queuedItemId; }), }; const flow = new TuiCommandFlow({ @@ -1116,6 +1119,7 @@ describe("TuiCommandFlow", () => { append, setHint: vi.fn(), onChanged: vi.fn(), + onMessageAdmitted, }); await expect(flow.submit("follow-up")).resolves.toBe( @@ -1134,6 +1138,11 @@ describe("TuiCommandFlow", () => { [], ); expect(composerDraft.completeSubmission).not.toHaveBeenCalled(); + if (queuedItemId) { + expect(onMessageAdmitted).toHaveBeenCalledOnce(); + } else { + expect(onMessageAdmitted).not.toHaveBeenCalled(); + } if (enqueueFails) { expect(queueFlow.cancelAdmission).toHaveBeenCalledWith( expect.stringMatching(/^\d+-\d+$/u), diff --git a/release/public-source.json b/release/public-source.json index b5fd4d50..63804a38 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -59,6 +59,8 @@ "docs/release-audit.md", "docs/releasing.md", "docs/source-sync.md", + "docs/superpowers/plans/2026-09-23-tui-follow-tail.md", + "docs/superpowers/specs/2026-09-23-tui-follow-tail-design.md", "docs/telemetry.md", "docs/tui-capabilities.md", "docs/verification.md", From 25518db09cca248d925d3ea1fbbeba3b4fbecbc1 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:54:16 +0800 Subject: [PATCH 09/15] fix(tui): restore early-aborted prompt to composer (#325) * fix(tui): restore early-aborted prompt to composer * fix(tui): preserve restored submission edits and metadata --- README.md | 2 +- README_ZH.md | 2 +- .../tui/src/application/run-coordinator.ts | 2 +- packages/tui/src/tui/app.ts | 197 ++++- .../tui/controller/chat-controller-types.ts | 6 + .../tui/src/tui/controller/chat-controller.ts | 3 + .../tui/controller/product/command-flow.ts | 112 ++- .../src/tui/controller/run/abort-live-turn.ts | 90 ++- .../run/turn-submission-retainer.ts | 43 + .../tui/features/composer/draft-lifecycle.ts | 59 +- .../tui/features/composer/draft-recovery.ts | 17 +- packages/tui/src/tui/transcript/view.ts | 13 +- packages/tui/src/tui/widgets/editor/editor.ts | 8 +- packages/tui/src/types/tui-app.ts | 3 + packages/tui/test/unit/tui-app.test.ts | 739 +++++++++++++++++- .../run/turn-submission-retainer.test.ts | 47 ++ release/public-source.json | 2 + test/vitest-suites.json | 1 + 18 files changed, 1291 insertions(+), 55 deletions(-) create mode 100644 packages/tui/src/tui/controller/run/turn-submission-retainer.ts create mode 100644 packages/tui/test/unit/tui/controller/run/turn-submission-retainer.test.ts diff --git a/README.md b/README.md index df7353d5..195b9acd 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Inside the TUI, use `/sessions` to find previous sessions and `/help` to see all | Reference a workspace file or directory | `@` | | Toggle Plan Mode | `Shift+Tab` | | Switch permission modes | `Alt+M` | -| Close a panel or interrupt a running task | `Esc` | +| Close a panel or interrupt a running task; interrupting before the model replies returns the message to the composer | `Esc` | ## Uninstall diff --git a/README_ZH.md b/README_ZH.md index 5800a879..ab35a080 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -145,7 +145,7 @@ mcode --session | 引用工作区文件或目录 | `@` | | 切换 Plan Mode | `Shift+Tab` | | 切换权限模式 | `Alt+M` | -| 关闭面板或中断正在运行的任务 | `Esc` | +| 关闭面板或中断正在运行的任务;在模型回复之前中断会把消息放回输入框 | `Esc` | ## 卸载 diff --git a/packages/tui/src/application/run-coordinator.ts b/packages/tui/src/application/run-coordinator.ts index 7e643f20..bfdcda4e 100644 --- a/packages/tui/src/application/run-coordinator.ts +++ b/packages/tui/src/application/run-coordinator.ts @@ -87,7 +87,7 @@ export interface TuiRunCoordinatorOptions { nowMs?: () => number; } -const DEFAULT_CANCELLATION_SETTLEMENT_TIMEOUT_MS = 1_000; +export const DEFAULT_CANCELLATION_SETTLEMENT_TIMEOUT_MS = 1_000; export class TuiRunCoordinator { private activeRun?: ActiveRun; diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index c8812f83..914d96e9 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -17,6 +17,10 @@ import { TranscriptView } from './transcript/view.js'; import { TuiChatController, type TuiChatSnapshot } from './controller/chat-controller.js'; import { TuiChromeFlow } from './controller/product/chrome-flow.js'; import type { TuiDraftLifecycle } from './features/composer/draft-lifecycle.js'; +import { retainAvailableAttachmentPlaceholders } from './features/composer/draft-recovery.js'; +import type { TuiSubmissionSnapshot } from './features/composer/submission.js'; +import { statSync } from 'node:fs'; +import type { TuiTransportAttachment } from '../types/invocation.js'; import { TuiRunProjection } from './state/run-projection.js'; import { createTuiState, TuiEffectRunner, TuiStateStore } from './state/index.js'; import { FeedbackFlow as Feedback } from './controller/product/feedback-flow.js'; @@ -358,6 +362,9 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { keybindings: options.keybindings, isStopped: () => stopped, }); + // Turns whose prompt was already returned to the composer by the abort + // restore; keys are runtime turn ids, unique for the app's lifetime. + const restoredAbortTurnIds = new Set(); abortLiveTurn = createTuiAbortLiveTurn({ controller, runProjection, @@ -371,6 +378,125 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { if (!stopped) tui.requestRender(); }, append: appendLocalCell, + onLiveTurnAborted: ({ turnId }) => { + // Return the aborted prompt to the composer only when the turn produced + // no user-visible output: any assistant text, tool call, or steer + // message means the user was interacting with a live response, not + // regretting a fresh submission. Thinking is internal model process and + // does not disqualify: it is the most common regret moment, and nothing + // of value is discarded by resending. + if (sessionFlow.isSideModeActive()) return; + const cells = transcript.snapshot().filter((cell) => cell.turnId === turnId); + const userCell = transcript.get(`user:${turnId}`); + if (!userCell) return; // runtime-owned or retry-continuation turns + // A second Esc in the settle window can reach the runtime-owned branch + // with the same turnId; track restored turns so a repeat concatenates + // nothing. (Cell status cannot key this: abort-time markTurn also + // cancels still-pending user cells before the first restore.) + if (restoredAbortTurnIds.has(turnId)) return; + const hasIrreversibleActivity = cells.some((cell) => { + if (cell.id === `user:${turnId}`) return false; + if (cell.kind === 'thinking' || cell.kind === 'turn-duration') return false; + // markTurn('cancelled') synthesizes an empty assistant placeholder for + // turns with no assistant output before this callback can run. + if ((cell.kind === 'assistant' || cell.kind === 'assistant-preamble') && !cell.content) { + return false; + } + return true; + }); + if (hasIrreversibleActivity) return; + // Preferred path: replay the ORIGINAL submission retained for this turn + // (complete attachment set, transport content, client intent, editor + // state) instead of reconstructing a lossy copy from the display cell. + const retained = commandFlow.getRetainedSubmission(turnId); + const retainedFiltered = retained + ? filterRetainedSubmissionForRestore(retained) + : undefined; + if (retained && retainedFiltered) { + commandFlow.restoreSubmission(retainedFiltered); + commandFlow.markAbortSubmissionRestored(retained.submissionId); + commandFlow.dropRetainedSubmission(turnId); + restoredAbortTurnIds.add(turnId); + markAbortedUserRowCancelled(transcript, turnId); + draftLifecycle?.recordRestoredSubmission(retainedFiltered); + chromeFlow?.setHint('Stopped · message restored to the Composer.'); + updateChrome(controller.snapshot()); + tui.requestRender(); + return; + } + const text = userCell.content; + const draftAttachments = (userCell.attachments ?? []).flatMap((attachment) => + attachment.filePath && typeof attachment.sizeBytes === 'number' + ? [ + { + type: attachment.type, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + filePath: attachment.filePath, + }, + ] + : [], + ); + // Fallback path: reconstruct from the display cell. The transport list + // must carry BOTH halves of a mixed row (asset-backed AND file-backed + // entries): submission-time selection is `transportAttachments ?? + // attachments`, so a partial transport list would silently drop the + // file-backed attachments on resubmit. + const transportAttachments: TuiTransportAttachment[] = (userCell.attachments ?? []).flatMap( + (attachment): TuiTransportAttachment[] => + attachment.assetId + ? [ + { + type: attachment.type, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + ...(attachment.filePath ? { filePath: attachment.filePath } : {}), + assetId: attachment.assetId, + }, + ] + : attachment.filePath && typeof attachment.sizeBytes === 'number' + ? [ + { + type: attachment.type, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + filePath: attachment.filePath, + }, + ] + : [], + ); + if (!text.trim() && draftAttachments.length === 0 && transportAttachments.length === 0) { + return; + } + const restoredSubmission: TuiSubmissionSnapshot = { + submissionId: `abort-restore:${turnId}`, + sessionId: controller.snapshot().session?.sessionId, + content: text, + attachments: draftAttachments, + ...(transportAttachments.length > 0 ? { transportAttachments } : {}), + createdAtMs: Date.now(), + editor: { + schemaVersion: 1, + text, + cursor: text.length, + pastes: [], + pasteCounter: 0, + }, + }; + commandFlow.restoreSubmission(restoredSubmission); + restoredAbortTurnIds.add(turnId); + markAbortedUserRowCancelled(transcript, turnId); + // The restored text already carries the submission's content; a pending + // retry for it would merge the same text again on the next hydrate. + draftLifecycle?.recordRestoredSubmission(restoredSubmission); + chromeFlow?.setHint('Stopped · message restored to the Composer.'); + // The funnel's updateChrome tail ran during the settle wait, before this + // hint was set; push the chrome once more so the hint is not left + // unpresented. + updateChrome(controller.snapshot()); + tui.requestRender(); + }, }); const planModeFlow = new TuiPlanModeFlow({ currentSession: () => controller.snapshot().session, @@ -447,8 +573,11 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { }, followBottom: () => layout.forceFollowBottom(), requestWelcomeRebuild: () => tui.requestImmediateRender(), - switchComposerDraft: (sessionKey) => - draftLifecycle?.switchSession(sessionKey) ?? Promise.resolve(), + switchComposerDraft: (sessionKey) => { + // Retained turn submissions belong to the previous session's turns. + commandFlow.clearRetainedSubmissions(); + return draftLifecycle?.switchSession(sessionKey) ?? Promise.resolve(); + }, detachForegroundObserver: () => runtimeEventFlow?.detachForegroundObserver(), adoptForegroundRun: () => sessionLifecycle.adoptForegroundRun( @@ -730,8 +859,8 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { activity.dispose(); widgets.imagePreview.dispose(); renderer.prepareTranscriptExit(); - disposeComponents(surfaceHost, editor, transcriptView, status, goal); draftStopPromise ??= draftLifecycle?.stop() ?? Promise.resolve(); + disposeComponents(surfaceHost, editor, transcriptView, status, goal); if (stopOptions.abortActiveTurn !== false) { const snapshot = controller.snapshot(); if (snapshot.activeTurnId) void controller.abort().catch(() => undefined); @@ -808,8 +937,70 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { updateChromeAndRequestRender(); }, submit: (input) => commandFlow.submit(input).then(() => undefined), + /** Exposed for integration tests that submit with an explicit seed. */ + commandFlow, abortTurn: abortLiveTurn, leaveUi, stop, }; } + +function fileExists(filePath: string): boolean { + try { + return statSync(filePath).isFile(); + } catch { + return false; + } +} + +/** + * Filters a retained submission snapshot for abort-time restore: drops + * attachments whose backing file disappeared (asset-backed entries stay), + * and prunes editor placeholders accordingly. Returns undefined when nothing + * restorable remains. + */ +function filterRetainedSubmissionForRestore( + snapshot: TuiSubmissionSnapshot, +): TuiSubmissionSnapshot | undefined { + const attachments = snapshot.attachments.filter((attachment) => + fileExists(attachment.filePath), + ); + const transportAttachments = (snapshot.transportAttachments ?? []).filter((attachment) => + attachment.assetId ? true : attachment.filePath ? fileExists(attachment.filePath) : false, + ); + if (!snapshot.content.trim() && attachments.length === 0 && transportAttachments.length === 0) { + return undefined; + } + return { + ...snapshot, + submissionId: `abort-restore:${snapshot.submissionId}`, + attachments, + ...(snapshot.transportAttachments ? { transportAttachments } : {}), + editor: retainAvailableAttachmentPlaceholders( + // Some submission paths (automation results, test seeds) submit with a + // payload but an empty editor draft; restoring that verbatim would + // leave the composer blank, so fall back to the visible content. + snapshot.editor.text.trim() + ? snapshot.editor + : { ...snapshot.editor, text: snapshot.content, cursor: snapshot.content.length }, + attachments, + ), + }; +} + +/** Marks the aborted turn's user row cancelled so it stays visible in history. */ +function markAbortedUserRowCancelled(transcript: TranscriptStore, turnId: string): void { + const userCell = transcript.get(`user:${turnId}`); + if (!userCell) return; + // markTurn does not rewrite the user cell once the runtime echo flipped it + // to 'succeeded', so upsert explicitly. createdAtMs is force-preserved by + // the store's merge for existing cells and is intentionally omitted. + transcript.upsert({ + id: `user:${turnId}`, + kind: 'user', + status: 'cancelled', + ...(userCell.content ? { content: userCell.content } : {}), + updatedAtMs: Date.now(), + ...(userCell.attachments ? { attachments: userCell.attachments } : {}), + }); +} diff --git a/packages/tui/src/tui/controller/chat-controller-types.ts b/packages/tui/src/tui/controller/chat-controller-types.ts index 992399b4..02c9d4a9 100644 --- a/packages/tui/src/tui/controller/chat-controller-types.ts +++ b/packages/tui/src/tui/controller/chat-controller-types.ts @@ -70,4 +70,10 @@ export interface TuiSubmitOptions { beforeTurnAdmission?: (sessionId: string) => void | Promise; onRuntimeAccepted?: (sessionId: string) => void; optimisticRequestId?: string; + /** + * Fired once a new turn has been minted and projected (before the first + * await), so the caller can retain per-turn state such as the original + * submission snapshot. Not fired for steer paths or retry continuations. + */ + onTurnStarted?: (turnId: string) => void; } diff --git a/packages/tui/src/tui/controller/chat-controller.ts b/packages/tui/src/tui/controller/chat-controller.ts index be98e4c6..0d970508 100644 --- a/packages/tui/src/tui/controller/chat-controller.ts +++ b/packages/tui/src/tui/controller/chat-controller.ts @@ -389,6 +389,9 @@ export class TuiChatController { errorRetryable: undefined, }); if (!optimisticCell && !isRetryContinuation) this.onUserSubmissionProjected?.(); + // Before the first await: the turn id is final, so the caller can retain + // the original submission snapshot for this turn. + if (!isRetryContinuation) options.onTurnStarted?.(turnId); try { await this.requireLoginForAgentAction(); diff --git a/packages/tui/src/tui/controller/product/command-flow.ts b/packages/tui/src/tui/controller/product/command-flow.ts index b78157c0..43b93fdc 100644 --- a/packages/tui/src/tui/controller/product/command-flow.ts +++ b/packages/tui/src/tui/controller/product/command-flow.ts @@ -12,7 +12,7 @@ import { TuiLoginRegionPicker } from '../../features/auth/login-region-picker.js import { TuiPermissionModePicker } from '../../features/interaction/permission-mode-picker.js'; import { TuiSettingsPicker } from '../../features/settings/picker.js'; import { TuiHotkeysPicker } from '../../features/settings/hotkeys-picker.js'; -import type { Editor } from '../../widgets/editor/editor.js'; +import { submittedEditorContent, type Editor } from '../../widgets/editor/editor.js'; import type { TuiInteractionSurface } from '../../shell/interaction-surface.js'; import type { TuiSurfaceHost } from '../../shell/surface-host.js'; import type { TuiRunProjection } from '../../state/run-projection.js'; @@ -23,6 +23,7 @@ import type { FeedbackFlow } from './feedback-flow.js'; import type { TuiSessionMutationFlow } from './session-mutation-flow.js'; import type { TuiInteractionFlow } from '../interaction/interaction-flow.js'; import type { TuiQueueFlow } from '../run/queue-flow.js'; +import { TuiTurnSubmissionRetainer } from '../run/turn-submission-retainer.js'; import type { TuiSessionFlow } from '../session-flow.js'; import type { TuiUpdateFlow } from './update-flow.js'; import type { TuiGoalFlow } from './goal-flow.js'; @@ -49,7 +50,7 @@ import { rebuildSessionMutationTransport } from '../../features/session-mutation import { toTuiTranscriptAttachments } from '../../features/composer/attachments.js'; import { resolveTuiRuntimeFailure } from '../runtime/runtime-error-presentation.js'; import type { TuiEditMessageAttachment, TuiSession } from '../../../runtime/port.js'; -import type { TuiTransportAttachment } from '../../../types/invocation.js'; +import type { TuiAttachment, TuiTransportAttachment } from '../../../types/invocation.js'; import { sessionMutationText } from '../../features/session-mutation/copy.js'; import { parseTuiBashInput } from '../../commands/bash-input.js'; import type { TuiBashFlow } from './bash-flow.js'; @@ -171,6 +172,8 @@ export class TuiCommandFlow { private readonly recoverableSubmissions = new Map(); private readonly sessionRetryability = new Map(); private readonly failedSubmissions = new Map(); + private readonly turnSubmissionRetainer = new TuiTurnSubmissionRetainer(); + private readonly restoredAbortSubmissionIds = new Set(); private readonly openExternalTarget: TuiExternalTargetOpener; constructor(private readonly options: TuiCommandFlowOptions) { @@ -181,6 +184,30 @@ export class TuiCommandFlow { ); } + /** The original submission retained for a still-unoutput turn, if any. */ + getRetainedSubmission(turnId: string): TuiSubmissionSnapshot | undefined { + return this.turnSubmissionRetainer.get(turnId); + } + + dropRetainedSubmission(turnId: string): void { + this.turnSubmissionRetainer.drop(turnId); + } + + /** Clears per-turn retained submissions on a session switch. */ + clearRetainedSubmissions(): void { + this.turnSubmissionRetainer.clear(); + this.restoredAbortSubmissionIds.clear(); + } + + /** Keep a restored draft's attachment lease when its original send finishes. */ + markAbortSubmissionRestored(submissionId: string): void { + this.restoredAbortSubmissionIds.add(submissionId); + if (this.restoredAbortSubmissionIds.size > 8) { + const oldest = this.restoredAbortSubmissionIds.values().next().value; + if (oldest) this.restoredAbortSubmissionIds.delete(oldest); + } + } + captureSubmissionSeed(editorDraft?: ReturnType): TuiSubmissionSeed { const sessionId = this.options.controller.snapshot().session?.sessionId; const editor = editorDraft ?? this.options.editor.captureDraft(); @@ -194,15 +221,32 @@ export class TuiCommandFlow { const recoveryKey = sessionId ?? 'new-session'; const recoverable = this.recoverableSubmissions.get(recoveryKey); this.recoverableSubmissions.delete(recoveryKey); + const visibleContent = submittedEditorContent(editor); + const unchangedText = recoverable?.content === visibleContent; + // Hidden context may be rebuilt for an edited session-mutation message. + // Other opaque transport (including /review) belongs to the old text and + // must not override the user's correction. + const transportContent = recoverable?.transportContent && + (unchangedText || rebuildSessionMutationTransport(recoverable.transportContent, visibleContent)) + ? recoverable.transportContent + : undefined; + const transportAttachments = recoverable?.transportAttachments + ? reconcileRecoveredTransportAttachments(recoverable, resources.attachments) + : undefined; return { ...(sessionId ? { sessionId } : {}), editor, resources, - ...(recoverable?.transportContent ? { transportContent: recoverable.transportContent } : {}), - ...(recoverable?.transportAttachments - ? { transportAttachments: recoverable.transportAttachments } + ...(transportContent ? { transportContent } : {}), + ...(transportAttachments + ? { transportAttachments } + : {}), + ...(unchangedText && recoverable?.clientIntent + ? { clientIntent: recoverable.clientIntent } + : {}), + ...(unchangedText && recoverable?.reviewRequest + ? { reviewRequest: recoverable.reviewRequest } : {}), - ...(recoverable?.reviewRequest ? { reviewRequest: recoverable.reviewRequest } : {}), }; } @@ -552,9 +596,7 @@ export class TuiCommandFlow { isFirstMessage, }); this.options.planModeFlow?.rejectSubmission(submission.submissionId); - await this.options.composerDraft.completeSubmission({ - attachments: submission.attachments, - }); + await this.completeSubmittedResources(submission); return { disposition: 'consumed' }; } catch (error) { this.options.planModeFlow?.rejectSubmission(submission.submissionId); @@ -635,6 +677,7 @@ export class TuiCommandFlow { }); } await this.options.featureFlow.waitForWelcomeModelSelection(); + let retainedTurnId: string | undefined; try { return { primary: this.options.controller.submit(submission.transportContent ?? command, { @@ -649,9 +692,20 @@ export class TuiCommandFlow { ...(optimisticRequestId ? { optimisticRequestId } : {}), ...(submission.clientIntent ? { clientIntent: submission.clientIntent } : {}), ...(submission.reviewRequest ? { reviewRequest: submission.reviewRequest } : {}), + onTurnStarted: (turnId) => { + // Retain the original submission for this turn so an early + // abort can replay it in full instead of reconstructing a + // lossy copy from the transcript cell. + retainedTurnId = turnId; + this.turnSubmissionRetainer.remember(turnId, submission); + }, onSessionResolved: (sessionId) => { submission = { ...submission, sessionId }; preparingSubmission = submission; + // Re-remember with the resolved session id attached. + if (retainedTurnId !== undefined) { + this.turnSubmissionRetainer.remember(retainedTurnId, submission); + } options.onSubmissionPrepared?.(submission); if (submission.clientIntent) { this.options.planModeFlow?.bindSubmissionToSession( @@ -744,9 +798,7 @@ export class TuiCommandFlow { } if (this.options.isStopped?.()) { await this.options.whenStopping?.(); - await this.options.composerDraft.completeSubmission({ - attachments: submission.attachments, - }); + await this.completeSubmittedResources(submission); return 'consumed'; } if (submitStatus === 'queue-required' && this.options.queueEnabled) { @@ -824,9 +876,7 @@ export class TuiCommandFlow { submitStatus === 'cancelled' || submitStatus === 'ignored' ) { - await this.options.composerDraft.completeSubmission({ - attachments: submission.attachments, - }); + await this.completeSubmittedResources(submission); } else if (prepared.atomic) { this.restoreSubmission(submission); } else { @@ -875,16 +925,26 @@ export class TuiCommandFlow { !attachment.filePath || !submission.attachments.some((draft) => draft.filePath === attachment.filePath), ); - if (submission.transportContent || submission.reviewRequest || hasTransportOnlyAttachment) { - this.restoreRecoverableSubmission(submission); - } this.options.editor.restoreSubmittedDraft(submission.editor); this.options.composerDraft.restoreSubmission({ attachments: submission.attachments }); + if ( + submission.transportContent || + submission.clientIntent || + submission.reviewRequest || + hasTransportOnlyAttachment + ) { + this.restoreRecoverableSubmission(submission); + } this.options.surfaceHost.setChatFocus(this.options.editor); this.options.onChanged(); return 'retained'; } + private async completeSubmittedResources(submission: TuiSubmissionSnapshot): Promise { + if (this.restoredAbortSubmissionIds.delete(submission.submissionId)) return; + await this.options.composerDraft.completeSubmission({ attachments: submission.attachments }); + } + private async completeStoppedSubmission(seed: TuiSubmissionSeed): Promise { await this.options.whenStopping?.(); await this.options.composerDraft.completeSubmission(seed.resources); @@ -1754,6 +1814,22 @@ function toRecoveredEditTransportAttachments( }); } +function reconcileRecoveredTransportAttachments( + submission: TuiSubmissionSnapshot, + currentAttachments: readonly TuiAttachment[], +): TuiTransportAttachment[] { + const originalLocalPaths = new Set(submission.attachments.map((item) => item.filePath)); + const currentPaths = new Set(currentAttachments.map((item) => item.filePath)); + const retained = (submission.transportAttachments ?? []).filter( + (item) => !item.filePath || !originalLocalPaths.has(item.filePath) || currentPaths.has(item.filePath), + ); + const retainedPaths = new Set(retained.flatMap((item) => item.filePath ? [item.filePath] : [])); + return [ + ...retained, + ...currentAttachments.filter((item) => !retainedPaths.has(item.filePath)), + ]; +} + function prepareSubmissionFailureMessage(error: unknown): string { if (error instanceof TuiLoginRequiredError) return error.message; return formatTuiActionFailure(error, { diff --git a/packages/tui/src/tui/controller/run/abort-live-turn.ts b/packages/tui/src/tui/controller/run/abort-live-turn.ts index 5b82ac1e..636572f0 100644 --- a/packages/tui/src/tui/controller/run/abort-live-turn.ts +++ b/packages/tui/src/tui/controller/run/abort-live-turn.ts @@ -3,6 +3,7 @@ import { isTuiDelegatedSession } from '../../../runtime/delegation.js'; import type { TuiRunProjection } from '../../state/run-projection.js'; import type { TuiChatController } from '../chat-controller.js'; import { formatTuiActionFailure } from '../../../user-facing-failure.js'; +import { DEFAULT_CANCELLATION_SETTLEMENT_TIMEOUT_MS } from '../../../application/run-coordinator.js'; export function createTuiAbortLiveTurn(options: { controller: TuiChatController; @@ -15,6 +16,15 @@ export function createTuiAbortLiveTurn(options: { updateChrome(): void; requestRender(): void; append(message: string, kind: 'warning' | 'error'): void; + /** + * Invoked at most once per abort attempt after a confirmed stop of the + * aborted turn, so the caller can return that turn's prompt to the + * composer when the turn produced no user-visible output. Only a confirmed + * root-turn stop fires this; a delegated-only stop may leave the root turn + * running. A second abort attempt for the same turn may fire it again — + * the caller owns cross-attempt dedupe. + */ + onLiveTurnAborted?: (info: { turnId: string; sessionId?: string }) => void; }): () => Promise { return async () => { const snapshot = options.controller.snapshot(); @@ -47,19 +57,50 @@ export function createTuiAbortLiveTurn(options: { return receipt.rootStopped || receipt.stoppedSessionIds.length > 0; }; if (snapshot.activeTurnId) { + const turnId = snapshot.activeTurnId; + let abortedRoot = false; + let delegatedStopped = false; try { const stoppingDelegation = stopDelegatedAgents(); - const aborted = await options.controller.abort(); - const delegatedStopped = await stoppingDelegation; - if (!aborted && !delegatedStopped) { + abortedRoot = await options.controller.abort(); + delegatedStopped = await stoppingDelegation; + if (!abortedRoot && !delegatedStopped) { options.append('Runtime did not confirm that the active response stopped.', 'warning'); } - return delegatedStopped || aborted; } finally { options.setTransientHint(undefined); options.updateChrome(); options.requestRender(); } + // Restore only after the run is CONFIRMED settled, not merely accepted: + // coordinator.abort() can return true after the settlement timeout while + // the run still drains, and a late stream event would defeat the gate. + // Bounded wait: if retirement does not settle within the cancellation + // settlement window, skip the restore entirely (an unbounded await + // would hang the funnel — nothing resolves idle waiters in that state). + // An unconfirmed stop skips the wait: no restore can fire, so the + // funnel (and leaveUi behind it) should not pay the bound. + let settleTimer: ReturnType | undefined; + const settledInTime = abortedRoot + ? await Promise.race([ + options.controller.whenIdle().then(() => true), + new Promise((resolve) => { + settleTimer = setTimeout( + () => resolve(false), + DEFAULT_CANCELLATION_SETTLEMENT_TIMEOUT_MS, + ); + }), + ]) + : false; + // Match the settleWithin house pattern: never leave the losing timer + // holding the event loop (embedded hosts drain on exit). + if (settleTimer !== undefined) clearTimeout(settleTimer); + // Fire after the finally block: it clears the transient hint and would + // otherwise erase the restore hint set by the callback. + if (abortedRoot && settledInTime && options.onLiveTurnAborted) { + fireRestore(options, { turnId, sessionId: session?.sessionId }); + } + return delegatedStopped || abortedRoot; } const runtimeTurnId = options.latestRuntimeTurnId(); if (!runtimeTurnId || !snapshot.session?.sessionId) return false; @@ -80,17 +121,23 @@ export function createTuiAbortLiveTurn(options: { options.append('Runtime did not confirm that the active response stopped.', 'warning'); } const active = await options.getActiveRun(sessionId).catch(() => undefined); - if ( - active && + const settled = + !!active && (active.state === 'idle' || active.state === 'terminal' || - (active.turnId && active.turnId !== runtimeTurnId)) - ) { + (active.turnId && active.turnId !== runtimeTurnId)); + if (settled) { options.runProjection.clearRuntimeTurn(runtimeTurnId); options.setTransientHint(undefined); } options.updateChrome(); options.requestRender(); + // Restore only once the run is confirmed settled: runtime-owned turns + // settle on the terminal runtime event, which can trail the abort + // response, and late cells would otherwise defeat the gate. + if (rootStopped && settled && options.onLiveTurnAborted) { + fireRestore(options, { turnId: runtimeTurnId, sessionId }); + } return rootStopped || delegatedStopped; } catch (error) { options.runProjection.clearRuntimeTurnStopping(runtimeTurnId); @@ -109,3 +156,30 @@ export function createTuiAbortLiveTurn(options: { } }; } + +/** + * Fires the restore callback with Esc-handling isolation: a restore failure + * is reported as a warning instead of escaping the funnel. + */ +function fireRestore( + options: { + onLiveTurnAborted?: (info: { turnId: string; sessionId?: string }) => void; + append: (message: string, kind: 'warning' | 'error') => void; + }, + info: { turnId: string; sessionId?: string }, +): void { + if (!options.onLiveTurnAborted) return; + try { + options.onLiveTurnAborted(info); + } catch (error) { + // Esc handling must survive a restore failure; report it instead. + options.append( + formatTuiActionFailure(error, { + summary: "Couldn't return the aborted prompt to the composer.", + nextStep: 'Retry Esc, or recall the prompt with Up.', + preservation: 'The session transcript is unchanged.', + }), + 'warning', + ); + } +} diff --git a/packages/tui/src/tui/controller/run/turn-submission-retainer.ts b/packages/tui/src/tui/controller/run/turn-submission-retainer.ts new file mode 100644 index 00000000..950dc321 --- /dev/null +++ b/packages/tui/src/tui/controller/run/turn-submission-retainer.ts @@ -0,0 +1,43 @@ +import type { TuiSubmissionSnapshot } from '../../features/composer/submission.js'; + +interface RetainedTurnSubmission { + readonly snapshot: TuiSubmissionSnapshot; + readonly createdAtMs: number; +} + +const RETAINED_TURNS_LIMIT = 8; + +/** + * Retains the most recent submission snapshots keyed by turn id so an early + * abort can replay the ORIGINAL submission (complete attachment set, + * transport content, client intent, editor state) instead of reconstructing + * a lossy copy from the transcript display cell. Entries are dropped once + * restored; the map is bounded and cleared on session switch. + */ +export class TuiTurnSubmissionRetainer { + private readonly entries = new Map(); + + remember(turnId: string, snapshot: TuiSubmissionSnapshot, now = Date.now()): void { + // Delete-then-set keeps iteration order aligned with recency so the + // oldest entry is always the first to evict. + this.entries.delete(turnId); + this.entries.set(turnId, { snapshot, createdAtMs: now }); + while (this.entries.size > RETAINED_TURNS_LIMIT) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + } + + get(turnId: string): TuiSubmissionSnapshot | undefined { + return this.entries.get(turnId)?.snapshot; + } + + drop(turnId: string): void { + this.entries.delete(turnId); + } + + clear(): void { + this.entries.clear(); + } +} diff --git a/packages/tui/src/tui/features/composer/draft-lifecycle.ts b/packages/tui/src/tui/features/composer/draft-lifecycle.ts index 5ce7513f..c9bd52b6 100644 --- a/packages/tui/src/tui/features/composer/draft-lifecycle.ts +++ b/packages/tui/src/tui/features/composer/draft-lifecycle.ts @@ -13,6 +13,7 @@ import { } from './submission.js'; const ADOPTION_RETRY_DELAY_MS = 30_000; +const RESTORED_SUBMISSION_CODE = 'submission.restored'; export class TuiDraftLifecycle { private recovery?: TuiDraftRecovery; @@ -122,6 +123,39 @@ export class TuiDraftLifecycle { void this.recovery.flush(this.capture()).catch((error: unknown) => this.report(error)); } + /** Persist an aborted submission's opaque metadata without replaying its text on hydrate. */ + recordRestoredSubmission(snapshot: TuiSubmissionSnapshot): void { + if (this.stopped) return; + for (const [submissionToken, pending] of this.pendingSubmissions) { + if (pending.sessionKey !== this.sessionKey) continue; + this.pendingSubmissions.delete(submissionToken); + } + const hasTransportOnlyAttachment = snapshot.transportAttachments?.some( + (attachment) => + !attachment.filePath || + !snapshot.attachments.some((draft) => draft.filePath === attachment.filePath), + ); + if ( + snapshot.transportContent || + snapshot.clientIntent || + snapshot.reviewRequest || + hasTransportOnlyAttachment + ) { + const retryId = `retry:restored:${snapshot.submissionId}`; + this.pendingSubmissions.set(retryId, { + sessionKey: this.sessionKey, + retry: { + retryId, + failureCode: RESTORED_SUBMISSION_CODE, + failedReason: 'Aborted submission returned to the composer.', + snapshot, + }, + }); + } + // This also removes the matching interrupted retry from the recovery file. + void this.recovery?.flush(this.capture()).catch((error: unknown) => this.report(error)); + } + switchSession(sessionKey: string): Promise { return this.enqueueTransition(async () => { if (this.stopped || sessionKey === this.sessionKey) return; @@ -202,12 +236,15 @@ export class TuiDraftLifecycle { async stop(): Promise { if (this.stopped) return; + // The app disposes the editor immediately after calling stop(); capture + // placeholder metadata before its dispose() clears that state. + const finalDraft = this.capture(); this.stopped = true; await this.transitionTail.catch(() => undefined); let releaseDraft = true; if (this.recovery) { try { - await this.recovery.flush(this.capture(), { materializeVolatileAttachments: true }); + await this.recovery.flush(finalDraft, { materializeVolatileAttachments: true }); } catch (error) { releaseDraft = false; this.report(error); @@ -251,8 +288,21 @@ export class TuiDraftLifecycle { private capture() { const draft = this.options.composerDraft.snapshot(); + const editor = this.options.editor.captureDraft(); + // Clearing a visible restored draft also discards its hidden metadata. + if (!editor.text && draft.attachments.length === 0) { + for (const [token, pending] of this.pendingSubmissions) { + if ( + pending.sessionKey === this.sessionKey && + pending.retry.failureCode === RESTORED_SUBMISSION_CODE && + (pending.retry.snapshot.editor.text || pending.retry.snapshot.content) + ) { + this.pendingSubmissions.delete(token); + } + } + } return { - editor: this.options.editor.captureDraft(), + editor, attachments: [...draft.attachments], retrySubmissions: this.captureRetrySubmissions(this.sessionKey), }; @@ -262,6 +312,11 @@ export class TuiDraftLifecycle { let restoredEditor = this.options.editor.restoreDraft(recovered.editor); this.options.composerDraft.restoreAttachments(recovered.attachments); for (const retry of recovered.retrySubmissions ?? []) { + if (retry.failureCode === RESTORED_SUBMISSION_CODE) { + this.pendingSubmissions.set(retry.retryId, { sessionKey: this.sessionKey, retry }); + this.options.onRetryRestored?.(retry.snapshot); + continue; + } if (retry.failureCode !== 'submission.interrupted') continue; restoredEditor = this.options.editor.restoreSubmittedDraft(retry.snapshot.editor) || restoredEditor; diff --git a/packages/tui/src/tui/features/composer/draft-recovery.ts b/packages/tui/src/tui/features/composer/draft-recovery.ts index 8f7b94f5..0e8a829e 100644 --- a/packages/tui/src/tui/features/composer/draft-recovery.ts +++ b/packages/tui/src/tui/features/composer/draft-recovery.ts @@ -437,6 +437,7 @@ export class TuiDraftRecovery { : {}), createdAtMs: snapshot.createdAtMs, ...(snapshot.clientIntent ? { clientIntent: snapshot.clientIntent } : {}), + ...(snapshot.reviewRequest ? { reviewRequest: snapshot.reviewRequest } : {}), }; } @@ -547,6 +548,7 @@ function cloneRetrySubmission(retry: TuiRetrySubmission): TuiRetrySubmission { : {}), createdAtMs: retry.snapshot.createdAtMs, ...(retry.snapshot.clientIntent ? { clientIntent: retry.snapshot.clientIntent } : {}), + ...(retry.snapshot.reviewRequest ? { reviewRequest: retry.snapshot.reviewRequest } : {}), }, }; } @@ -579,7 +581,8 @@ function relocateEditorAttachmentPlaceholders( }; } -function retainAvailableAttachmentPlaceholders( +/** Drops editor attachment placeholders whose backing file no longer exists. */ +export function retainAvailableAttachmentPlaceholders( editor: EditorDraftSnapshot, attachments: readonly TuiAttachment[], ): EditorDraftSnapshot { @@ -620,6 +623,16 @@ function relocateStoredDraftAssets( ...retry.snapshot, attachments: retryAttachments, editor: relocateEditorAttachmentPlaceholders(retry.snapshot.editor, relocatedPaths), + ...(retry.snapshot.transportAttachments + ? { + transportAttachments: retry.snapshot.transportAttachments.map((attachment) => ({ + ...attachment, + ...(attachment.filePath && relocatedPaths.has(attachment.filePath) + ? { filePath: relocatedPaths.get(attachment.filePath) } + : {}), + })), + } + : {}), }, }; }); @@ -711,6 +724,8 @@ function isSubmissionSnapshot(value: unknown): value is TuiSubmissionSnapshot { (value.clientIntent !== undefined && value.clientIntent !== 'plan-entry' && value.clientIntent !== 'plan-exit') || + (value.reviewRequest !== undefined && + (!isRecord(value.reviewRequest) || value.reviewRequest.scope !== 'local_changes')) || !Number.isFinite(value.createdAtMs) ) { return false; diff --git a/packages/tui/src/tui/transcript/view.ts b/packages/tui/src/tui/transcript/view.ts index 86986735..25a17f8f 100644 --- a/packages/tui/src/tui/transcript/view.ts +++ b/packages/tui/src/tui/transcript/view.ts @@ -821,9 +821,16 @@ function renderUserIntent(content: string, width: number): string[] { function renderUserMessage(cell: TranscriptCell, width: number): string[] { const attachments = renderUserAttachments(cell, width); const body = cell.content.trim() ? renderUserIntent(cell.content, width) : []; - return attachments.length > 0 && body.length > 0 - ? [...attachments, ' ', ...body] - : [...attachments, ...body]; + const rows = + attachments.length > 0 && body.length > 0 + ? [...attachments, ' ', ...body] + : [...attachments, ...body]; + // A cancelled user row (prompt restored to the composer on abort) stays in + // the history with a muted marker, matching the cancelled-todo precedent. + if (cell.status === 'cancelled') { + return [chalk.hex(colors.muted)('× Cancelled'), ...rows]; + } + return rows; } function renderPendingSteerMessage(cell: TranscriptCell, width: number): string[] { diff --git a/packages/tui/src/tui/widgets/editor/editor.ts b/packages/tui/src/tui/widgets/editor/editor.ts index eeca2a0f..f81e3eff 100644 --- a/packages/tui/src/tui/widgets/editor/editor.ts +++ b/packages/tui/src/tui/widgets/editor/editor.ts @@ -450,8 +450,7 @@ export class Editor implements Component, Focusable { ), }; this.pendingSubmission = undefined; - const visibleText = removeAttachmentElements(draft.text, draft.attachmentPlaceholders ?? []); - const content = expandDraftPastes(visibleText, draft.pastes).trim(); + const content = submittedEditorContent(draft); if (draft.attachmentPlaceholders?.length) this.lastAttachmentSubmission = { content, draft }; this.onSubmit?.(content, draft); } @@ -808,6 +807,11 @@ function appendAttachmentElements( return { text: output, cursor, elements: appended }; } +export function submittedEditorContent(draft: EditorDraftSnapshot): string { + const visibleText = removeAttachmentElements(draft.text, draft.attachmentPlaceholders ?? []); + return expandDraftPastes(visibleText, draft.pastes).trim(); +} + function removeAttachmentElements( text: string, elements: readonly EditorAttachmentElement[], diff --git a/packages/tui/src/types/tui-app.ts b/packages/tui/src/types/tui-app.ts index 2228931f..32c30d2d 100644 --- a/packages/tui/src/types/tui-app.ts +++ b/packages/tui/src/types/tui-app.ts @@ -1,6 +1,7 @@ import type { TuiIncidentSink, TuiObservability } from '../observability/index.js'; import type { McodeAuthPort } from '../auth/application.js'; import type { TuiRuntime, TuiWorkspaceRoot } from '../runtime/port.js'; +import type { TuiCommandFlow } from '../tui/controller/product/command-flow.js'; import type { TuiAttachment, ResolveTuiAttachmentOptions, @@ -106,6 +107,8 @@ export interface TuiApp { /** Internal startup chrome shown while the embedded Runtime is initializing. */ setStartupStatus(status?: string): void; submit(input: string): Promise; + /** Exposed for integration tests that submit with an explicit seed. */ + commandFlow: TuiCommandFlow; abortTurn(): Promise; leaveUi(): Promise; stop(options?: TuiStopOptions): Promise; diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 147f85d9..ab081afd 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -11326,12 +11326,15 @@ describe("createTuiApp", () => { app.start(); await app.ready; const submitting = app.submit("Wait for the session"); - await vi.waitFor(() => - expect(app.controller.snapshot()).toMatchObject({ + let turnIdAtAbort: string | undefined; + await vi.waitFor(() => { + const snapshot = app.controller.snapshot(); + turnIdAtAbort = snapshot.activeTurnId; + expect(snapshot).toMatchObject({ status: "starting", activeTurnId: expect.any(String), - }), - ); + }); + }); expect(app.tui.render(80).join("\n")).toContain("Loading"); expect(app.tui.render(80).join("\n")).not.toContain("Loading · 0s"); expect(app.tui.render(80).join("\n")).not.toContain("MCode ·"); @@ -11346,11 +11349,15 @@ describe("createTuiApp", () => { await submitting; expect(runtime.sendMessage).not.toHaveBeenCalled(); - expect(app.transcript.snapshot()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: "assistant", status: "cancelled" }), - ]), - ); + // The turn had produced no output, so its prompt returned to the composer; + // the user row stays in the transcript, marked cancelled. + expect(app.editor.getText()).toBe("Wait for the session"); + expect(app.transcript.get(`user:${turnIdAtAbort}`)?.status).toBe("cancelled"); + expect( + app.transcript + .snapshot() + .some((cell) => cell.kind === "assistant" && cell.status === "cancelled"), + ).toBe(true); await app.stop(); }); @@ -11400,7 +11407,9 @@ describe("createTuiApp", () => { ); terminal.input?.("Second request"); terminal.input?.("\r"); - await vi.waitFor(() => expect(app.editor.getText()).toBe("Second request")); + await vi.waitFor(() => + expect(app.editor.getText()).toContain("Second request"), + ); expect(runtime.sendMessage).toHaveBeenCalledTimes(1); await vi.waitFor(() => @@ -11410,6 +11419,11 @@ describe("createTuiApp", () => { expect(app.controller.snapshot().retiringTurnId).toBeUndefined(), ); expect(app.controller.snapshot().cancelling).toBe(false); + // The aborted first prompt (still without output) returns to the composer + // and merges with the text typed while the fence was active. + await vi.waitFor(() => + expect(app.editor.getText()).toBe("First request\nSecond request"), + ); expect(app.tui.render(80).join("\n")).not.toContain( "Stopping the current response", ); @@ -11419,11 +11433,12 @@ describe("createTuiApp", () => { ); expect(runtime.sendMessage).toHaveBeenCalledTimes(2); expect(runtime.enqueueMessage).not.toHaveBeenCalled(); - expect(app.transcript.snapshot()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: "assistant", status: "cancelled" }), - ]), - ); + // Restoring the aborted prompt keeps that turn's rows, marked cancelled. + expect( + app.transcript + .snapshot() + .some((cell) => cell.kind === "assistant" && cell.status === "cancelled"), + ).toBe(true); await app.stop(); }); @@ -11436,6 +11451,9 @@ describe("createTuiApp", () => { }); vi.mocked(runtime.sendMessage).mockImplementation( async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + // One delta keeps this turn outside the abort-prompt-restore window so + // this test stays focused on the failed-submission restore below. + yield { type: "delta", content: "First response" }; await new Promise((resolve) => { signal?.addEventListener("abort", resolve, { once: true }); }); @@ -11491,6 +11509,697 @@ describe("createTuiApp", () => { await app.stop(); }); + it("returns the prompt to the composer when a turn is aborted before any output", async () => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + terminal.input?.("Fix the typo"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(app.controller.snapshot().status).toBe("running"), + ); + const turnId = app.controller.snapshot().activeTurnId; + expect(turnId).toBeTruthy(); + + terminal.input?.("\x1b"); + await vi.waitFor(() => expect(app.editor.getText()).toBe("Fix the typo")); + + // The user row stays in the transcript, marked cancelled and rendered + // with the muted marker. + const cancelledUserCell = app.transcript.get(`user:${turnId}`); + expect(cancelledUserCell).toBeDefined(); + expect(cancelledUserCell?.status).toBe("cancelled"); + expect( + app.transcript.snapshot().some((cell) => cell.turnId === turnId), + ).toBe(true); + await vi.waitFor(() => + expect(app.tui.render(160).join("\n")).toContain("× Cancelled"), + ); + await vi.waitFor(() => + expect(app.tui.render(160).join("\n")).toContain( + "Stopped · message restored to the Composer.", + ), + ); + await app.stop(); + }); + + it("returns a thinking-only turn's prompt to the composer on abort", async () => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + yield { type: "delta", thinking: "let me consider" }; + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + terminal.input?.("Wrong question"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(app.controller.snapshot().status).toBe("running"), + ); + const turnId = app.controller.snapshot().activeTurnId; + await vi.waitFor(() => + expect( + app.transcript.snapshot().some((cell) => cell.kind === "thinking"), + ).toBe(true), + ); + + terminal.input?.("\x1b"); + await vi.waitFor(() => expect(app.editor.getText()).toBe("Wrong question")); + expect( + app.transcript.snapshot().some((cell) => cell.turnId === turnId), + ).toBe(true); + await app.stop(); + }); + + it("keeps the transcript and leaves the composer empty when aborting after output", async () => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + yield { type: "delta", content: "partial answer" }; + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + terminal.input?.("Keep this prompt"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(app.controller.snapshot().status).toBe("running"), + ); + const turnId = app.controller.snapshot().activeTurnId; + await vi.waitFor(() => + expect( + app.transcript.snapshot().some( + (cell) => cell.kind === "assistant" && cell.content.length > 0, + ), + ).toBe(true), + ); + + terminal.input?.("\x1b"); + await vi.waitFor(() => + expect(app.controller.snapshot().activeTurnId).toBeUndefined(), + ); + await vi.waitFor(() => + expect(app.controller.snapshot().retiringTurnId).toBeUndefined(), + ); + + expect(app.editor.getText()).toBe(""); + expect(app.transcript.get(`user:${turnId}`)).toBeDefined(); + expect(app.tui.render(160).join("\n")).not.toContain( + "Stopped · message restored to the Composer.", + ); + await app.stop(); + }); + + it.each([false, true])( + "restores a mixed submission after abort (local attachment removed: %s)", + async (removeLocal) => { + const directory = await mkdtemp(join(tmpdir(), "mcode-mixed-abort-")); + try { + const localPath = join(directory, "local.png"); + await writeFile(localPath, "png"); + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + // Mixed submission: a file-backed attachment (composer chip) plus an + // asset-only transport attachment. Submit through the seed path so the + // snapshot carries the full transport list, exactly as a paste flow + // would produce. + const seed = { + editor: { + schemaVersion: 1 as const, + text: "Mixed restore", + cursor: 13, + pastes: [], + pasteCounter: 0, + }, + resources: { + attachments: [ + { + type: "image" as const, + fileName: "local.png", + mimeType: "image/png", + sizeBytes: 3, + filePath: localPath, + }, + ], + }, + transportAttachments: [ + { + type: "image" as const, + fileName: "local.png", + mimeType: "image/png", + filePath: localPath, + }, + { + type: "image" as const, + fileName: "asset.png", + mimeType: "image/png", + assetId: "asset-mixed-1", + }, + ], + }; + void app.commandFlow.submit("Mixed restore", seed).catch(() => undefined); + await vi.waitFor(() => + expect(app.controller.snapshot().status).toBe("running"), + ); + const turnIdAtAbort = app.controller.snapshot().activeTurnId; + + terminal.input?.("\x1b"); + await vi.waitFor(() => + expect(app.editor.getText()).toBe("Mixed restore [Image #1] "), + ); + // The retained snapshot was consulted and dropped after the restore. + await vi.waitFor(() => + expect(app.commandFlow.getRetainedSubmission(String(turnIdAtAbort))).toBeUndefined(), + ); + + if (removeLocal) { + app.editor.handleInput("\x05"); + app.editor.handleInput("\x7f"); + await vi.waitFor(() => + expect(app.editor.getText()).not.toContain("[Image #1]"), + ); + } + + // Resubmit the restored draft with its current attachment selection. + app.editor.handleInput("\r"); + await vi.waitFor(() => + expect(vi.mocked(runtime.sendMessage).mock.calls.length).toBe(2), + ); + + const request = vi.mocked(runtime.sendMessage).mock.calls[1][0]; + expect(request.attachments).toHaveLength(removeLocal ? 1 : 2); + const locals = request.attachments?.map((attachment) => attachment.local) ?? []; + expect(locals).toEqual( + removeLocal + ? [expect.objectContaining({ assetId: "asset-mixed-1" })] + : expect.arrayContaining([ + expect.objectContaining({ filePath: localPath }), + expect.objectContaining({ assetId: "asset-mixed-1" }), + ]), + ); + await app.stop(); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, + ); + + it.each([false, true])( + "restores hidden transport only for unchanged text (edited: %s)", + async (edited) => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + void app.commandFlow.submit("Visible prompt", { + editor: { schemaVersion: 1, text: "", cursor: 0, pastes: [], pasteCounter: 0 }, + resources: { attachments: [] }, + transportContent: "Original hidden transport", + clientIntent: "plan-entry", + }); + await vi.waitFor(() => expect(app.controller.snapshot().status).toBe("running")); + + terminal.input?.("\x1b"); + await vi.waitFor(() => expect(app.editor.getText()).toBe("Visible prompt")); + if (edited) app.editor.setText("Corrected prompt"); + app.editor.handleInput("\r"); + await vi.waitFor(() => expect(vi.mocked(runtime.sendMessage)).toHaveBeenCalledTimes(2)); + const request = vi.mocked(runtime.sendMessage).mock.calls[1][0]; + expect(request.content).toBe(edited ? "Corrected prompt" : "Original hidden transport"); + expect(request.clientIntent).toBe(edited ? undefined : "plan-entry"); + await app.stop(); + }, + ); + + it("sends edited visible text instead of stale hidden review transport after abort", async () => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + void app.commandFlow.submit( + "/review", + { + editor: { schemaVersion: 1, text: "/review", cursor: 7, pastes: [], pasteCounter: 0 }, + resources: { attachments: [] }, + }, + { + forceMessage: true, + transportContent: "Please review my uncommitted changes.", + reviewRequest: { scope: "local_changes" }, + }, + ); + await vi.waitFor(() => expect(app.controller.snapshot().status).toBe("running")); + + terminal.input?.("\x1b"); + await vi.waitFor(() => expect(app.editor.getText()).toBe("/review")); + app.editor.setText("Review only src/foo.ts"); + app.editor.handleInput("\r"); + await vi.waitFor(() => expect(vi.mocked(runtime.sendMessage)).toHaveBeenCalledTimes(2)); + expect(vi.mocked(runtime.sendMessage).mock.calls[1][0]).toMatchObject({ + content: "Review only src/foo.ts", + }); + expect(vi.mocked(runtime.sendMessage).mock.calls[1][0].reviewRequest).toBeUndefined(); + await app.stop(); + }); + + it("does not restore the prompt when the runtime does not confirm the stop", async () => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(false); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + terminal.input?.("Unconfirmed stop"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(app.controller.snapshot().status).toBe("running"), + ); + const turnId = app.controller.snapshot().activeTurnId; + + terminal.input?.("\x1b"); + await vi.waitFor(() => + expect(app.controller.snapshot().cancelling).toBe(true), + ); + // Let the abort funnel (settle race included) finish before asserting; an + // immediate assert could pass before a regressed restore ever fired. + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(app.editor.getText()).toBe(""); + expect(app.transcript.get(`user:${turnId}`)).toBeDefined(); + await app.stop(); + }); + + it("does not restore the prompt when only delegated agents stopped", async () => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(false); + vi.mocked(runtime.stopDelegation).mockResolvedValue({ + rootStopped: false, + stoppedSessionIds: ["child-1"], + failedSessionIds: [], + activeSessionIds: [], + }); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + terminal.input?.("Root keeps running"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(app.controller.snapshot().status).toBe("running"), + ); + const turnId = app.controller.snapshot().activeTurnId; + + terminal.input?.("\x1b"); + await vi.waitFor(() => + expect(app.controller.snapshot().cancelling).toBe(true), + ); + // Let the abort funnel settle; the root turn was never confirmed stopped. + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(app.editor.getText()).toBe(""); + expect(app.transcript.get(`user:${turnId}`)).toBeDefined(); + await app.stop(); + }); + + it("does not restore when retirement does not settle within the settlement window", async () => { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + await app.ready; + terminal.input?.("Hanging retirement"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(app.controller.snapshot().status).toBe("running"), + ); + const turnId = app.controller.snapshot().activeTurnId; + + // Simulate a retirement that never settles: whenIdle parks its waiter + // forever, so the bounded settle race must skip the restore instead of + // hanging the funnel. + vi.spyOn(app.controller, "whenIdle").mockReturnValue(new Promise(() => undefined)); + + terminal.input?.("\x1b"); + // Both the coordinator settle wait and the restore's settle race use the + // 1s cancellation-settlement window; dwell past both before asserting. + await new Promise((resolve) => setTimeout(resolve, 2_500)); + + expect(app.editor.getText()).toBe(""); + expect(app.tui.render(160).join("\n")).not.toContain( + "Stopped · message restored to the Composer.", + ); + // The restore never fired; the user row is untouched by it. (Its status + // may legitimately read 'cancelled' from abort-time markTurn when Esc + // lands before the user echo, which is unrelated to the restore.) + expect(app.transcript.get(`user:${turnId}`)).toBeDefined(); + + await app.stop(); + }); + + it("does not duplicate the restored prompt after relaunch", async () => { + const dataDir = await mkdtemp(join(tmpdir(), "mcode-abort-draft-")); + try { + const terminal = new FakeTerminal(); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const first = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + dataDir, + workspaceDir: "/workspace", + }); + first.start(); + await first.ready; + await first.openSession("session-1"); + terminal.input?.("Only once"); + terminal.input?.("\r"); + await vi.waitFor(() => + expect(first.controller.snapshot().status).toBe("running"), + ); + terminal.input?.("\x1b"); + await vi.waitFor(() => expect(first.editor.getText()).toBe("Only once")); + await first.stop(); + + const restored = createTuiApp({ + runtime: createRuntime(), + terminal: new FakeTerminal(), + version: "0.1.0", + dataDir, + workspaceDir: "/workspace", + }); + restored.start(); + await restored.ready; + await restored.openSession("session-1"); + await vi.waitFor(() => expect(restored.editor.getText()).toBe("Only once")); + await restored.stop(); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it("preserves restored asset and hidden submission metadata after relaunch", async () => { + const dataDir = await mkdtemp(join(tmpdir(), "mcode-abort-metadata-")); + try { + const localPath = join(dataDir, "local.png"); + await writeFile(localPath, "png"); + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const first = createTuiApp({ + runtime, + terminal: new FakeTerminal(), + version: "0.1.0", + dataDir, + workspaceDir: "/workspace", + }); + first.start(); + await first.ready; + await first.openSession("session-1"); + void first.commandFlow.submit("Mixed restore", { + sessionId: "session-1", + editor: { + schemaVersion: 1, + text: "Mixed restore", + cursor: 13, + pastes: [], + pasteCounter: 0, + }, + resources: { + attachments: [{ + type: "image", + fileName: "local.png", + mimeType: "image/png", + sizeBytes: 3, + filePath: localPath, + }], + }, + transportAttachments: [ + { type: "image", fileName: "local.png", mimeType: "image/png", filePath: localPath }, + { type: "image", fileName: "asset.png", mimeType: "image/png", assetId: "asset-1" }, + ], + transportContent: "Original hidden transport", + clientIntent: "plan-entry", + }); + await vi.waitFor(() => expect(first.controller.snapshot().status).toBe("running")); + await first.abortTurn(); + await vi.waitFor(() => expect(first.editor.getText()).toContain("Mixed restore")); + await first.stop(); + + const nextRuntime = createRuntime(); + const restored = createTuiApp({ + runtime: nextRuntime, + terminal: new FakeTerminal(), + version: "0.1.0", + dataDir, + workspaceDir: "/workspace", + }); + restored.start(); + await restored.ready; + await restored.openSession("session-1"); + await vi.waitFor(() => expect(restored.editor.getText()).toContain("Mixed restore")); + expect(restored.editor.captureDraft().attachmentPlaceholders).toHaveLength(1); + restored.editor.handleInput("\r"); + await vi.waitFor(() => expect(nextRuntime.sendMessage).toHaveBeenCalledTimes(1)); + const request = vi.mocked(nextRuntime.sendMessage).mock.calls[0][0]; + expect(request.content).toBe("Original hidden transport"); + expect(request.clientIntent).toBe("plan-entry"); + expect(request.attachments?.map((attachment) => attachment.local)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ filePath: localPath }), + expect.objectContaining({ assetId: "asset-1" }), + ]), + ); + await restored.stop(); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it.each([false, true])( + "preserves review metadata after relaunch only when unchanged (edited: %s)", + async (edited) => { + const dataDir = await mkdtemp(join(tmpdir(), "mcode-abort-review-")); + try { + const runtime = createRuntime(); + vi.mocked(runtime.sendMessage).mockImplementation( + async function* sendMessage(_req: SendMessageReq, signal?: AbortSignal) { + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + yield { type: "done" }; + }, + ); + vi.mocked(runtime.abortSession).mockResolvedValue(true); + const first = createTuiApp({ + runtime, + terminal: new FakeTerminal(), + version: "0.1.0", + dataDir, + workspaceDir: "/workspace", + }); + first.start(); + await first.ready; + await first.openSession("session-1"); + void first.commandFlow.submit( + "/review", + { + sessionId: "session-1", + editor: { schemaVersion: 1, text: "/review", cursor: 7, pastes: [], pasteCounter: 0 }, + resources: { attachments: [] }, + }, + { + forceMessage: true, + transportContent: "Please review my uncommitted changes.", + reviewRequest: { scope: "local_changes" }, + }, + ); + await vi.waitFor(() => expect(first.controller.snapshot().status).toBe("running")); + await first.abortTurn(); + await vi.waitFor(() => expect(first.editor.getText()).toBe("/review")); + await first.stop(); + + const nextRuntime = createRuntime(); + const restored = createTuiApp({ + runtime: nextRuntime, + terminal: new FakeTerminal(), + version: "0.1.0", + dataDir, + workspaceDir: "/workspace", + }); + restored.start(); + await restored.ready; + await restored.openSession("session-1"); + await vi.waitFor(() => expect(restored.editor.getText()).toBe("/review")); + if (edited) restored.editor.setText("Review src/foo.ts"); + restored.editor.handleInput("\r"); + await vi.waitFor(() => expect(nextRuntime.sendMessage).toHaveBeenCalledOnce()); + const request = vi.mocked(nextRuntime.sendMessage).mock.calls[0][0]; + expect(request.content).toBe( + edited ? "Review src/foo.ts" : "Please review my uncommitted changes.", + ); + expect(request.reviewRequest).toEqual( + edited ? undefined : { scope: "local_changes" }, + ); + await restored.stop(); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }, + ); + it("aborts session creation when the TUI stops during its first turn", async () => { const terminal = new FakeTerminal(); const runtime = createRuntime(); diff --git a/packages/tui/test/unit/tui/controller/run/turn-submission-retainer.test.ts b/packages/tui/test/unit/tui/controller/run/turn-submission-retainer.test.ts new file mode 100644 index 00000000..f3afceaf --- /dev/null +++ b/packages/tui/test/unit/tui/controller/run/turn-submission-retainer.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { TuiTurnSubmissionRetainer } from '../../../../../src/tui/controller/run/turn-submission-retainer.js'; +import type { TuiSubmissionSnapshot } from '../../../../../src/tui/features/composer/submission.js'; + +function snapshot(id: string): TuiSubmissionSnapshot { + return { + submissionId: id, + editor: { schemaVersion: 1, text: `text-${id}`, cursor: 6, pastes: [], pasteCounter: 0 }, + content: `text-${id}`, + attachments: [], + transportAttachments: [], + createdAtMs: 0, + }; +} + +describe('TuiTurnSubmissionRetainer', () => { + it('remembers and returns snapshots by turn id', () => { + const retainer = new TuiTurnSubmissionRetainer(); + retainer.remember('turn-1', snapshot('sub-1')); + expect(retainer.get('turn-1')?.submissionId).toBe('sub-1'); + expect(retainer.get('turn-2')).toBeUndefined(); + }); + + it('evicts the oldest entry beyond the cap and re-remembering bumps recency', () => { + const retainer = new TuiTurnSubmissionRetainer(); + for (let index = 0; index < 8; index += 1) { + retainer.remember(`turn-${index}`, snapshot(`sub-${index}`), index); + } + // Re-touch turn-3 so turn-1 becomes the oldest. + retainer.remember('turn-3', snapshot('sub-3'), 99); + retainer.remember('turn-new', snapshot('sub-new'), 100); + expect(retainer.get('turn-0')).toBeUndefined(); + expect(retainer.get('turn-1')).toBeDefined(); + expect(retainer.get('turn-3')?.submissionId).toBe('sub-3'); + expect(retainer.get('turn-new')).toBeDefined(); + }); + + it('drops individual entries and clears everything', () => { + const retainer = new TuiTurnSubmissionRetainer(); + retainer.remember('turn-1', snapshot('sub-1')); + retainer.remember('turn-2', snapshot('sub-2')); + retainer.drop('turn-1'); + expect(retainer.get('turn-1')).toBeUndefined(); + retainer.clear(); + expect(retainer.get('turn-2')).toBeUndefined(); + }); +}); diff --git a/release/public-source.json b/release/public-source.json index 63804a38..d795f506 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -2994,6 +2994,7 @@ "packages/tui/src/tui/controller/run/queue-flow.ts", "packages/tui/src/tui/controller/run/runtime-turn-settlement.ts", "packages/tui/src/tui/controller/run/turn-settlement.ts", + "packages/tui/src/tui/controller/run/turn-submission-retainer.ts", "packages/tui/src/tui/controller/runtime/event-stream-reconciliation.ts", "packages/tui/src/tui/controller/runtime/runtime-error-presentation.ts", "packages/tui/src/tui/controller/runtime/runtime-event-flow.ts", @@ -3294,6 +3295,7 @@ "packages/tui/test/unit/tui/controller/product/feature-flow.test.ts", "packages/tui/test/unit/tui/controller/product/model-state.test.ts", "packages/tui/test/unit/tui/controller/projection/visible-presentation.test.ts", + "packages/tui/test/unit/tui/controller/run/turn-submission-retainer.test.ts", "packages/tui/test/unit/tui/controller/side-session-flow.test.ts", "packages/tui/test/unit/tui/features/composer/attachments.test.ts", "packages/tui/test/unit/tui/features/settings/hotkeys-picker.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index c8a8090f..40ae8d55 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -149,6 +149,7 @@ "packages/tui/test/unit/tui/controller/product/chrome-flow.test.ts", "packages/tui/test/unit/tui/controller/product/command-flow.test.ts", "packages/tui/test/unit/tui/controller/product/feature-flow.test.ts", + "packages/tui/test/unit/tui/controller/run/turn-submission-retainer.test.ts", "packages/tui/test/unit/tui/features/settings/hotkeys-picker.test.ts", "packages/tui/test/unit/incident-reporter-privacy.test.ts", "packages/config/test/config-file-permissions.test.ts", From b7456eb92b7dfefdf54378b64d21c7363fdab4c9 Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:17:49 +0800 Subject: [PATCH 10/15] docs: define pull request label usage (#326) --- .github/PULL_REQUEST_TEMPLATE.md | 2 ++ CONTRIBUTING.md | 2 ++ docs/maintainers.md | 14 ++++++++++++++ 3 files changed, 18 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c2c5eccd..d66d282d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,6 +4,8 @@ Describe the user-visible problem and resulting behavior. Link a public issue when applicable. +- PR labels: change type (`bug`, `enhancement`, `documentation` or `dependencies`) and affected product (`cli`, `tui` together with `cli`, or `desktop`) where applicable; see the [label guide](https://github.com/MiniMax-AI/minimax-code/blob/main/docs/maintainers.md#pull-request-labels). + ## Validation - Checks run and results (include the revision/profile where relevant): diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 90e07f37..cddfe258 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,6 +6,8 @@ Thanks for your interest in contributing. For now, we only accept code and docum Repository collaborators should submit pull requests from feature branches; do not push directly to the default branch. Describe user-visible changes, checks you ran, live-service or platform validation you did not run, and documentation impact. Preserve real author identities and existing copyright notices. +Apply the relevant change-type and product labels using the [pull request label guide](docs/maintainers.md#pull-request-labels). Add `perf:full` separately when the [performance rules](#performance-checks) require the full suite. + ## Maintainers and review See [Maintainers](docs/maintainers.md) for review ownership, independent approval, security/release routing and the public-to-internal contribution flow. The [PR template](.github/PULL_REQUEST_TEMPLATE.md) records checks, untested boundaries and permission to contribute under the existing applicable licenses. CODEOWNERS routes reviews; required checks and approvals must also be enabled in repository settings. diff --git a/docs/maintainers.md b/docs/maintainers.md index 23b70982..11098787 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -34,6 +34,20 @@ Route security reports through [Security](../SECURITY.md). Do not ask for secret This small label set draws on the type and information-request handling in [VS Code's triage guide](https://github.com/microsoft/vscode/wiki/Issues-Triaging) and the `needs-triage`/`needs-info` states used by [Ruff](https://github.com/astral-sh/ruff/labels). Contribution eligibility follows this repository's policy. +## Pull request labels + +Use the existing labels to describe a PR on three independent axes: + +| Axis | Labels and meaning | +| --- | --- | +| Change type | `bug`: fixes a malfunction; `enhancement`: adds or improves behavior, including performance; `documentation`: primarily changes documentation; `dependencies`: updates dependencies | +| Product | `cli`: changes the standalone CLI, its runtime or its build; `tui`: changes interactive-terminal behavior and is used together with `cli`; `desktop`: changes the Desktop app | +| Verification | `perf:full`: selects the full performance suite for changes covered by the [performance rules](../CONTRIBUTING.md#performance-checks) | + +Add one primary change-type label when a PR fits one of these types. Leave it unset for repository maintenance that fits none of them. Add each affected product label; leave product labels unset for repository-wide policy or tooling changes with no specific product impact. Classify the behavior and scope of the PR, not just its title or changed paths. The author proposes the labels and the reviewer checks them when the scope changes. + +Keep `perf:full` until merge when the performance rules require it, including for dependency updates that affect the covered runtime paths. It is a verification trigger, not a change type or a statement that the PR is ready to merge. Use GitHub's draft state, reviewers, approvals and checks for review progress. `needs-triage` and `needs-info` remain issue-triage labels. + ## Review and merge 1. Repository collaborators open an issue for substantial scope or submit a focused feature-branch PR. Other users may open issues to discuss ideas and proposals. Security details follow the private reporting process. From bb3972068399c67d6dab56efac6aa0597cfc271a Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:14:56 +0800 Subject: [PATCH 11/15] fix(tui): rebuild chat after closing full viewport interactions (#328) --- docs/tui-capabilities.md | 6 +- packages/tui/src/tui/app-composition.ts | 2 +- packages/tui/src/tui/app.ts | 6 +- .../tui/src/tui/shell/interaction-surface.ts | 6 +- packages/tui/test/unit/tui-app.test.ts | 94 +++++++++++++++++++ .../tui/test/unit/tui-surface-host.test.ts | 25 +++++ 6 files changed, 132 insertions(+), 7 deletions(-) diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 92baff9e..61348fe7 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -85,8 +85,10 @@ and initial prompt are not applied. In regular mode, independent feature panels occupy the complete visible terminal area, including short Rewind previews and scope pickers. Closing a panel restores -the current conversation. When running content shrinks entirely within the current -screen, the renderer keeps native scrollback and the Composer position stable. +the current conversation. Closing a full-viewport interaction rebuilds the chat +screen so its temporary rows do not leave a large blank area above the conversation. +When running content shrinks entirely within the current screen, the renderer +keeps native scrollback and the Composer position stable. Freed rows temporarily remain blank at the top of the active screen and subsequent output reuses them. This avoids resetting the host's scroll position when a turn finishes. Redundant resize notifications with unchanged dimensions do not rebuild diff --git a/packages/tui/src/tui/app-composition.ts b/packages/tui/src/tui/app-composition.ts index 6273fc7d..2a823ca6 100644 --- a/packages/tui/src/tui/app-composition.ts +++ b/packages/tui/src/tui/app-composition.ts @@ -425,7 +425,7 @@ export function createTuiApplicationSurface(options: { readonly liveRunId: (snapshot?: TuiChatSnapshot) => string | undefined; readonly shouldResumeDraftAfterLogin: () => boolean; readonly isActive: () => boolean; - readonly requestInteractionRender: () => void; + readonly requestInteractionRender: (rebuild?: boolean) => void; readonly mode: () => TuiMode; readonly switchMode: (mode: TuiMode) => boolean; readonly chatMode: TuiMode; diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index 914d96e9..7a8b1601 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -820,8 +820,10 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { activity, composer, }); - function requestInteractionRender(): void { - if (started && !stopped) tui.requestImmediateRender(); + function requestInteractionRender(rebuild = false): void { + if (!started || stopped) return; + if (rebuild) tui.requestRender(true); + else tui.requestImmediateRender(); } const controllerReady = controller.initialize(); const ready = controllerReady.then(async () => { diff --git a/packages/tui/src/tui/shell/interaction-surface.ts b/packages/tui/src/tui/shell/interaction-surface.ts index 288340bb..334ce475 100644 --- a/packages/tui/src/tui/shell/interaction-surface.ts +++ b/packages/tui/src/tui/shell/interaction-surface.ts @@ -8,7 +8,7 @@ export class TuiInteractionSurface { constructor( private readonly host: TuiInlinePanelHost, private readonly surfaces: TuiSurfaceHost, - private readonly requestRender: () => void, + private readonly requestRender: (rebuild?: boolean) => void, private readonly onActiveChanged?: (active: boolean) => void, private readonly followFullscreenBottom?: () => void, ) {} @@ -38,11 +38,13 @@ export class TuiInteractionSurface { close(panel?: Component): boolean { if (!this.host.isActive(panel)) return false; + const rebuild = this.surfaces.getChatMode() === 'regular' && this.host.fullscreenViewport; if (this.layer && !this.layer.close()) return false; this.layer = undefined; if (!this.host.close(panel)) return false; this.onActiveChanged?.(false); - this.requestRender(); + if (rebuild) this.requestRender(true); + else this.requestRender(); return true; } diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index ab081afd..8b92d10c 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -189,6 +189,13 @@ class FakeTerminal implements Terminal { } } +// Apple Terminal can save ED 2 clears into scrollback before the renderer clears history. +class ClearToScrollbackTerminal extends VirtualTerminal { + override write(data: string): void { + super.write(data.replaceAll("\x1b[2J", `\x1b[${this.rows};1H${"\r\n".repeat(this.rows)}\x1b[2J`)); + } +} + function renderTerminalViewport( app: ReturnType, terminal: FakeTerminal, @@ -6041,6 +6048,93 @@ describe("createTuiApp", () => { await app.stop(); }); + it("does not push the conversation downward after closing /usage", async () => { + const terminal = new VirtualTerminal(80, 50); + const runtime = createRuntime(); + vi.mocked(runtime.getAccountStatus).mockResolvedValue({ + status: "ready", + authMode: "managed-login", + managedTokenPresent: true, + modelSource: "token-plan", + tokenPlanQuotaState: "available", + tokenPlanSummary: { tier: "Ultra Plan", creditBalance: "100" }, + tokenPlanQuota: { + fiveHour: { remainingPercent: 75, unlimited: false }, + weekly: { remainingPercent: 50, unlimited: false }, + video: { remainingCount: 5, totalCount: 5, unlimited: false }, + }, + warnings: [], + }); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + try { + await app.ready; + for (let index = 0; index < 2; index++) await app.submit(`Message ${index}`); + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).toContain("Message 1"); + const firstContentRowBefore = terminal.getViewport().findIndex((line) => line.trim().length > 0); + + await app.submit("/usage"); + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).toContain("Usage"); + + terminal.sendInput("\x1b"); + app.tui.renderNow(); + await terminal.flush(); + const viewport = terminal.getViewport().join("\n"); + expect(viewport).toContain("Message 1"); + expect(viewport).toContain("Message"); + expect(app.tui.render(80).findIndex((line) => line.trim().length > 0)).toBe(firstContentRowBefore); + expect(terminal.getViewport().findIndex((line) => line.trim().length > 0)).toBe(firstContentRowBefore); + } finally { + await app.stop(); + } + }); + + it.each([ + ['xterm', VirtualTerminal], + ['clear-to-scrollback terminal', ClearToScrollbackTerminal], + ] as const)('restores unique long history after /usage on %s', async (_name, Terminal) => { + const terminal = new Terminal(80, 24); + const app = createTuiApp({ + runtime: createRuntime(), + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + try { + await app.ready; + for (let index = 0; index < 12; index++) await app.submit(`Message ${index}`); + await app.submit("/usage"); + app.tui.renderNow(); + await terminal.flush(); + expect(terminal.getViewport().join("\n")).toContain("Usage"); + + terminal.sendInput("\x1b"); + app.tui.renderNow(); + await terminal.flush(); + const history = terminal.getScrollBuffer(); + for (let index = 0; index < 12; index++) { + expect(history.filter((line) => line.trimEnd().endsWith(`› Message ${index}`))).toHaveLength(1); + } + expect(history.join("\n")).not.toContain("Session usage"); + expect(terminal.getViewport().join("\n")).toContain("Ask Mcode to do anything"); + expect(terminal.getViewport().join("\n")).toContain("/workspace"); + } finally { + await app.stop(); + } + }); + it("uses public Runtime owners for Session inspection commands", async () => { const terminal = new FakeTerminal(); terminal.rows = 40; diff --git a/packages/tui/test/unit/tui-surface-host.test.ts b/packages/tui/test/unit/tui-surface-host.test.ts index da7e6925..fbfc2b54 100644 --- a/packages/tui/test/unit/tui-surface-host.test.ts +++ b/packages/tui/test/unit/tui-surface-host.test.ts @@ -70,6 +70,31 @@ function createRecordingPresenter(): { } describe('TuiSurfaceHost', () => { + it.each([ + { mode: 'regular', fullscreenViewport: true, rebuild: true }, + { mode: 'regular', fullscreenViewport: false, rebuild: false }, + { mode: 'fullscreen', fullscreenViewport: true, rebuild: false }, + ] as const)('requests rebuild on interaction close only for a regular full-viewport panel ($mode, $fullscreenViewport)', ({ mode, fullscreenViewport, rebuild }) => { + const inline = new TuiInlinePanelHost(); + const host = createSurfaceHost({ + chat: { component: inline, focus: component([]) }, + chatMode: mode, + mode: () => mode, + setFocus: vi.fn(), + requestRender: vi.fn(), + }); + const requestRender = vi.fn(); + const interaction = new TuiInteractionSurface(inline, host, requestRender); + const panel = { ...component(['inspection']), fullscreenViewport }; + + interaction.show(panel); + requestRender.mockClear(); + expect(interaction.close(panel)).toBe(true); + expect(requestRender).toHaveBeenCalledOnce(); + if (rebuild) expect(requestRender).toHaveBeenCalledWith(true); + else expect(requestRender).toHaveBeenCalledWith(); + }); + it.each(['regular', 'fullscreen'] as const)( 'passes current terminal size to leaf features in %s', (mode) => { From 7f2fe5230a30cb840e12d863e56d61032508f89f Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:15:13 +0800 Subject: [PATCH 12/15] fix(tui): clear previous run duration on new turn (#327) * fix(tui): clear previous run duration on new turn * fix(tui): dismiss stale duration when a message appears --- .../tui/src/tui/controller/chat-controller.ts | 5 +++ .../controller/projection/turn-projection.ts | 13 +++++- .../tui/test/unit/tui-chat-controller.test.ts | 41 +++++++++++++++++++ .../tui/test/unit/tui-shell-block.test.ts | 25 +++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/tui/controller/chat-controller.ts b/packages/tui/src/tui/controller/chat-controller.ts index 0d970508..4420801a 100644 --- a/packages/tui/src/tui/controller/chat-controller.ts +++ b/packages/tui/src/tui/controller/chat-controller.ts @@ -380,6 +380,8 @@ export class TuiChatController { updatedAtMs: timestamp, }); } + // The user turn is already visible while the asynchronous login preflight runs. + this.turnProjection.removePreviousTerminalDuration(turnId); this.updateState({ activeTurnId: turnId, lastSettledTurn: undefined, @@ -667,6 +669,9 @@ export class TuiChatController { attachments: readonly TranscriptAttachment[] = [], userPresentation?: TranscriptUserPresentation, ): void { + if (userPresentation !== 'pending-steer') { + this.turnProjection.removePreviousTerminalDuration(); + } this.turnProjection.projectOptimisticUserMessage( requestId, content, diff --git a/packages/tui/src/tui/controller/projection/turn-projection.ts b/packages/tui/src/tui/controller/projection/turn-projection.ts index 2b4a6096..b036d5db 100644 --- a/packages/tui/src/tui/controller/projection/turn-projection.ts +++ b/packages/tui/src/tui/controller/projection/turn-projection.ts @@ -59,7 +59,8 @@ export class TuiTurnProjection { beginTurn(turnId: string, timestamp: number): void { let changed = this.todoProjection.clearSettled(); - // Shell results are one-time local feedback; history refreshes retain ephemeral cells. + changed = this.removePreviousTerminalDuration(turnId) || changed; + // One-time feedback survives history refreshes, so dismiss it when a new run starts. for (const cell of this.transcript.snapshot()) { if ( cell.kind === 'shell' && @@ -73,6 +74,16 @@ export class TuiTurnProjection { this.liveProjection.beginTurn(turnId, timestamp); } + removePreviousTerminalDuration(turnId?: string): boolean { + let changed = false; + for (const cell of this.transcript.snapshot()) { + if (cell.kind === 'turn-duration' && (turnId === undefined || cell.turnId !== turnId)) { + changed = this.transcript.remove(cell.id) || changed; + } + } + return changed; + } + projectOptimisticUserMessage( requestId: string, content: string, diff --git a/packages/tui/test/unit/tui-chat-controller.test.ts b/packages/tui/test/unit/tui-chat-controller.test.ts index 524369b9..922c4e5f 100644 --- a/packages/tui/test/unit/tui-chat-controller.test.ts +++ b/packages/tui/test/unit/tui-chat-controller.test.ts @@ -266,7 +266,22 @@ describe('TuiChatController', () => { onUserSubmissionProjected, }); + transcript.upsert({ + id: 'turn-duration:previous-turn', + kind: 'turn-duration', + status: 'cancelled', + content: '', + durationMs: 12_000, + turnId: 'previous-turn', + ephemeral: true, + createdAtMs: 80, + updatedAtMs: 80, + }); + const view = new TranscriptView(transcript); + expect(view.render(80).join('\n')).toContain('Interrupted after 12s'); controller.projectOptimisticUserMessage('submission-1', 'Show this immediately', 90); + expect(view.render(80).join('\n')).not.toContain('Interrupted after 12s'); + expect(transcript.get('turn-duration:previous-turn')).toBeUndefined(); const sending = controller.submit('Show this immediately', { optimisticRequestId: 'submission-1', }); @@ -279,6 +294,8 @@ describe('TuiChatController', () => { }); expect(transcript.get('optimistic:user:submission-1')).toBeUndefined(); expect(transcript.snapshot().filter((cell) => cell.kind === 'user')).toHaveLength(1); + expect(view.render(80).join('\n')).not.toContain('Interrupted after 12s'); + expect(transcript.get('turn-duration:previous-turn')).toBeUndefined(); expect(onUserSubmissionProjected).toHaveBeenCalledOnce(); expect(runtime.createSession).not.toHaveBeenCalled(); expect(runtime.sendMessage).not.toHaveBeenCalled(); @@ -287,6 +304,30 @@ describe('TuiChatController', () => { await expect(sending).resolves.toBe('succeeded'); }); + it('keeps the previous duration while projecting a steer for the current turn', () => { + const transcript = new TranscriptStore(); + transcript.upsert({ + id: 'turn-duration:previous-turn', + kind: 'turn-duration', + status: 'cancelled', + content: '', + durationMs: 12_000, + turnId: 'previous-turn', + ephemeral: true, + createdAtMs: 80, + updatedAtMs: 80, + }); + const controller = new ProductionTuiChatController({ + runtime: {} as never, + transcript, + workspaceDir: '/workspace', + }); + + controller.projectOptimisticUserMessage('steer-1', 'Continue this turn', 90, [], 'pending-steer'); + + expect(transcript.get('turn-duration:previous-turn')).toBeDefined(); + }); + it('detaches the foreground observer when switching Sessions without aborting the Runtime turn', async () => { let releaseTurn: (() => void) | undefined; const runtime = { diff --git a/packages/tui/test/unit/tui-shell-block.test.ts b/packages/tui/test/unit/tui-shell-block.test.ts index 3e1d30be..4fe2e18c 100644 --- a/packages/tui/test/unit/tui-shell-block.test.ts +++ b/packages/tui/test/unit/tui-shell-block.test.ts @@ -22,6 +22,31 @@ function shellCell() { } describe('Shell transcript block', () => { + it.each(['succeeded', 'cancelled'] as const)( + 'dismisses the previous %s turn duration when the next turn starts', + (status) => { + const transcript = new TranscriptStore(); + const view = new TranscriptView(transcript); + const onChange = vi.fn(); + const projection = new TuiTurnProjection({ transcript, now: () => 10, onChange }); + projection.recordTerminalDuration('previous-turn', status, 12_000); + const label = status === 'cancelled' ? 'Interrupted after 12s' : 'Completed in 12s'; + expect(stripAnsi(view.render(80).join('\n'))).toContain(label); + + transcript.replaceDurableProjection(() => projection.hydrateHistory([])); + expect(stripAnsi(view.render(80).join('\n'))).toContain(label); + + onChange.mockClear(); + projection.beginTurn('next-turn', 20); + expect(transcript.get('turn-duration:previous-turn')).toBeUndefined(); + expect(stripAnsi(view.render(80).join('\n'))).not.toContain(label); + expect(onChange).toHaveBeenCalledOnce(); + + projection.recordTerminalDuration('next-turn', 'succeeded', 3_000); + expect(stripAnsi(view.render(80).join('\n'))).toContain('Completed in 3s'); + }, + ); + it.each([ { exitCode: 0, cancelled: false, status: 'succeeded' }, { exitCode: 7, cancelled: false, status: 'failed' }, From cf66c7fdece9d49b3f7943cbcb29f7d31fb203de Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:19:46 +0800 Subject: [PATCH 13/15] fix(tui): dismiss interrupted duration on submit frame (#329) Render the projected submission synchronously so the prior interrupted footer is erased before input returns. Queue the normal input render before dispatch so a synchronous render coalesces instead of doing a second pass. --- packages/tui/src/tui/app.ts | 2 +- .../tui/src/tui/engine/LOCAL_CHANGES.json | 8 +- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 8 ++ packages/tui/src/tui/engine/tui.ts | 5 +- packages/tui/test/unit/tui-app.test.ts | 91 +++++++++++++++++++ .../test/unit/tui-engine-local-deltas.test.ts | 46 ++++++++++ 6 files changed, 154 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/tui/app.ts b/packages/tui/src/tui/app.ts index 7a8b1601..ab9207c9 100644 --- a/packages/tui/src/tui/app.ts +++ b/packages/tui/src/tui/app.ts @@ -131,7 +131,7 @@ export function createTuiApp(options: CreateTuiAppOptions): TuiApp { onTodoChange: (items) => tasks.setItems(items), onUserSubmissionProjected: () => { codexHandoffFlow?.dismiss(); - if (started && !stopped) tui.requestImmediateRender(); + if (started && !stopped) tui.renderNow(); }, onSessionLifecycle: (sessionId) => { if (!sessionId) bashFlow.clearUnboundContext(); diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index cfae2635..5c9eedcd 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -171,10 +171,10 @@ }, { "path": "tui.ts", - "currentSha256": "0dee17d791b8f02fc26b28fe32506b036eb82761572dbcd378f2cac782a01798", - "changeIds": ["L005", "L015", "L023", "L027", "L028", "L031"], - "reason": "Keep strict TypeScript fixes, expose Pi's existing immediate scheduler as a non-destructive product interaction contract, and dispatch the input left over after terminal color sequences are removed. 提供 resize hook 及焦点输入过滤 独立面板声明键盘分页归属。", - "behaviorImpact": "Urgent product interactions render immediately without resetting differential state or clearing native scrollback, and a coalesced color answer no longer discards the keystrokes sharing its chunk. 焦点先交给 viewport listener,不进入编辑器。 handlesViewportKeys 为 true 时,fullscreen 分页交给焦点面板;默认仍由外层视口处理。" + "currentSha256": "cea30262a99f7e22dc03c51d4f6dfb9a949aee183c7da1d6138c5eeff77a7ea3", + "changeIds": ["L005", "L015", "L023", "L027", "L028", "L031", "L040"], + "reason": "Keep strict TypeScript fixes, expose Pi's existing immediate scheduler as a non-destructive product interaction contract, dispatch the input left over after terminal color sequences are removed, and coalesce synchronous submission renders. 提供 resize hook 及焦点输入过滤 独立面板声明键盘分页归属。", + "behaviorImpact": "Urgent product interactions render immediately without resetting differential state or clearing native scrollback, and a coalesced color answer no longer discards the keystrokes sharing its chunk. A synchronous submission frame dismisses the previous interrupted footer without a second input render. 焦点先交给 viewport listener,不进入编辑器。 handlesViewportKeys 为 true 时,fullscreen 分页交给焦点面板;默认仍由外层视口处理。" }, { "path": "utils.ts", diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index 88f5ab2f..e9c7d159 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -154,3 +154,11 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Evidence: local-delta tests exercise xterm and a clear-to-scrollback host model, covering historical style changes, simultaneous growth, short-document shrink, subsequent differential output, host scrolling and resize preview/replay. Native Apple Terminal replay of synthetic renderer output reproduces duplicate rows before the fix and preserves the exact document afterward. - Boundary: native replay covers synthetic output, not every live-model interaction or other terminal emulator. - Removal condition: the selected Pi baseline supplies equivalent in-place viewport erasure without retaining stale rows in native history. + +## L040: Coalesce synchronous submission renders + +- Product contract: sending the next message removes the previous interrupted duration from the physical terminal before the input callback returns, without rendering the same frame twice. +- Minimal difference: queue the normal immediate input render before dispatching a focused component's key. A synchronous `renderNow()` during submission clears that queued request; ordinary keys still render on the next tick. +- User impact: the previous interrupted footer disappears with the submitted message, and the extra no-op render after Enter is avoided. +- Evidence: `tui-app.test.ts` checks every presented frame across interrupt and resend; `tui-engine-local-deltas.test.ts` checks that a synchronous input render has no second pass. +- Removal condition: the selected Pi baseline coalesces synchronous input renders while preserving immediate key rendering. diff --git a/packages/tui/src/tui/engine/tui.ts b/packages/tui/src/tui/engine/tui.ts index f3fbe574..89cd0bb3 100644 --- a/packages/tui/src/tui/engine/tui.ts +++ b/packages/tui/src/tui/engine/tui.ts @@ -904,10 +904,13 @@ export abstract class TuiBase extends Container implements TUI { if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) { return; } - this.focusedComponent.handleInput(data); // Keyboard input is latency-sensitive. Avoid the throttled timer path, // where even setTimeout(0) can take a full 16 ms tick on Windows. this.requestImmediateRender(); + // A submission may render synchronously to dismiss a previous turn's + // footer. In that case renderNow clears this pending request, avoiding + // a second full render on the next tick. + this.focusedComponent.handleInput(data); } } diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index 8b92d10c..9a922ceb 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -11654,6 +11654,97 @@ describe("createTuiApp", () => { await app.stop(); }); + it("clears the interrupted duration before the next message input returns", async () => { + const terminal = new FakeTerminal(); + terminal.rows = 10; + const screen = new VirtualTerminalScreen(terminal.columns, terminal.rows); + const frames: string[] = []; + const write = terminal.write.bind(terminal); + terminal.write = (data) => { + write(data); + // Model terminals that preserve an erased screen in native scrollback. + screen.feed( + data.replaceAll( + "\x1b[2J", + `\x1b[${terminal.rows};1H${"\r\n".repeat(terminal.rows)}\x1b[2J`, + ), + ); + if (data.includes("\x1b[?2026l")) frames.push(screen.text()); + }; + const runtime = createRuntime(); + let finishResend: (() => void) | undefined; + let sendCount = 0; + vi.mocked(runtime.sendMessage).mockImplementation( + async function* (_request, signal) { + sendCount += 1; + if (sendCount <= 10) { + yield { type: "delta", content: `Preload reply ${sendCount}` }; + yield { type: "done" }; + return; + } + if (sendCount === 11) { + yield { type: "delta", content: "Partial response" }; + await new Promise((resolve) => { + signal?.addEventListener("abort", resolve, { once: true }); + }); + } else { + await new Promise((resolve) => { + finishResend = resolve; + }); + } + if (sendCount === 12) yield { type: "delta", content: "Agent reply" }; + yield { type: "done" }; + }, + ); + const app = createTuiApp({ + runtime, + terminal, + version: "0.1.0", + workspaceDir: "/workspace", + }); + + app.start(); + try { + await app.ready; + for (let i = 0; i < 10; i++) { + await app.submit(`Preload ${i}`); + app.tui.renderNow(); + } + terminal.input?.("First request"); + terminal.input?.("\r"); + await vi.waitFor(() => expect(app.controller.snapshot().status).toBe("running")); + terminal.input?.("\x1b"); + await vi.waitFor(() => + expect(app.tui.render(80).join("\n")).toContain("Partial response"), + ); + await vi.waitFor(() => + expect( + app.transcript + .snapshot() + .some((cell) => cell.kind === "turn-duration" && cell.status === "cancelled"), + ).toBe(true), + ); + app.tui.renderNow(); + expect(screen.text()).toContain("Interrupted after"); + + app.tui.renderNow(); + frames.length = 0; + terminal.input?.("Second request"); + terminal.input?.("\r"); + expect(screen.text()).not.toContain("Interrupted after"); + expect(app.tui.render(80).join("\n")).not.toContain("Interrupted after"); + await vi.waitFor(() => expect(runtime.sendMessage).toHaveBeenCalledTimes(12)); + app.tui.renderNow(); + + expect(frames.length).toBeGreaterThan(0); + expect(frames.filter((frame) => frame.includes("Interrupted after"))).toEqual([]); + } finally { + finishResend?.(); + await app.stop(); + screen.dispose(); + } + }); + it("returns a thinking-only turn's prompt to the composer on abort", async () => { const terminal = new FakeTerminal(); const runtime = createRuntime(); diff --git a/packages/tui/test/unit/tui-engine-local-deltas.test.ts b/packages/tui/test/unit/tui-engine-local-deltas.test.ts index d61cf233..da018735 100644 --- a/packages/tui/test/unit/tui-engine-local-deltas.test.ts +++ b/packages/tui/test/unit/tui-engine-local-deltas.test.ts @@ -56,6 +56,52 @@ class MutableLines implements Component { } describe('MCode Pi Engine local deltas', () => { + it('does not replay a submitted input frame after a synchronous render', async () => { + const terminal = new RecordingVirtualTerminal(60, 12); + const tui = new TuiMainScreen(terminal); + let renderCount = 0; + let content = 'old footer'; + let updateAfterSyncRender = false; + const component: Component = { + render: () => { + renderCount += 1; + return [content]; + }, + invalidate: () => undefined, + handleInput: () => { + content = 'new message'; + tui.renderNow(); + if (updateAfterSyncRender) { + content = 'later status'; + tui.requestRender(); + } + }, + }; + tui.addChild(component); + tui.setFocus(component); + tui.start(); + tui.renderNow(); + await new Promise((resolve) => setImmediate(resolve)); + renderCount = 0; + + terminal.sendInput('\r'); + expect(renderCount).toBe(1); + await new Promise((resolve) => setImmediate(resolve)); + expect(renderCount).toBe(1); + await terminal.flush(); + expect(terminal.getViewport().join('\n')).toContain('new message'); + + updateAfterSyncRender = true; + renderCount = 0; + terminal.sendInput('\r'); + expect(renderCount).toBe(1); + await new Promise((resolve) => setImmediate(resolve)); + expect(renderCount).toBe(2); + await terminal.flush(); + expect(terminal.getViewport().join('\n')).toContain('later status'); + tui.stop(); + }); + describe.each([ ['xterm', RecordingVirtualTerminal], ['clear-to-scrollback host', ClearToScrollbackTerminal], From 6dbc3e8f12619d96493c43e6c0354a8f2520d4ff Mon Sep 17 00:00:00 2001 From: weekbin Date: Wed, 23 Sep 2026 14:22:39 +0800 Subject: [PATCH 14/15] chore(security): allowlist the redaction test fixture in the history scan The Release audit workflow scans complete history with gitleaks and reported one finding: packages/webui/test/trajectory/store.test.mjs asserts that redactText() scrubs 'api_key=abcdef123456', so the fake value is a fixture, not a credential. It originates from commit a10f820 (2026-09-21) and is therefore already in this fork's history; the workflow had simply never run here before, so it surfaced for the first time on this PR. Anchored to the exact match text, mirroring the existing synthetic-marker allowlist for the local-runtime privacy test. Verified with `gitleaks git --log-opts=--all` and the archive/dir scan: no leaks found. --- .gitleaks.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitleaks.toml b/.gitleaks.toml index d749e8f0..eb0ce63c 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -74,3 +74,10 @@ condition = "AND" paths = ['''(^|/)packages/local-runtime/test/unit/error-reporting-privacy\.test\.ts$'''] regexTarget = "match" regexes = ['''^SECRET = 'SYNTHETIC_PRIVATE_4cd7'$'''] + +[[rules.allowlists]] +description = "Redaction regression fixture asserting api_key values are scrubbed, including historical commits" +condition = "AND" +paths = ['''(^|/)packages/webui/test/trajectory/store\.test\.mjs$'''] +regexTarget = "match" +regexes = ['''^api_key=abcdef123456'$'''] From 944e8741c33fda5a0b7924937ea6d0e833f0d50f Mon Sep 17 00:00:00 2001 From: DanielWalnut <45447813+hetaoBackend@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:29:34 +0800 Subject: [PATCH 15/15] fix(tui): switch theme appearance with arrow keys (#330) --- .../src/tui/features/settings/theme-picker.ts | 28 +++++++++++-------- .../features/settings/theme-picker.test.ts | 27 ++++++++++++++---- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/packages/tui/src/tui/features/settings/theme-picker.ts b/packages/tui/src/tui/features/settings/theme-picker.ts index 723f874b..1f24db4f 100644 --- a/packages/tui/src/tui/features/settings/theme-picker.ts +++ b/packages/tui/src/tui/features/settings/theme-picker.ts @@ -11,6 +11,9 @@ import type { export type TuiThemeAppearanceChoice = 'auto' | TuiResolvedAppearance; +const APPEARANCE_CHOICES = ['light', 'auto', 'dark'] as const satisfies + readonly TuiThemeAppearanceChoice[]; + export interface TuiThemePickerOptions { readonly themes: readonly TuiThemeDefinition[]; readonly currentThemeId: string; @@ -64,9 +67,10 @@ export class TuiThemePicker implements Component { this.selectedIndex = (this.selectedIndex - 1 + themes.length) % themes.length; } else if (keys.matches(data, 'tui.select.down')) { this.selectedIndex = (this.selectedIndex + 1) % themes.length; - } else if (matchesKey(data, 'a')) { - this.appearance = cycleAppearance(this.appearance); - this.options.setAppearance(this.appearance); + } else if (matchesKey(data, 'left')) { + this.shiftAppearance(-1); + } else if (matchesKey(data, 'right')) { + this.shiftAppearance(1); } else { return; } @@ -162,6 +166,14 @@ export class TuiThemePicker implements Component { return this.appearance === 'auto' ? this.options.currentAppearance : this.appearance; } + private shiftAppearance(step: -1 | 1): void { + const index = APPEARANCE_CHOICES.indexOf(this.appearance); + const next = APPEARANCE_CHOICES[Math.max(0, Math.min(index + step, APPEARANCE_CHOICES.length - 1))]!; + if (next === this.appearance) return; + this.appearance = next; + this.options.setAppearance(next); + } + private restore(): void { const theme = this.options.themes.find((candidate) => candidate.id === this.originalThemeId); if (theme) this.options.preview(this.originalThemeId); @@ -193,12 +205,6 @@ export class TuiThemePicker implements Component { } } -function cycleAppearance(current: TuiThemeAppearanceChoice): TuiThemeAppearanceChoice { - if (current === 'auto') return 'light'; - if (current === 'light') return 'dark'; - return 'auto'; -} - function appearanceLabel( choice: TuiThemeAppearanceChoice, detected: TuiResolvedAppearance, @@ -243,6 +249,6 @@ const CURRENT_BADGE = ' current '; function footerText(width: number, busy: boolean): string { if (busy) return 'Saving…'; return width >= 64 - ? '↑↓ preview · a appearance · Enter save · Esc cancel' - : '↑↓ · a · Enter · Esc'; + ? '↑↓ theme · ←→ appearance · Enter save · Esc cancel' + : '↑↓ ←→ · Enter · Esc'; } diff --git a/packages/tui/test/unit/tui/features/settings/theme-picker.test.ts b/packages/tui/test/unit/tui/features/settings/theme-picker.test.ts index e7a2f07f..072e540d 100644 --- a/packages/tui/test/unit/tui/features/settings/theme-picker.test.ts +++ b/packages/tui/test/unit/tui/features/settings/theme-picker.test.ts @@ -74,15 +74,30 @@ describe('TuiThemePicker', () => { expect(preview).toHaveBeenLastCalledWith('minimax'); }); - it('cycles the appearance with the a key', () => { + it('selects light, auto, and dark with the left and right arrows', () => { const { picker, setAppearance } = build(); - picker.handleInput('a'); + picker.handleInput('\u001b[D'); expect(setAppearance).toHaveBeenLastCalledWith('light'); - picker.handleInput('a'); + picker.handleInput('\u001b[D'); + expect(setAppearance).toHaveBeenCalledTimes(1); + + picker.handleInput('\u001b[C'); + expect(setAppearance).toHaveBeenLastCalledWith('auto'); + picker.handleInput('\u001b[C'); expect(setAppearance).toHaveBeenLastCalledWith('dark'); + picker.handleInput('\u001b[C'); + expect(setAppearance).toHaveBeenCalledTimes(3); + }); + + it('ignores a as an appearance shortcut', () => { + const { picker, preview, setAppearance, requestRender } = build(); + picker.handleInput('a'); - expect(setAppearance).toHaveBeenLastCalledWith('auto'); + + expect(setAppearance).not.toHaveBeenCalled(); + expect(preview).not.toHaveBeenCalled(); + expect(requestRender).not.toHaveBeenCalled(); }); it('saves the focused theme and closes', async () => { @@ -99,7 +114,7 @@ describe('TuiThemePicker', () => { const { picker, save, onClose } = build(); picker.handleInput('\u001b[B'); - picker.handleInput('a'); + picker.handleInput('\u001b[D'); picker.handleInput('\r'); await vi.waitFor(() => expect(onClose).toHaveBeenCalled()); @@ -119,7 +134,7 @@ describe('TuiThemePicker', () => { const { picker, preview, setAppearance, save, onClose } = build(); picker.handleInput('\u001b[B'); - picker.handleInput('a'); + picker.handleInput('\u001b[D'); preview.mockClear(); picker.handleInput('\u001b');